9212 lines
435 KiB
JavaScript
9212 lines
435 KiB
JavaScript
const http = require('http');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const { Server } = require('socket.io');
|
||
|
||
const PORT = parseInt(process.env.PORT, 10) || 3001;
|
||
/** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */
|
||
const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0';
|
||
const BASE_PATH = '/Game';
|
||
const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
|
||
const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
|
||
const adminTokens = new Set();
|
||
/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
|
||
const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
|
||
/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */
|
||
const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
|
||
/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */
|
||
/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */
|
||
const DETECTIVE_MINIGAME_POOL = [
|
||
{ key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' },
|
||
{ key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' },
|
||
{ key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' },
|
||
{ key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' },
|
||
{ key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' },
|
||
{ key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' },
|
||
{ key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' },
|
||
];
|
||
/** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */
|
||
function normalizeForcedMinigameKeys(v) {
|
||
let arr;
|
||
if (Array.isArray(v)) arr = v;
|
||
else if (v != null && String(v).trim()) arr = [v, v, v];
|
||
else arr = [];
|
||
const one = (k) => {
|
||
const s = (k == null ? '' : String(k)).trim();
|
||
return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : '';
|
||
};
|
||
return [one(arr[0]), one(arr[1]), one(arr[2])];
|
||
}
|
||
const LOBBY_SLOT_TOTAL = 6;
|
||
|
||
/* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้
|
||
ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */
|
||
const LIVE_COUNTDOWN_MS = 3500;
|
||
const LOBBY_THEME_COUNT = 8;
|
||
const LOBBY_SKIN_COUNT = 3;
|
||
|
||
/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
|
||
function getTakenLobbyThemeIndices(space, excludePeerId) {
|
||
const taken = new Set();
|
||
if (!space) return taken;
|
||
space.peers.forEach((p, id) => {
|
||
if (excludePeerId && id === excludePeerId) return;
|
||
const ti = parseInt(p.lobbyColorThemeIndex, 10);
|
||
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
|
||
});
|
||
const bots = space.lobbyBotThemeBySlot;
|
||
if (bots && typeof bots === 'object') {
|
||
Object.keys(bots).forEach((k) => {
|
||
const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
|
||
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
|
||
});
|
||
}
|
||
return taken;
|
||
}
|
||
|
||
function pickFreeLobbyThemeIndex(space, excludePeerId) {
|
||
const taken = getTakenLobbyThemeIndices(space, excludePeerId);
|
||
for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
|
||
if (!taken.has(i)) return i;
|
||
}
|
||
const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
|
||
return ((n - 1) % LOBBY_THEME_COUNT) + 1;
|
||
}
|
||
|
||
function pickLobbySkinIndexForSlot(slotIndex) {
|
||
const s = Number(slotIndex);
|
||
if (!Number.isFinite(s)) return 1;
|
||
return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
|
||
}
|
||
|
||
/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น */
|
||
function syncLobbyBotThemeSlots(space) {
|
||
if (!space) return [];
|
||
const botCount = effectiveBotSlotCount(space);
|
||
if (!space.lobbyBotThemeBySlot || typeof space.lobbyBotThemeBySlot !== 'object') {
|
||
space.lobbyBotThemeBySlot = {};
|
||
}
|
||
const taken = getTakenLobbyThemeIndices(space);
|
||
const out = [];
|
||
for (let i = 0; i < botCount; i++) {
|
||
let slot = space.lobbyBotThemeBySlot[i];
|
||
let themeIndex = parseInt(slot && slot.themeIndex, 10);
|
||
if (!(themeIndex >= 1 && themeIndex <= LOBBY_THEME_COUNT) || taken.has(themeIndex)) {
|
||
themeIndex = pickFreeLobbyThemeIndex(space);
|
||
}
|
||
taken.add(themeIndex);
|
||
const skinToneIndex = (() => {
|
||
const si = parseInt(slot && slot.skinToneIndex, 10);
|
||
return (si >= 1 && si <= LOBBY_SKIN_COUNT) ? si : pickLobbySkinIndexForSlot(i);
|
||
})();
|
||
space.lobbyBotThemeBySlot[i] = { themeIndex, skinToneIndex };
|
||
out.push({ themeIndex, skinToneIndex });
|
||
}
|
||
Object.keys(space.lobbyBotThemeBySlot).forEach((k) => {
|
||
if (parseInt(k, 10) >= botCount) delete space.lobbyBotThemeBySlot[k];
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function emitLobbyTintSync(spaceId, space) {
|
||
const peersTint = [];
|
||
space.peers.forEach((p, id) => {
|
||
peersTint.push({
|
||
id,
|
||
lobbyColorThemeIndex: p.lobbyColorThemeIndex || null,
|
||
lobbySkinToneIndex: p.lobbySkinToneIndex || null,
|
||
});
|
||
});
|
||
io.to(spaceId).emit('lobby-tint-sync', {
|
||
peers: peersTint,
|
||
lobbyBotThemes: syncLobbyBotThemeSlots(space),
|
||
});
|
||
}
|
||
|
||
/** จำนวน Bot จาก Create Room — ถ้าไม่เคยบันทึก ใช้ 6 − maxPlayers (เช่น 3 คน → 3 Bot) */
|
||
function effectiveBotSlotCount(space) {
|
||
let n = parseInt(space && space.botSlotCount, 10);
|
||
if (isNaN(n) || n < 0) n = 0;
|
||
const maxP = space && space.maxPlayers ? parseInt(space.maxPlayers, 10) : 0;
|
||
if (n === 0 && maxP > 0 && maxP < LOBBY_SLOT_TOTAL) {
|
||
n = LOBBY_SLOT_TOTAL - maxP;
|
||
if (space) space.botSlotCount = n;
|
||
}
|
||
return Math.min(LOBBY_SLOT_TOTAL, Math.max(0, n));
|
||
}
|
||
/** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */
|
||
const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi';
|
||
/** Jump Survival ภารกิจ Jumper — lobby READY / START เดียวกับ Stack Tower */
|
||
const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
|
||
/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */
|
||
const SPACE_EMPTY_TTL_MS = 45000;
|
||
// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025)
|
||
const AI_MODELS = [
|
||
'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano',
|
||
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
|
||
'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research',
|
||
'o1', 'o1-mini', 'o1-pro',
|
||
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4',
|
||
'gpt-3.5-turbo',
|
||
];
|
||
|
||
function loadAiSettings() {
|
||
try {
|
||
if (fs.existsSync(AI_SETTINGS_PATH)) {
|
||
const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8');
|
||
return JSON.parse(raw);
|
||
}
|
||
} catch (e) { console.error('loadAiSettings', e.message); }
|
||
return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' };
|
||
}
|
||
|
||
function saveAiSettings(settings) {
|
||
try {
|
||
const dir = path.dirname(AI_SETTINGS_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||
} catch (e) { console.error('saveAiSettings', e.message); }
|
||
}
|
||
|
||
function getAdminToken(cookieHeader) {
|
||
if (!cookieHeader) return null;
|
||
const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/);
|
||
return m && adminTokens.has(m[1]) ? m[1] : null;
|
||
}
|
||
|
||
const MAPS_DIR = path.join(__dirname, 'data', 'maps');
|
||
const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters');
|
||
/** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */
|
||
const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
|
||
const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right'];
|
||
|
||
/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */
|
||
function buildCharacterLayerManifest(id, files) {
|
||
if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} };
|
||
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
|
||
const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|');
|
||
const byDir = {};
|
||
for (const dir of CHAR_MANIFEST_DIRS) {
|
||
const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const multiHits = files.filter((f) => multiRe.test(f));
|
||
if (multiHits.length) {
|
||
let maxFi = -1;
|
||
const perFrame = new Map();
|
||
multiHits.forEach((f) => {
|
||
const m = f.match(multiRe);
|
||
if (!m) return;
|
||
const fi = parseInt(m[1], 10);
|
||
if (!Number.isFinite(fi) || fi < 0 || fi > 200) return;
|
||
const layer = m[2];
|
||
if (fi > maxFi) maxFi = fi;
|
||
if (!perFrame.has(fi)) perFrame.set(fi, {});
|
||
perFrame.get(fi)[layer] = f;
|
||
});
|
||
const frames = [];
|
||
for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {});
|
||
byDir[dir] = { multi: true, frames };
|
||
} else {
|
||
const frame0 = {};
|
||
files.forEach((f) => {
|
||
if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return;
|
||
const m = f.match(singleRe);
|
||
if (m) frame0[m[1]] = f;
|
||
});
|
||
if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] };
|
||
}
|
||
}
|
||
const byDirIdle = {};
|
||
for (const dir of CHAR_MANIFEST_DIRS) {
|
||
const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const multiIdleHits = files.filter((f) => multiIdleRe.test(f));
|
||
if (multiIdleHits.length) {
|
||
let maxFi = -1;
|
||
const perFrame = new Map();
|
||
multiIdleHits.forEach((f) => {
|
||
const m = f.match(multiIdleRe);
|
||
if (!m) return;
|
||
const fi = parseInt(m[1], 10);
|
||
if (!Number.isFinite(fi) || fi < 0 || fi > 200) return;
|
||
const layer = m[2];
|
||
if (fi > maxFi) maxFi = fi;
|
||
if (!perFrame.has(fi)) perFrame.set(fi, {});
|
||
perFrame.get(fi)[layer] = f;
|
||
});
|
||
const frames = [];
|
||
for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {});
|
||
byDirIdle[dir] = { multi: true, frames };
|
||
} else {
|
||
const frame0 = {};
|
||
files.forEach((f) => {
|
||
if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return;
|
||
const m = f.match(singleIdleRe);
|
||
if (m) frame0[m[1]] = f;
|
||
});
|
||
if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] };
|
||
}
|
||
}
|
||
return { ok: true, id, byDir, byDirIdle };
|
||
}
|
||
|
||
/** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */
|
||
const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets');
|
||
const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets');
|
||
const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json');
|
||
/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */
|
||
const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets');
|
||
const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json');
|
||
/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */
|
||
const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH
|
||
? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim())
|
||
: '';
|
||
const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json');
|
||
|
||
/* ===== Quiz Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */
|
||
const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json');
|
||
/** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */
|
||
let qbLeaderboardStore = {};
|
||
function loadQbLeaderboard() {
|
||
try {
|
||
if (fs.existsSync(QB_LEADERBOARD_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8'));
|
||
qbLeaderboardStore = (j && typeof j === 'object') ? j : {};
|
||
}
|
||
} catch (e) { qbLeaderboardStore = {}; }
|
||
}
|
||
function saveQbLeaderboard() {
|
||
try {
|
||
const dir = path.dirname(QB_LEADERBOARD_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8');
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
function qbLeaderboardSubmit(roomId, name, score, characterId) {
|
||
roomId = String(roomId || '').trim();
|
||
name = String(name || '').trim().slice(0, 24);
|
||
score = Math.max(0, Math.floor(Number(score) || 0));
|
||
if (!roomId || !name) return;
|
||
if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = [];
|
||
const list = qbLeaderboardStore[roomId];
|
||
const ex = list.find((e) => e && e.name === name);
|
||
if (ex) {
|
||
if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); }
|
||
} else {
|
||
list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() });
|
||
}
|
||
list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts));
|
||
if (list.length > 500) list.length = 500;
|
||
saveQbLeaderboard();
|
||
}
|
||
function qbLeaderboardGet(roomId, name) {
|
||
roomId = String(roomId || '').trim();
|
||
const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : [];
|
||
const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' }));
|
||
let me = null;
|
||
const nm = String(name || '').trim();
|
||
if (nm) {
|
||
const idx = list.findIndex((e) => e && e.name === nm);
|
||
if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' };
|
||
}
|
||
return { top, me, total: list.length };
|
||
}
|
||
loadQbLeaderboard();
|
||
|
||
/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */
|
||
const os = require('os');
|
||
let __prevCpuSample = null;
|
||
function __cpuSample() {
|
||
const cpus = os.cpus() || [];
|
||
let idle = 0, total = 0;
|
||
for (const c of cpus) {
|
||
for (const k in c.times) total += c.times[k];
|
||
idle += c.times.idle;
|
||
}
|
||
return { idle, total };
|
||
}
|
||
/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */
|
||
function __cpuPercent() {
|
||
const cur = __cpuSample();
|
||
const prev = __prevCpuSample;
|
||
__prevCpuSample = cur;
|
||
if (!prev) return null;
|
||
const idleD = cur.idle - prev.idle;
|
||
const totalD = cur.total - prev.total;
|
||
if (totalD <= 0) return null;
|
||
return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100)));
|
||
}
|
||
__cpuSample(); // เก็บ baseline แรก
|
||
function getDiskStats() {
|
||
try {
|
||
if (typeof fs.statfsSync !== 'function') return null;
|
||
const s = fs.statfsSync(__dirname);
|
||
const total = s.blocks * s.bsize;
|
||
const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด
|
||
const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้
|
||
const used = total - free;
|
||
if (!(total > 0)) return null;
|
||
return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) };
|
||
} catch (e) { return null; }
|
||
}
|
||
function getSystemStats() {
|
||
const totalMem = os.totalmem();
|
||
const freeMem = os.freemem();
|
||
const usedMem = totalMem - freeMem;
|
||
const load = os.loadavg();
|
||
const cores = (os.cpus() || []).length || 1;
|
||
const cpuPct = __cpuPercent();
|
||
const mu = process.memoryUsage();
|
||
return {
|
||
ok: true,
|
||
cpu: {
|
||
percent: cpuPct, // 0-100 หรือ null (ครั้งแรก)
|
||
cores,
|
||
load1: Math.round(load[0] * 100) / 100,
|
||
load5: Math.round(load[1] * 100) / 100,
|
||
load15: Math.round(load[2] * 100) / 100,
|
||
loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))),
|
||
},
|
||
mem: {
|
||
total: totalMem,
|
||
free: freeMem,
|
||
used: usedMem,
|
||
usedPercent: Math.round((usedMem / totalMem) * 100),
|
||
},
|
||
disk: getDiskStats(),
|
||
node: {
|
||
rss: mu.rss,
|
||
heapUsed: mu.heapUsed,
|
||
rssPercent: Math.round((mu.rss / totalMem) * 100),
|
||
version: process.version,
|
||
uptime: Math.round(process.uptime()),
|
||
},
|
||
uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) },
|
||
hostname: os.hostname(),
|
||
platform: os.platform(),
|
||
ts: Date.now(),
|
||
};
|
||
}
|
||
|
||
/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */
|
||
const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
|
||
const maps = new Map();
|
||
const defaultMap = () => ({
|
||
width: 20, height: 15, tileSize: 32, gameType: 'zep',
|
||
characterCells: 1, characterCellsW: 1, characterCellsH: 1,
|
||
/** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */
|
||
blockPlayerSeparationW: 0,
|
||
blockPlayerSeparationH: 0,
|
||
ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 },
|
||
gridImageLibrary: [],
|
||
gridImageSprites: [],
|
||
gridImageCells: [],
|
||
quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [],
|
||
quizCarryHubArea: [], quizCarryOptionArea: [],
|
||
/** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */
|
||
carryEmbedCountdownArea: [],
|
||
/** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */
|
||
quizBattleDomeArea: [],
|
||
/** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */
|
||
quizBattlePathArea: [],
|
||
stackReleaseArea: [], stackLandArea: [],
|
||
/** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */
|
||
jumpSurvivePlatforms: [],
|
||
jumpSurvivePlatformArea: [],
|
||
/** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */
|
||
jumpSurvivePlatformVariantArea: [],
|
||
jumpSurviveHazardArea: [],
|
||
jumpSurviveRisePxPerSec: 42,
|
||
jumpSurviveGravity: 3200,
|
||
/** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */
|
||
jumpSurviveJumpImpulse: 0,
|
||
jumpSurviveMovePxPerSec: 200,
|
||
});
|
||
|
||
/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */
|
||
const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5];
|
||
const SPECIAL_QUIZ_CASES = [1, 2, 3];
|
||
const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary'];
|
||
const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/';
|
||
|
||
function specialCardTxtFilename(id) {
|
||
return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`;
|
||
}
|
||
|
||
function specialCardImageUrl(id) {
|
||
return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`;
|
||
}
|
||
|
||
function specialCardTxtImageUrl(id) {
|
||
return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`;
|
||
}
|
||
|
||
function specialQuizSetKey(level, caseId) {
|
||
return 'L' + level + '_C' + caseId;
|
||
}
|
||
|
||
/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */
|
||
function sanitizeSpecialQuizQuestion(q) {
|
||
if (!q || typeof q !== 'object') return null;
|
||
const text = String(q.text == null ? '' : q.text).trim();
|
||
if (!text) return null;
|
||
const type = q.type === 'mcq' ? 'mcq' : 'tf';
|
||
if (type === 'mcq') {
|
||
/* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */
|
||
const choices = (Array.isArray(q.choices) ? q.choices : [])
|
||
.map((c) => String(c == null ? '' : c).trim())
|
||
.slice(0, 8);
|
||
while (choices.length && choices[choices.length - 1] === '') choices.pop();
|
||
if (choices.filter((c) => c).length < 2) return null;
|
||
let ci = Number(q.correctIndex);
|
||
if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') {
|
||
ci = choices.findIndex((c) => c);
|
||
if (ci < 0) ci = 0;
|
||
}
|
||
return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci };
|
||
}
|
||
return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue };
|
||
}
|
||
|
||
function sanitizeSpecialCard(c) {
|
||
const src = (c && typeof c === 'object') ? c : {};
|
||
const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare';
|
||
let cardId = Number(src.cardId);
|
||
if (!(cardId >= 1 && cardId <= 7)) cardId = null;
|
||
let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600);
|
||
let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600);
|
||
if (cardId) {
|
||
if (!imageUrl) imageUrl = specialCardImageUrl(cardId);
|
||
if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId);
|
||
} else if (imageUrl) {
|
||
const m = imageUrl.match(/\/card-(\d)\.png/i);
|
||
if (m) {
|
||
cardId = Number(m[1]);
|
||
imageUrl = specialCardImageUrl(cardId);
|
||
if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId);
|
||
}
|
||
}
|
||
return {
|
||
cardId: cardId || null,
|
||
th: String(src.th == null ? '' : src.th).trim().slice(0, 160),
|
||
en: String(src.en == null ? '' : src.en).trim().slice(0, 160),
|
||
rarity,
|
||
imageUrl,
|
||
txtImageUrl,
|
||
};
|
||
}
|
||
|
||
/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */
|
||
function sanitizeSpecialQuizSets(obj) {
|
||
const out = {};
|
||
SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => {
|
||
const key = specialQuizSetKey(L, C);
|
||
const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {};
|
||
const questions = (Array.isArray(src.questions) ? src.questions : [])
|
||
.map(sanitizeSpecialQuizQuestion)
|
||
.filter(Boolean)
|
||
.slice(0, 50);
|
||
out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) };
|
||
}));
|
||
return out;
|
||
}
|
||
|
||
/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */
|
||
function getSpecialQuizSet(settings, level, caseId) {
|
||
const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({});
|
||
/* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่)
|
||
key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */
|
||
const cid = Math.max(1, Math.min(15, Number(caseId) || 1));
|
||
const L = Math.ceil(cid / 3);
|
||
const C = ((cid - 1) % 3) + 1;
|
||
return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) };
|
||
}
|
||
|
||
/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */
|
||
/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */
|
||
/** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */
|
||
const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7'];
|
||
const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police'];
|
||
/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */
|
||
/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */
|
||
const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3;
|
||
|
||
/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */
|
||
function normalizeTestSpecialCardByMap(v) {
|
||
const out = {};
|
||
if (v && typeof v === 'object') {
|
||
SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => {
|
||
const c = Math.floor(Number(v[mid]));
|
||
if (c >= 1 && c <= 7) out[mid] = c;
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */
|
||
const SPECIAL_QUIZ_ANSWER_MS = 20000;
|
||
/** หน่วงโชว์เฉลยก่อนข้อต่อไป */
|
||
const SPECIAL_QUIZ_REVEAL_MS = 2600;
|
||
|
||
/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */
|
||
function specialQuizNormalizeQuestion(q) {
|
||
if (!q || typeof q !== 'object') return null;
|
||
const text = String(q.text == null ? '' : q.text).trim();
|
||
if (!text) return null;
|
||
if (q.type === 'mcq') {
|
||
const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== '');
|
||
if (choices.length < 2) return null;
|
||
let ci = Number(q.correctIndex);
|
||
if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0;
|
||
return { text, choices, correctIndex: ci };
|
||
}
|
||
return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 };
|
||
}
|
||
|
||
function specialQuizShuffle(arr) {
|
||
const a = arr.slice();
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function specialQuizTileWalkable(md, x, y) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
if (x < 0 || x >= w || y < 0 || y >= h) return false;
|
||
const obj = md.objects && md.objects[y];
|
||
if (obj && obj[x] === 1) return false;
|
||
/* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */
|
||
const bp = md.blockPlayer && md.blockPlayer[y];
|
||
if (bp && bp[x] === 1) return false;
|
||
return true;
|
||
}
|
||
|
||
/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */
|
||
function specialQuizTileInGameZone(md, x, y) {
|
||
if (quizCellOn(md.quizTrueArea || [], x, y)) return true;
|
||
if (quizCellOn(md.quizFalseArea || [], x, y)) return true;
|
||
if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true;
|
||
const oa = md.quizCarryOptionArea;
|
||
if (oa && oa[y] && oa[y][x]) return true;
|
||
return false;
|
||
}
|
||
|
||
/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */
|
||
function pickSpecialQuizSpawnTile(md, occupiedLaneCount) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
/* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer →
|
||
ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้
|
||
→ เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */
|
||
if (md.gameType === 'quiz_carry') {
|
||
const nearMarkedTable = (x, y) => {
|
||
for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) {
|
||
if (!Array.isArray(g)) continue;
|
||
for (let dy = -2; dy <= 2; dy++) {
|
||
const gy = g[y + dy];
|
||
if (!gy) continue;
|
||
for (let dx = -2; dx <= 2; dx++) {
|
||
if (gy[x + dx]) return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length)
|
||
? md.lobbyPlayerSpawns
|
||
: [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }];
|
||
const qcCands = [];
|
||
seeds.forEach((s) => {
|
||
const bx = Math.floor(Number(s.x));
|
||
const by = Math.floor(Number(s.y));
|
||
for (let dy = -5; dy <= 5; dy++) {
|
||
for (let dx = -5; dx <= 5; dx++) {
|
||
const dist = Math.abs(dx) + Math.abs(dy);
|
||
if (dist < 2 || dist > 6) continue;
|
||
const xx = bx + dx;
|
||
const yy = by + dy;
|
||
if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) {
|
||
qcCands.push({ x: xx, y: yy });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
if (qcCands.length) {
|
||
const p = qcCands[Math.floor(Math.random() * qcCands.length)];
|
||
return { x: p.x + 0.5, y: p.y + 0.5 };
|
||
}
|
||
}
|
||
/* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns
|
||
(ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */
|
||
if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) {
|
||
/* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */
|
||
const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0)
|
||
? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount))
|
||
: md.gauntletPlayerSpawns.length;
|
||
const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes);
|
||
const rows = Array.from(new Set(usableSpawns
|
||
.map((s) => Math.floor(Number(s.y)))
|
||
.filter((n) => Number.isFinite(n) && n >= 0 && n < h)));
|
||
const laneCands = [];
|
||
const xMin = Math.max(4, Math.floor(w * 0.30));
|
||
const xMax = Math.min(w - 2, Math.floor(w * 0.72));
|
||
rows.forEach((ry) => {
|
||
for (let x = xMin; x <= xMax; x++) {
|
||
if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry });
|
||
}
|
||
});
|
||
if (laneCands.length) {
|
||
const p = laneCands[Math.floor(Math.random() * laneCands.length)];
|
||
return { x: p.x + 0.5, y: p.y + 0.5 };
|
||
}
|
||
}
|
||
/* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง
|
||
(ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */
|
||
if (md.gameType === 'stack') {
|
||
const zc = [];
|
||
[md.stackReleaseArea, md.stackLandArea].forEach((g) => {
|
||
if (Array.isArray(g)) {
|
||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y });
|
||
}
|
||
});
|
||
if (zc.length) {
|
||
let cx2 = 0;
|
||
let cy2 = 0;
|
||
zc.forEach((c) => { cx2 += c.x; cy2 += c.y; });
|
||
cx2 = Math.round(cx2 / zc.length);
|
||
cy2 = Math.round(cy2 / zc.length);
|
||
for (let rad = 0; rad <= 6; rad++) {
|
||
for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) {
|
||
for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) {
|
||
if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 };
|
||
}
|
||
}
|
||
}
|
||
/* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */
|
||
return { x: cx2 + 0.5, y: cy2 + 0.5 };
|
||
}
|
||
}
|
||
/* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack
|
||
(สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */
|
||
if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) {
|
||
const painted = [];
|
||
const paintedWalkable = [];
|
||
for (let yy = 0; yy < h; yy++) {
|
||
const row = md.specialQuizSpawnArea[yy];
|
||
if (!row) continue;
|
||
for (let xx = 0; xx < w; xx++) {
|
||
if (!row[xx]) continue;
|
||
painted.push({ x: xx, y: yy });
|
||
if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy });
|
||
}
|
||
}
|
||
const pickFrom = paintedWalkable.length ? paintedWalkable : painted;
|
||
if (pickFrom.length) {
|
||
const p = pickFrom[Math.floor(Math.random() * pickFrom.length)];
|
||
return { x: p.x + 0.5, y: p.y + 0.5 };
|
||
}
|
||
}
|
||
/* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้
|
||
fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */
|
||
let sx = -1;
|
||
let sy = -1;
|
||
const spawnGrid = md.spawnArea;
|
||
if (Array.isArray(spawnGrid)) {
|
||
for (let y = 0; y < h && sx < 0; y++) {
|
||
const row = spawnGrid[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; }
|
||
}
|
||
}
|
||
}
|
||
if (sx < 0) {
|
||
const sp = md.spawn || { x: 1, y: 1 };
|
||
const fx = Math.floor(Number(sp.x));
|
||
const fy = Math.floor(Number(sp.y));
|
||
if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; }
|
||
}
|
||
if (sx < 0) {
|
||
/* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก
|
||
เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */
|
||
const ccx = Math.floor(w / 2);
|
||
const ccy = Math.floor(h / 2);
|
||
const maxRad = Math.max(w, h);
|
||
for (let rad = 0; rad <= maxRad && sx < 0; rad++) {
|
||
for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) {
|
||
for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) {
|
||
if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (sx < 0) {
|
||
for (let y = 0; y < h && sx < 0; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; }
|
||
}
|
||
}
|
||
}
|
||
if (sx < 0) return null;
|
||
const q = [[sx, sy]];
|
||
const seen = new Set([sx + ',' + sy]);
|
||
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||
const cands = [];
|
||
while (q.length) {
|
||
const [cx, cy] = q.shift();
|
||
for (let di = 0; di < dirs.length; di++) {
|
||
const nx = cx + dirs[di][0];
|
||
const ny = cy + dirs[di][1];
|
||
const k = nx + ',' + ny;
|
||
if (seen.has(k)) continue;
|
||
if (!specialQuizTileWalkable(md, nx, ny)) continue;
|
||
seen.add(k);
|
||
q.push([nx, ny]);
|
||
if (!specialQuizTileInGameZone(md, nx, ny)) {
|
||
const dist = Math.abs(nx - sx) + Math.abs(ny - sy);
|
||
if (dist >= 2) cands.push({ x: nx, y: ny });
|
||
}
|
||
}
|
||
}
|
||
if (!cands.length) return null;
|
||
/* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ ·
|
||
ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */
|
||
const near = cands.filter((c) => {
|
||
const d = Math.abs(c.x - sx) + Math.abs(c.y - sy);
|
||
return d >= 2 && d <= 8;
|
||
});
|
||
const pool = near.length ? near : cands;
|
||
const pick = pool[Math.floor(Math.random() * pool.length)];
|
||
return { x: pick.x + 0.5, y: pick.y + 0.5 };
|
||
}
|
||
|
||
function clearSpecialQuizTimers(space) {
|
||
if (Array.isArray(space.specialQuizTimers)) {
|
||
space.specialQuizTimers.forEach((t) => clearTimeout(t));
|
||
}
|
||
space.specialQuizTimers = [];
|
||
if (space.specialQuizContinueTimer) {
|
||
clearTimeout(space.specialQuizContinueTimer);
|
||
space.specialQuizContinueTimer = null;
|
||
}
|
||
space.specialQuizAwaitContinue = false;
|
||
}
|
||
|
||
/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */
|
||
function specialQuizIconPayload(space) {
|
||
const sq = space && space.specialQuiz;
|
||
if (!sq || sq.triggered || sq.done || !sq.appeared) return null;
|
||
return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 };
|
||
}
|
||
|
||
/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง */
|
||
function maybeSpawnSpecialQuizForRun(space) {
|
||
clearSpecialQuizTimers(space);
|
||
space.specialQuiz = null;
|
||
space.specialCardAwardedThisRun = null;
|
||
space.timeDilationPendingSec = 0;
|
||
/* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้
|
||
หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */
|
||
if (!space || !space.detectiveMinigameActive) return;
|
||
const mapId = String(space.mapId || '').trim();
|
||
if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return;
|
||
const level = Number(space.detectiveLobbyLevel) || 1;
|
||
const caseId = Number(space.detectiveLobbyCaseId) || 1;
|
||
const settings = loadQuizSettings();
|
||
const set = getSpecialQuizSet(settings, level, caseId);
|
||
const questions = (set && Array.isArray(set.questions) ? set.questions : [])
|
||
.map(specialQuizNormalizeQuestion)
|
||
.filter(Boolean);
|
||
let card = set && set.specialCard;
|
||
/* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */
|
||
const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId];
|
||
const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7);
|
||
if (testForcedCard) {
|
||
const def = specialCardDef(testCardId) || {};
|
||
card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th });
|
||
}
|
||
const hasCard = !!(card && (card.cardId || card.imageUrl));
|
||
if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; }
|
||
const forced = !!space.forceSpecialQuiz || testForcedCard;
|
||
const roll = Math.random();
|
||
if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; }
|
||
if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; }
|
||
/* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */
|
||
const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
|
||
const tile = pickSpecialQuizSpawnTile(md, occupiedLanes);
|
||
if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; }
|
||
const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)];
|
||
console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y);
|
||
space.specialQuiz = {
|
||
iconType,
|
||
x: tile.x,
|
||
y: tile.y,
|
||
level,
|
||
caseId,
|
||
card,
|
||
triggered: false,
|
||
done: false,
|
||
session: null,
|
||
};
|
||
}
|
||
|
||
function emitSpecialQuizIcon(sid, space) {
|
||
const payload = specialQuizIconPayload(space);
|
||
if (payload) io.to(sid).emit('special-quiz-icon', payload);
|
||
}
|
||
|
||
/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */
|
||
const SPECIAL_QUIZ_ICON_DELAY_MS = 2000;
|
||
function scheduleSpecialQuizIconAppearAndExpiry(sid, space) {
|
||
const sq = space && space.specialQuiz;
|
||
if (!sq || sq.triggered || sq.done) return;
|
||
const tAppear = setTimeout(() => {
|
||
const sp = spaces.get(sid);
|
||
if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return;
|
||
const cur = sp.specialQuiz;
|
||
const sec = readSpecialQuizIconExpireSec();
|
||
cur.appeared = true;
|
||
cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0;
|
||
emitSpecialQuizIcon(sid, sp);
|
||
if (sec > 0) {
|
||
const tExpire = setTimeout(() => {
|
||
const sp2 = spaces.get(sid);
|
||
if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return;
|
||
sp2.specialQuiz = null;
|
||
io.to(sid).emit('special-quiz-icon-clear', {});
|
||
console.log('[special-quiz] icon EXPIRED after', sec + 's');
|
||
}, sec * 1000);
|
||
if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = [];
|
||
sp.specialQuizTimers.push(tExpire);
|
||
}
|
||
}, SPECIAL_QUIZ_ICON_DELAY_MS);
|
||
if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = [];
|
||
space.specialQuizTimers.push(tAppear);
|
||
}
|
||
|
||
/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */
|
||
function buildSpecialQuizRoster(space) {
|
||
const roster = [];
|
||
space.peers.forEach((p, id) => {
|
||
roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false });
|
||
});
|
||
const botN = effectiveBotSlotCount(space);
|
||
for (let i = 0; i < botN; i++) {
|
||
roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true });
|
||
}
|
||
return roster;
|
||
}
|
||
|
||
function specialQuizRosterPayload(sess) {
|
||
return (sess && Array.isArray(sess.roster) ? sess.roster : [])
|
||
.map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot }));
|
||
}
|
||
|
||
/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */
|
||
function specialQuizExpectedIds(space, sess) {
|
||
const realAlive = new Set(space.peers.keys());
|
||
const out = [];
|
||
(sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => {
|
||
if (m.isBot) out.push(m.id);
|
||
else if (realAlive.has(m.id)) out.push(m.id);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */
|
||
function maybeResolveSpecialQuizAllAnswered(sid, space) {
|
||
const sess = space.specialQuiz && space.specialQuiz.session;
|
||
if (!sess || sess.resolving) return;
|
||
const expected = specialQuizExpectedIds(space, sess);
|
||
const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid]));
|
||
if (allAnswered) resolveSpecialQuizQuestion(sid, space, false);
|
||
}
|
||
|
||
/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */
|
||
function scheduleSpecialQuizBotAnswers(sid, space, sess, q) {
|
||
const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot);
|
||
if (!bots.length) return;
|
||
const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500);
|
||
bots.forEach((b) => {
|
||
const delay = 900 + Math.floor(Math.random() * windowMs);
|
||
const t = setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session;
|
||
if (!spNow || sNow !== sess || sess.resolving) return;
|
||
if (Number.isInteger(sess.answers[b.id])) return;
|
||
sess.answers[b.id] = q.correctIndex;
|
||
io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true });
|
||
maybeResolveSpecialQuizAllAnswered(sid, spNow);
|
||
}, delay);
|
||
space.specialQuizTimers.push(t);
|
||
});
|
||
}
|
||
|
||
function sendSpecialQuizQuestion(sid, space) {
|
||
const sq = space.specialQuiz;
|
||
const sess = sq && sq.session;
|
||
if (!sess) return;
|
||
const q = sess.questions[sess.qIndex];
|
||
if (!q) { endSpecialQuizSession(sid, space, false); return; }
|
||
sess.answers = {};
|
||
sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS;
|
||
io.to(sid).emit('special-quiz-question', {
|
||
index: sess.qIndex,
|
||
total: sess.questions.length,
|
||
text: q.text,
|
||
choices: q.choices,
|
||
endsAt: sess.phaseEndsAt,
|
||
iconType: sq.iconType,
|
||
roster: specialQuizRosterPayload(sess),
|
||
});
|
||
scheduleSpecialQuizBotAnswers(sid, space, sess, q);
|
||
const t = setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return;
|
||
resolveSpecialQuizQuestion(sid, spNow, true);
|
||
}, SPECIAL_QUIZ_ANSWER_MS + 80);
|
||
space.specialQuizTimers.push(t);
|
||
}
|
||
|
||
/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */
|
||
function resolveSpecialQuizQuestion(sid, space, fromTimeout) {
|
||
const sq = space.specialQuiz;
|
||
const sess = sq && sq.session;
|
||
if (!sess || sess.resolving) return;
|
||
sess.resolving = true;
|
||
clearSpecialQuizTimers(space);
|
||
const q = sess.questions[sess.qIndex];
|
||
const expected = specialQuizExpectedIds(space, sess);
|
||
let allCorrect = expected.length > 0;
|
||
expected.forEach((pid) => {
|
||
const a = sess.answers[pid];
|
||
if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false;
|
||
});
|
||
io.to(sid).emit('special-quiz-result', {
|
||
index: sess.qIndex,
|
||
correctIndex: q.correctIndex,
|
||
answers: { ...sess.answers },
|
||
roster: specialQuizRosterPayload(sess),
|
||
allCorrect,
|
||
timeout: !!fromTimeout,
|
||
});
|
||
if (!allCorrect) {
|
||
const t = setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || spNow.specialQuiz !== sq) return;
|
||
endSpecialQuizSession(sid, spNow, false);
|
||
}, SPECIAL_QUIZ_REVEAL_MS);
|
||
space.specialQuizTimers.push(t);
|
||
return;
|
||
}
|
||
sess.qIndex++;
|
||
if (sess.qIndex >= sess.questions.length) {
|
||
const t = setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || spNow.specialQuiz !== sq) return;
|
||
endSpecialQuizSession(sid, spNow, true);
|
||
}, SPECIAL_QUIZ_REVEAL_MS);
|
||
space.specialQuizTimers.push(t);
|
||
return;
|
||
}
|
||
const t = setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return;
|
||
sess.resolving = false;
|
||
sendSpecialQuizQuestion(sid, spNow);
|
||
}, SPECIAL_QUIZ_REVEAL_MS);
|
||
space.specialQuizTimers.push(t);
|
||
}
|
||
|
||
/**
|
||
* นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน:
|
||
* minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation)
|
||
* now = ใช้ทันทีตอนได้รับ (Fund coins)
|
||
* after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence)
|
||
* pre_trial = ก่อนโหวตพิจารณาคดี (Silence)
|
||
* trial_vote = ระหว่างโหวตพิจารณาคดี (Extension)
|
||
* trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin)
|
||
* lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban)
|
||
*/
|
||
const SPECIAL_CARD_DEFS = {
|
||
1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' },
|
||
2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' },
|
||
3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' },
|
||
4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' },
|
||
5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' },
|
||
6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' },
|
||
7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' },
|
||
};
|
||
|
||
function specialCardDef(cardId) {
|
||
return SPECIAL_CARD_DEFS[Number(cardId)] || null;
|
||
}
|
||
|
||
/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */
|
||
function specialCardClientPayload(card) {
|
||
if (!card) return null;
|
||
const def = specialCardDef(card.cardId) || {};
|
||
return {
|
||
cardId: card.cardId || null,
|
||
th: card.th || def.th || '',
|
||
en: card.en || '',
|
||
rarity: card.rarity || 'common',
|
||
imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''),
|
||
txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''),
|
||
effectKey: def.key || '',
|
||
effectWhen: def.when || '',
|
||
addSec: def.addSec || 0,
|
||
desc: def.th || '',
|
||
};
|
||
}
|
||
|
||
/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */
|
||
const TIME_DILATION_MAX_PENDING_SEC = 30;
|
||
|
||
/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */
|
||
function applySpecialCardImmediate(sid, space, card) {
|
||
const def = specialCardDef(card && card.cardId);
|
||
if (!def) return;
|
||
if (def.when === 'minigame' && def.key === 'time_dilation') {
|
||
/* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง
|
||
cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */
|
||
space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10));
|
||
io.to(sid).emit('special-card-applied', {
|
||
card: specialCardClientPayload(card),
|
||
addSec: def.addSec || 10,
|
||
});
|
||
consumePendingSpecialCard(space, card.cardId);
|
||
return;
|
||
}
|
||
if (def.when === 'now' && def.key === 'fund') {
|
||
/* +COINS — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม
|
||
noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */
|
||
const coins = def.coins || 10;
|
||
const awards = [...space.peers.values()]
|
||
.filter((p) => p.playerKey)
|
||
.map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true }));
|
||
submitGameAwards(awards);
|
||
io.to(sid).emit('special-card-applied', {
|
||
card: specialCardClientPayload(card),
|
||
coins: coins,
|
||
coinsServerAwarded: true,
|
||
});
|
||
consumePendingSpecialCard(space, card.cardId);
|
||
return;
|
||
}
|
||
/* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next)
|
||
— คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */
|
||
}
|
||
|
||
/* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */
|
||
function dbgCards(space) {
|
||
return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : '')));
|
||
}
|
||
function pendingSpecialCardList(space) {
|
||
if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = [];
|
||
return space.pendingSpecialCards;
|
||
}
|
||
/** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */
|
||
function addPendingSpecialCard(space, card) {
|
||
if (!card || !card.cardId) return null;
|
||
const list = pendingSpecialCardList(space);
|
||
const cid = Number(card.cardId);
|
||
if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; }
|
||
const entry = { cardId: cid, card: card, consumed: false };
|
||
list.push(entry);
|
||
console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space));
|
||
return entry;
|
||
}
|
||
/** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */
|
||
function findPendingSpecialCard(space, cardId) {
|
||
const cid = Number(cardId);
|
||
return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null;
|
||
}
|
||
function consumePendingSpecialCard(space, cardId) {
|
||
const e = findPendingSpecialCard(space, cardId);
|
||
if (e) e.consumed = true;
|
||
}
|
||
|
||
function endSpecialQuizSession(sid, space, success) {
|
||
const sq = space.specialQuiz;
|
||
if (!sq) return;
|
||
clearSpecialQuizTimers(space);
|
||
sq.done = true;
|
||
sq.session = null;
|
||
let cardOut = null;
|
||
console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId));
|
||
if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) {
|
||
space.specialCardAwardedThisRun = sq.card;
|
||
cardOut = specialCardClientPayload(sq.card);
|
||
addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */
|
||
applySpecialCardImmediate(sid, space, sq.card);
|
||
}
|
||
io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut });
|
||
/* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด)
|
||
— มี safety timeout กันค้างถ้าไม่มีใครกด */
|
||
space.specialQuizAwaitContinue = true;
|
||
space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */
|
||
/* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */
|
||
io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size });
|
||
if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer);
|
||
space.specialQuizContinueTimer = setTimeout(() => {
|
||
space.specialQuizContinueTimer = null;
|
||
finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */
|
||
}, 32000);
|
||
}
|
||
|
||
/** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */
|
||
function maybeFinishSpecialQuizContinue(sid, space) {
|
||
if (!space || !space.specialQuizAwaitContinue) return;
|
||
const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set());
|
||
const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */
|
||
const ready = expected.filter((pid) => acks.has(pid)).length;
|
||
io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length });
|
||
if (expected.length === 0 || expected.every((pid) => acks.has(pid))) {
|
||
finishSpecialQuizContinue(sid, space);
|
||
}
|
||
}
|
||
|
||
/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */
|
||
function finishSpecialQuizContinue(sid, space) {
|
||
if (!space || !space.specialQuizAwaitContinue) return;
|
||
space.specialQuizAwaitContinue = false;
|
||
space.specialQuizContinueAcks = null;
|
||
if (space.specialQuizContinueTimer) {
|
||
clearTimeout(space.specialQuizContinueTimer);
|
||
space.specialQuizContinueTimer = null;
|
||
}
|
||
/* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */
|
||
io.to(sid).emit('special-quiz-resume-all', {});
|
||
resumeQuizAfterSpecialQuiz(sid, space);
|
||
}
|
||
|
||
function startSpecialQuizSession(sid, space) {
|
||
const sq = space.specialQuiz;
|
||
if (!sq || sq.triggered || sq.done) return false;
|
||
const settings = loadQuizSettings();
|
||
const set = getSpecialQuizSet(settings, sq.level, sq.caseId);
|
||
const all = (set && Array.isArray(set.questions) ? set.questions : [])
|
||
.map(specialQuizNormalizeQuestion)
|
||
.filter(Boolean);
|
||
if (!all.length) return false;
|
||
/* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */
|
||
const picked = specialQuizShuffle(all).slice(0, 1);
|
||
sq.triggered = true;
|
||
sq.session = {
|
||
questions: picked,
|
||
qIndex: 0,
|
||
answers: {},
|
||
phaseEndsAt: 0,
|
||
resolving: false,
|
||
roster: buildSpecialQuizRoster(space),
|
||
};
|
||
clearSpecialQuizTimers(space);
|
||
pauseQuizForSpecialQuiz(sid, space);
|
||
io.to(sid).emit('special-quiz-icon-clear', {});
|
||
sendSpecialQuizQuestion(sid, space);
|
||
return true;
|
||
}
|
||
|
||
function defaultQuizSettings() {
|
||
return {
|
||
readMs: 10000,
|
||
answerMs: 5000,
|
||
betweenMs: 3500,
|
||
/** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */
|
||
carryReadMs: 3000,
|
||
/** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */
|
||
carryAnswerMs: 5000,
|
||
/** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */
|
||
carrySessionLength: 0,
|
||
/** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */
|
||
carryMapPanelTheme: defaultCarryMapPanelTheme(),
|
||
/** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */
|
||
quizMapPanelTheme: defaultCarryMapPanelTheme(),
|
||
/** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */
|
||
carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(),
|
||
/** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 1–16 */
|
||
carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
|
||
/** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
|
||
carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
|
||
/** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
|
||
carryChoicePlaqueMapScale: 1.25,
|
||
/** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */
|
||
carryWalkSpeedMultForMapId: '',
|
||
carryWalkSpeedMult: null,
|
||
/** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */
|
||
quizRoundQuestionCount: 10,
|
||
questions: [],
|
||
carryQuestions: [],
|
||
battleQuizMcq: [],
|
||
/** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */
|
||
specialQuizSets: sanitizeSpecialQuizSets({}),
|
||
};
|
||
}
|
||
|
||
function defaultCarryMapPanelTheme() {
|
||
return {
|
||
panelBg: 'rgba(12, 14, 28, 0.88)',
|
||
panelBorder: 'rgba(255, 214, 102, 0.7)',
|
||
borderWidthPx: 2,
|
||
textColor: '#f1f5f9',
|
||
questionFontMinPx: 10,
|
||
questionFontMaxPx: 24,
|
||
};
|
||
}
|
||
|
||
function sanitizeCssColorToken(input, fallback) {
|
||
const t = String(input == null ? '' : input).trim().slice(0, 120);
|
||
if (!t) return fallback;
|
||
if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback;
|
||
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
|
||
if (/^rgba?\(\s*[^)]+\)$/i.test(t)) {
|
||
const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, '');
|
||
if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim();
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function sanitizeCarryMapPanelTheme(raw) {
|
||
const d = defaultCarryMapPanelTheme();
|
||
if (!raw || typeof raw !== 'object') return d;
|
||
let qMin = Number(raw.questionFontMinPx);
|
||
let qMax = Number(raw.questionFontMaxPx);
|
||
if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx;
|
||
if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx;
|
||
qMin = Math.round(Math.max(10, Math.min(40, qMin)));
|
||
qMax = Math.round(Math.max(14, Math.min(56, qMax)));
|
||
if (qMax < qMin) {
|
||
const t = qMin;
|
||
qMin = qMax;
|
||
qMax = t;
|
||
}
|
||
return {
|
||
panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg),
|
||
panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder),
|
||
borderWidthPx: (() => {
|
||
let w = Number(raw.borderWidthPx);
|
||
if (!Number.isFinite(w)) w = d.borderWidthPx;
|
||
return Math.max(0, Math.min(12, Math.round(w)));
|
||
})(),
|
||
textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
|
||
questionFontMinPx: qMin,
|
||
questionFontMaxPx: qMax,
|
||
};
|
||
}
|
||
|
||
/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
|
||
function defaultCarryChoicePlaqueTheme() {
|
||
return {
|
||
borderMode: 'neon',
|
||
fixedBorder: 'rgba(122, 200, 255, 0.9)',
|
||
fillBg: 'rgba(12, 10, 20, 0.88)',
|
||
textColor: 'rgba(248, 249, 255, 1)',
|
||
borderWidthPx: 2.5,
|
||
};
|
||
}
|
||
|
||
function sanitizeCarryChoicePlaqueTheme(raw) {
|
||
const d = defaultCarryChoicePlaqueTheme();
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
|
||
const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
|
||
let bw = Number(raw.borderWidthPx);
|
||
if (!Number.isFinite(bw)) bw = d.borderWidthPx;
|
||
bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
|
||
return {
|
||
borderMode: mode,
|
||
fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
|
||
fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
|
||
textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
|
||
borderWidthPx: bw,
|
||
plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
|
||
};
|
||
}
|
||
|
||
const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
|
||
|
||
function defaultCarryChoicePlaqueThemes() {
|
||
const out = [];
|
||
for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
|
||
out.push(sanitizeCarryChoicePlaqueTheme({}));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
|
||
function sanitizeCarryChoicePlaqueThemes(raw) {
|
||
if (Array.isArray(raw) && raw.length > 0) {
|
||
const out = [];
|
||
for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
|
||
out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
|
||
}
|
||
return out;
|
||
}
|
||
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
|
||
const one = sanitizeCarryChoicePlaqueTheme(raw);
|
||
return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
|
||
}
|
||
return defaultCarryChoicePlaqueThemes();
|
||
}
|
||
|
||
function sanitizeCarryChoiceImageUrl(input) {
|
||
const s = String(input == null ? '' : input).trim().slice(0, 512);
|
||
if (!s) return '';
|
||
if (/[\s<>'"`]/.test(s)) return '';
|
||
if (s.startsWith('/')) {
|
||
if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
|
||
return s;
|
||
}
|
||
const low = s.toLowerCase();
|
||
if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
|
||
try {
|
||
const u = new URL(s);
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
||
return s;
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function defaultCarryEmbedCountdownTheme() {
|
||
return {
|
||
overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
|
||
innerBg: 'rgba(0, 0, 0, 0.78)',
|
||
innerBorder: 'rgba(94, 234, 212, 0.82)',
|
||
innerBorderWpx: 1,
|
||
innerRadiusPx: 14,
|
||
digitColor: '#ffe066',
|
||
mapDigitCqmin: 78,
|
||
mapDigitCqh: 82,
|
||
mapDigitMaxPx: 220,
|
||
screenDigitVw: 32,
|
||
screenDigitMaxPx: 200,
|
||
};
|
||
}
|
||
|
||
function sanitizeCarryEmbedCountdownTheme(raw) {
|
||
const d = defaultCarryEmbedCountdownTheme();
|
||
if (!raw || typeof raw !== 'object') return d;
|
||
const clampN = (v, lo, hi, def) => {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return def;
|
||
return Math.max(lo, Math.min(hi, Math.round(n)));
|
||
};
|
||
return {
|
||
overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
|
||
innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
|
||
innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
|
||
innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
|
||
innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
|
||
digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
|
||
mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
|
||
mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
|
||
mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
|
||
screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
|
||
screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
|
||
};
|
||
}
|
||
|
||
/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
|
||
const QUIZ_BATTLE_CATEGORY_IDS = new Set([
|
||
'topic1', 'topic2', 'topic3', 'topic4', 'topic5',
|
||
'topic6', 'topic7', 'topic8', 'topic9', 'topic10',
|
||
]);
|
||
|
||
/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
|
||
function sanitizeBattleQuizMcq(arr) {
|
||
if (!Array.isArray(arr)) return [];
|
||
const out = [];
|
||
for (const q of arr) {
|
||
if (!q || typeof q !== 'object') continue;
|
||
let categoryId = String(q.categoryId || '').trim();
|
||
if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
|
||
categoryId = 'topic1';
|
||
}
|
||
const text = String(q.text || '').trim().slice(0, 500);
|
||
if (!text) continue;
|
||
const rawChoices = Array.isArray(q.choices) ? q.choices : [];
|
||
const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
|
||
if (choices.length !== 3 || !choices.every(Boolean)) continue;
|
||
let ci = Number(q.correctIndex);
|
||
if (!Number.isFinite(ci)) ci = 0;
|
||
ci = Math.max(0, Math.min(2, Math.floor(ci)));
|
||
out.push({ categoryId, text, choices, correctIndex: ci });
|
||
if (out.length >= 200) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
|
||
function sanitizeCarryQuestions(arr) {
|
||
if (!Array.isArray(arr)) return [];
|
||
const out = [];
|
||
for (const q of arr) {
|
||
if (!q || typeof q !== 'object') continue;
|
||
const text = String(q.text || '').trim().slice(0, 500);
|
||
if (!text) continue;
|
||
let choices = Array.isArray(q.choices)
|
||
? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
|
||
: [];
|
||
if (choices.length < 2) continue;
|
||
if (choices.length > 16) choices = choices.slice(0, 16);
|
||
let ci = Number(q.correctIndex);
|
||
if (!Number.isFinite(ci)) ci = 0;
|
||
ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
|
||
const row = { text, choices, correctIndex: ci };
|
||
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
|
||
const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
|
||
if (urls.some((u) => u)) row.choiceImageUrls = urls;
|
||
}
|
||
out.push(row);
|
||
if (out.length >= 80) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clampQuizMs(n, def) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return def;
|
||
return Math.max(1000, Math.min(300000, Math.floor(v)));
|
||
}
|
||
|
||
function clampQuizBetweenMs(n, def) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return def;
|
||
return Math.max(0, Math.min(300000, Math.floor(v)));
|
||
}
|
||
|
||
function clampCarrySessionLength(n, def) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return def;
|
||
return Math.max(0, Math.min(500, Math.floor(v)));
|
||
}
|
||
|
||
/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
|
||
function clampQuizRoundQuestionCount(n, def) {
|
||
const v = Number(n);
|
||
if (!Number.isFinite(v)) {
|
||
const d = Number(def);
|
||
if (!Number.isFinite(d)) return 10;
|
||
return Math.max(1, Math.min(50, Math.floor(d)));
|
||
}
|
||
return Math.max(1, Math.min(50, Math.floor(v)));
|
||
}
|
||
|
||
function clampCarryChoicePlaqueMapScale(n, def) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return def;
|
||
return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
|
||
}
|
||
|
||
/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
|
||
function sanitizeCarryWalkSpeedMultForMapId(raw) {
|
||
const t = String(raw == null ? '' : raw).trim().slice(0, 64);
|
||
if (!t) return '';
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
|
||
return t;
|
||
}
|
||
|
||
/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
|
||
function clampCarryWalkSpeedMultSetting(n) {
|
||
if (n === null || n === undefined || n === '') return null;
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return null;
|
||
return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
|
||
}
|
||
|
||
function mergeQuizCarryFromMirrorIfSet(base) {
|
||
if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
|
||
try {
|
||
if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
|
||
const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
|
||
const j = JSON.parse(raw);
|
||
if (!j || typeof j !== 'object') return base;
|
||
if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
|
||
base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
|
||
}
|
||
if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
|
||
base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
|
||
}
|
||
if (j.carryReadMs != null) {
|
||
base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
|
||
}
|
||
if (j.carryAnswerMs != null) {
|
||
base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
|
||
}
|
||
if (j.carrySessionLength != null) {
|
||
base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
|
||
}
|
||
if (j.carryWalkSpeedMultForMapId != null) {
|
||
base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
|
||
}
|
||
if (j.carryWalkSpeedMult != null) {
|
||
const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
|
||
if (wm != null) base.carryWalkSpeedMult = wm;
|
||
}
|
||
if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
|
||
base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
|
||
}
|
||
if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
|
||
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
|
||
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
|
||
} else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
|
||
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
|
||
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
|
||
}
|
||
if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
|
||
base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
|
||
}
|
||
if (j.quizRoundQuestionCount != null) {
|
||
base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
|
||
}
|
||
return base;
|
||
} catch (e) {
|
||
console.error('mergeQuizCarryFromMirrorIfSet', e.message);
|
||
return base;
|
||
}
|
||
}
|
||
|
||
/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
|
||
const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
|
||
const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
|
||
const EVIDENCE_CASE_COUNT = 15;
|
||
|
||
/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
|
||
const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
|
||
const CASE_MEDIA_COUNT = 15;
|
||
/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
|
||
const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
|
||
|
||
function caseMediaDefault(n) {
|
||
const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png');
|
||
const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png');
|
||
return {
|
||
name: 'คดีที่ ' + n,
|
||
art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
|
||
detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
|
||
story: [
|
||
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
|
||
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
|
||
],
|
||
suspects,
|
||
culprits,
|
||
};
|
||
}
|
||
|
||
function sanitizeCaseMediaUrlList(arr, fallbackArr) {
|
||
const fb = Array.isArray(fallbackArr) ? fallbackArr : [];
|
||
const src = Array.isArray(arr) ? arr : [];
|
||
return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || ''));
|
||
}
|
||
|
||
/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
|
||
function sanitizeCaseMediaUrl(v, fallback) {
|
||
if (typeof v !== 'string') return fallback;
|
||
const s = v.trim();
|
||
if (!s) return fallback;
|
||
if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
|
||
return s;
|
||
}
|
||
|
||
function sanitizeCaseMedia(obj) {
|
||
const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
|
||
const cases = {};
|
||
for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
|
||
const d = caseMediaDefault(n);
|
||
const c = src[n] || src[String(n)] || {};
|
||
const story = Array.isArray(c.story) ? c.story : [];
|
||
cases[n] = {
|
||
name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
|
||
art: sanitizeCaseMediaUrl(c.art, d.art),
|
||
detail: sanitizeCaseMediaUrl(c.detail, d.detail),
|
||
story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
|
||
suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
|
||
culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
|
||
};
|
||
}
|
||
return { cases };
|
||
}
|
||
|
||
function loadCaseMedia() {
|
||
try {
|
||
if (fs.existsSync(CASE_MEDIA_PATH)) {
|
||
return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
|
||
}
|
||
} catch (e) { /* ignore — คืนค่า default */ }
|
||
return sanitizeCaseMedia({});
|
||
}
|
||
|
||
function saveCaseMedia(d) {
|
||
try {
|
||
const clean = sanitizeCaseMedia(d);
|
||
fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
|
||
return { ok: true, cases: clean.cases };
|
||
} catch (e) {
|
||
let err = 'บันทึกไม่ได้';
|
||
if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
|
||
else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
|
||
return { ok: false, error: err };
|
||
}
|
||
}
|
||
|
||
/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
|
||
function listCaseMediaImages() {
|
||
const out = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] };
|
||
['case', 'cutscene', 'suspect-pick', 'culprit-prison'].forEach((sub) => {
|
||
try {
|
||
const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
|
||
out[sub] = fs.readdirSync(p)
|
||
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
|
||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||
.map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
|
||
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
|
||
});
|
||
return out;
|
||
}
|
||
const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
|
||
/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
|
||
const EVIDENCE_RARITY_RANGES = [
|
||
{ rarity: 'common', min: 1, max: 22, stars: 1 },
|
||
{ rarity: 'rare', min: 23, max: 44, stars: 2 },
|
||
{ rarity: 'legendary', min: 45, max: 54, stars: 3 },
|
||
];
|
||
|
||
function evidenceRarityForNum(n) {
|
||
for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
|
||
return EVIDENCE_RARITY_RANGES[0];
|
||
}
|
||
|
||
function evidenceCardImageDefault(suspectIdx, cardIdx) {
|
||
return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
|
||
}
|
||
|
||
/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
|
||
let _evidencePoolCache = null;
|
||
let _evidencePoolCacheAt = 0;
|
||
function evidenceImagePools() {
|
||
const now = Date.now();
|
||
if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
|
||
const pools = {};
|
||
EVIDENCE_IMAGE_DIRS.forEach((dir) => {
|
||
const buckets = { common: [], rare: [], legendary: [] };
|
||
try {
|
||
const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
|
||
fs.readdirSync(p).forEach((f) => {
|
||
const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
|
||
if (!m) return;
|
||
const num = parseInt(m[1], 10);
|
||
const rr = evidenceRarityForNum(num);
|
||
buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
|
||
});
|
||
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
|
||
Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
|
||
pools[dir] = buckets;
|
||
});
|
||
_evidencePoolCache = pools;
|
||
_evidencePoolCacheAt = now;
|
||
return pools;
|
||
}
|
||
|
||
function evidencePoolCounts() {
|
||
const pools = evidenceImagePools();
|
||
const out = {};
|
||
EVIDENCE_IMAGE_DIRS.forEach((dir) => {
|
||
const b = pools[dir] || { common: [], rare: [], legendary: [] };
|
||
out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
|
||
function rollEvidenceRarityKey(grade) {
|
||
const r = Math.random();
|
||
if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
|
||
if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
|
||
return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
|
||
}
|
||
|
||
/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
|
||
function evidenceImageDirForSuspect(space, suspectIndex) {
|
||
const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
|
||
try {
|
||
const cid = String((space && space.caseId) || '1');
|
||
const cfg = loadEvidenceCards();
|
||
const cse = cfg.cases && cfg.cases[cid];
|
||
const s = cse && cse.suspects && cse.suspects[suspectIndex];
|
||
if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
|
||
} catch (e) { /* ใช้ default */ }
|
||
return def;
|
||
}
|
||
|
||
/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
|
||
function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
|
||
const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
|
||
const pools = evidenceImagePools();
|
||
const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
|
||
let want = rollEvidenceRarityKey(grade || 'C');
|
||
let pool = buckets[want];
|
||
if (!pool || !pool.length) {
|
||
const tryOrder = ['common', 'rare', 'legendary'];
|
||
for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
|
||
}
|
||
if (!pool || !pool.length) return null;
|
||
const pick = pool[Math.floor(Math.random() * pool.length)];
|
||
return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
|
||
}
|
||
|
||
function evidenceGradeFromPct(avgPct) {
|
||
return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
|
||
}
|
||
|
||
/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
|
||
function evidencePlayerRawScore(space, p, gameType) {
|
||
switch (gameType) {
|
||
case 'gauntlet':
|
||
case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
|
||
case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
|
||
case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
|
||
default:
|
||
return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
|
||
}
|
||
}
|
||
|
||
/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
|
||
function computeRunGradeForEvidence(space) {
|
||
try {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
const gameType = md && md.gameType;
|
||
/* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C
|
||
quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */
|
||
if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
|
||
const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
|
||
const ids = Object.keys(space.quizSession.players);
|
||
if (total > 0 && ids.length) {
|
||
const avgPct = ids.reduce((s, id) => {
|
||
const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
|
||
return s + Math.min(100, (sc / total) * 100);
|
||
}, 0) / ids.length;
|
||
return evidenceGradeFromPct(avgPct);
|
||
}
|
||
}
|
||
const peers = [...space.peers.values()];
|
||
if (!peers.length) return 'C';
|
||
const raw = peers.map((p) => evidencePlayerRawScore(space, p, gameType));
|
||
const max = Math.max(1, ...raw);
|
||
const avgPct = raw.reduce((s, v) => s + (v / max) * 100, 0) / raw.length;
|
||
return evidenceGradeFromPct(avgPct);
|
||
} catch (e) { return 'C'; }
|
||
}
|
||
|
||
/* ===== แจกเหรียญตามอันดับ + คะแนนสะสม (high score) แบบ server-authoritative ===== */
|
||
const GAME_AWARD_URL = process.env.GAME_AWARD_URL || 'https://srv1361159.hstgr.cloud/Admin/api/game-award.php';
|
||
const ACHIEVEMENTS_URL = process.env.ACHIEVEMENTS_URL || GAME_AWARD_URL.replace('game-award.php', 'achievements.php');
|
||
const GAME_AWARD_SECRET_PATH = path.join(__dirname, '..', 'Admin', 'private', 'game-award-secret.txt');
|
||
let __gameAwardSecret = null;
|
||
function gameAwardSecret() {
|
||
if (__gameAwardSecret != null) return __gameAwardSecret;
|
||
try { __gameAwardSecret = String(fs.readFileSync(GAME_AWARD_SECRET_PATH, 'utf8')).trim(); }
|
||
catch (e) { __gameAwardSecret = ''; }
|
||
return __gameAwardSecret;
|
||
}
|
||
/* เหรียญตามอันดับผู้รอดชีวิต: อันดับ 1→10, 2→8, 3→6, 4→4, 5→2, 6+→0 */
|
||
const MINIGAME_RANK_COINS = [10, 8, 6, 4, 2, 0];
|
||
|
||
/* เพดานคะแนนที่ client รายงานเองได้ (stack/quiz_carry) — sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
|
||
ค่าจริงต่อรอบอยู่หลักร้อย-พัน เพดานนี้กว้างพอไม่กระทบเล่นจริง (ไม่ใช่ anti-cheat สมบูรณ์ — ดูคอมเมนต์ที่ handler) */
|
||
const REPORTED_MINI_SCORE_MAX = 50000;
|
||
|
||
/* คะแนน + สถานะรอด/ตาย ของผู้เล่นต่อ gameType ตอนจบมินิเกม */
|
||
function minigamePlayerScoreSurvived(space, p, gameType) {
|
||
/* Card 3 Ban — ผู้ถูกแบนเข้ามาดูเฉยๆ ไม่ได้เล่น → ไม่นับเป็นผู้รอด/ไม่ได้คะแนน */
|
||
if (p && p.bannedSpectator) return { score: 0, survived: false };
|
||
switch (gameType) {
|
||
case 'quiz': {
|
||
const st = space.quizSession && space.quizSession.players ? space.quizSession.players[p.id] : null;
|
||
return { score: st ? Math.max(0, Number(st.score) | 0) : 0, survived: st ? !st.eliminated : true };
|
||
}
|
||
case 'gauntlet':
|
||
return { score: Math.max(0, p.gauntletScore | 0), survived: !p.gauntletEliminated };
|
||
case 'jump_survive':
|
||
/* jump_survive ไม่มี gauntlet ticker → gauntletScore/Eliminated ฝั่ง server เป็น 0/false เสมอ
|
||
ใช้ค่าที่ client รายงานตอนจบแทน (reportedMiniSurvived = ไม่ตาย) ; เดิมเข้า case gauntlet → ทุกคน survived เสมอ = coin มั่ว */
|
||
return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false };
|
||
case 'space_shooter':
|
||
return { score: Math.max(0, p.spaceShooterScore | 0), survived: true };
|
||
case 'balloon_boss':
|
||
return { score: Math.max(0, p.balloonBossScore | 0), survived: !p.balloonBossEliminated };
|
||
default: /* stack, quiz_carry — client รายงานคะแนน/รอดเอง ตอนจบ */
|
||
return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false };
|
||
}
|
||
}
|
||
|
||
/* ยิงไป PHP (มี secret) เพื่อบวกเหรียญ+คะแนนเข้าบัญชีผู้เล่นจริง (client ปลอมไม่ได้) */
|
||
function submitGameAwards(awards) {
|
||
const secret = gameAwardSecret();
|
||
if (!secret || !Array.isArray(awards) || !awards.length) return;
|
||
try {
|
||
fetch(GAME_AWARD_URL, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ secret, awards }),
|
||
}).then((r) => r.json()).then((d) => {
|
||
if (!d || !d.ok) console.error('[game-award] failed', d);
|
||
}).catch((e) => console.error('[game-award] error', e && e.message));
|
||
} catch (e) { console.error('[game-award] throw', e && e.message); }
|
||
}
|
||
|
||
/* บวก progress achievement (server-authoritative ผ่าน achievements.php — secret เดียวกับ game-award)
|
||
items: [{ playerKey, id, inc }] — หนึ่ง POST ต่อรายการ (achievements.php รับทีละ id) */
|
||
function submitAchievementProgress(items) {
|
||
const secret = gameAwardSecret();
|
||
if (!secret || !Array.isArray(items) || !items.length) return;
|
||
items.forEach((it) => {
|
||
if (!it || !it.playerKey || !it.id) return;
|
||
try {
|
||
fetch(ACHIEVEMENTS_URL, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'progress', secret, playerKey: it.playerKey, id: it.id, inc: it.inc || 1 }),
|
||
}).then((r) => r.json()).then((d) => {
|
||
if (!d || !d.ok) console.error('[achv] failed', it.id, d);
|
||
}).catch((e) => console.error('[achv] error', e && e.message));
|
||
} catch (e) { console.error('[achv] throw', e && e.message); }
|
||
});
|
||
}
|
||
|
||
/* แจกเหรียญตามอันดับคะแนน (เฉพาะผู้รอดชีวิต ผู้ตาย = 0) + คะแนนสะสมเข้า leaderboard — ครั้งเดียวต่อรอบ */
|
||
function awardMinigameRankCoins(sid, space) {
|
||
if (!space || space.coinAwardDoneForRun) return;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
const gameType = md && md.gameType;
|
||
if (!gameType || gameType === 'zep' || gameType === 'quiz_battle' || serverMapIsPostCaseLobbyB(space)) return;
|
||
const peers = [...space.peers.values()];
|
||
if (!peers.length) return;
|
||
space.coinAwardDoneForRun = true;
|
||
|
||
const rows = peers.map((p) => {
|
||
const ss = minigamePlayerScoreSurvived(space, p, gameType);
|
||
return { id: p.id, playerKey: p.playerKey || '', nickname: (p.nickname || '').trim() || 'ผู้เล่น', score: ss.score, survived: !!ss.survived, coins: 0 };
|
||
});
|
||
/* ผู้รอดชีวิตเรียงคะแนนมาก→น้อย แล้วให้เหรียญตามอันดับ */
|
||
const survivors = rows.filter((r) => r.survived).sort((a, b) => b.score - a.score);
|
||
survivors.forEach((r, i) => { r.coins = i < MINIGAME_RANK_COINS.length ? MINIGAME_RANK_COINS[i] : 0; });
|
||
|
||
const caseId = String(space.caseId || space.detectiveLobbyCaseId || '');
|
||
const awards = rows.filter((r) => r.coins > 0 && r.playerKey).map((r) => ({ playerKey: r.playerKey, nickname: r.nickname, coins: r.coins, caseId: caseId }));
|
||
submitGameAwards(awards);
|
||
|
||
/* Achievement: d1 Minigame Solver — รอดมินิเกมสำเร็จ (เฉพาะผู้รอดชีวิตที่เป็นผู้เล่นจริง) */
|
||
submitAchievementProgress(survivors.filter((r) => r.playerKey).map((r) => ({ playerKey: r.playerKey, id: 'd1_minigame_solver', inc: 1 })));
|
||
|
||
io.to(sid).emit('minigame-coins', {
|
||
gameType,
|
||
coinsTable: MINIGAME_RANK_COINS,
|
||
results: rows.map((r) => ({ id: r.id, nickname: r.nickname, score: r.score, survived: r.survived, coins: r.coins })),
|
||
});
|
||
}
|
||
|
||
/* card object ที่เก็บในแฟ้มหลักฐาน */
|
||
function sanitizeStoredEvidenceCard(c) {
|
||
if (!c || typeof c !== 'object') return null;
|
||
let rar = String(c.rarity || 'common').toLowerCase();
|
||
if (EVIDENCE_RARITIES.indexOf(rar) < 0) rar = 'common';
|
||
const img = String(c.imageUrl || '').slice(0, 400);
|
||
if (!img) return null;
|
||
let stars = Math.floor(Number(c.stars));
|
||
if (!(stars >= 1 && stars <= 3)) stars = rar === 'legendary' ? 3 : rar === 'rare' ? 2 : 1;
|
||
let num = Math.floor(Number(c.num));
|
||
if (!(num >= 1)) num = 0;
|
||
return { rarity: rar, stars, num, imageUrl: img };
|
||
}
|
||
|
||
function sanitizeEvidenceCases(obj) {
|
||
const src = (obj && obj.cases) ? obj.cases : {};
|
||
const out = {};
|
||
for (let i = 1; i <= EVIDENCE_CASE_COUNT; i++) {
|
||
const cid = String(i);
|
||
const cse = src[cid] || {};
|
||
const suspects = Array.isArray(cse.suspects) ? cse.suspects : [];
|
||
const outSus = [];
|
||
for (let si = 0; si < 3; si++) {
|
||
const s = suspects[si] || {};
|
||
let dir = String(s.imageDir || EVIDENCE_IMAGE_DIRS[si] || EVIDENCE_IMAGE_DIRS[0]);
|
||
if (EVIDENCE_IMAGE_DIRS.indexOf(dir) < 0) dir = EVIDENCE_IMAGE_DIRS[si] || EVIDENCE_IMAGE_DIRS[0];
|
||
outSus.push({
|
||
linkName: String(s.linkName || ('ผู้ต้องสงสัย ' + (si + 1))).slice(0, 60),
|
||
imageDir: dir,
|
||
});
|
||
}
|
||
out[cid] = { suspects: outSus };
|
||
}
|
||
return { cases: out };
|
||
}
|
||
|
||
function loadEvidenceCards() {
|
||
try {
|
||
if (fs.existsSync(EVIDENCE_CARDS_PATH)) {
|
||
return sanitizeEvidenceCases(JSON.parse(fs.readFileSync(EVIDENCE_CARDS_PATH, 'utf8')));
|
||
}
|
||
} catch (e) { /* ignore — คืนค่า default */ }
|
||
return sanitizeEvidenceCases({});
|
||
}
|
||
|
||
/** รายการรูปการ์ดที่มีจริงในแต่ละโฟลเดอร์ผู้ต้องสงสัย (ไว้ให้ Admin เลือกรูป) */
|
||
function listEvidenceCardImages() {
|
||
const out = { 'suspect-1': [], 'suspect-2': [], 'suspect-3': [] };
|
||
Object.keys(out).forEach((dir) => {
|
||
try {
|
||
const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
|
||
const files = fs.readdirSync(p)
|
||
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
|
||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||
out[dir] = files.map((f) => '/Game/img/evidence-cards/' + dir + '/' + f);
|
||
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function saveEvidenceCards(d) {
|
||
try {
|
||
const clean = sanitizeEvidenceCases(d);
|
||
fs.writeFileSync(EVIDENCE_CARDS_PATH, JSON.stringify(clean, null, 2), 'utf8');
|
||
return { ok: true, cases: clean.cases };
|
||
} catch (e) {
|
||
let err = 'บันทึกไม่ได้';
|
||
if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ evidence-cards.json (chown เป็น user ที่รันเกม)';
|
||
else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
|
||
return { ok: false, error: err };
|
||
}
|
||
}
|
||
|
||
function loadQuizSettings() {
|
||
try {
|
||
if (fs.existsSync(QUIZ_SETTINGS_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8'));
|
||
const d = defaultQuizSettings();
|
||
const questions = Array.isArray(j.questions) ? j.questions : [];
|
||
const carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
|
||
const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq);
|
||
const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
|
||
const quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
|
||
const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
|
||
const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length
|
||
? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes)
|
||
: sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
|
||
const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0];
|
||
const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale);
|
||
const carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
|
||
let carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
|
||
if (!carryWalkSpeedMultForMapId) carryWalkSpeedMult = null;
|
||
else if (carryWalkSpeedMult == null) carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42);
|
||
const out = {
|
||
readMs: clampQuizMs(j.readMs, d.readMs),
|
||
answerMs: clampQuizMs(j.answerMs, d.answerMs),
|
||
betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs),
|
||
carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs),
|
||
carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs),
|
||
carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength),
|
||
carryMapPanelTheme,
|
||
quizMapPanelTheme,
|
||
carryEmbedCountdownTheme,
|
||
carryChoicePlaqueThemes,
|
||
carryChoicePlaqueTheme,
|
||
carryChoicePlaqueMapScale,
|
||
carryWalkSpeedMultForMapId,
|
||
carryWalkSpeedMult,
|
||
quizRoundQuestionCount: clampQuizRoundQuestionCount(j.quizRoundQuestionCount, d.quizRoundQuestionCount),
|
||
questions,
|
||
carryQuestions,
|
||
battleQuizMcq,
|
||
specialQuizSets: sanitizeSpecialQuizSets(j.specialQuizSets),
|
||
};
|
||
return mergeQuizCarryFromMirrorIfSet(out);
|
||
}
|
||
} catch (e) { console.error('loadQuizSettings', e.message); }
|
||
return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings());
|
||
}
|
||
|
||
const GAUNTLET_DEFAULT_TICK_MS = 220;
|
||
const GAUNTLET_DEFAULT_JUMP_TICKS = 16;
|
||
const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0;
|
||
/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */
|
||
const STACK_SWING_HZ_DEFAULT = 0.55;
|
||
|
||
function defaultGameTiming() {
|
||
return {
|
||
gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS,
|
||
gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS,
|
||
gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
|
||
...defaultGauntletVisuals(),
|
||
stackSwingHz: STACK_SWING_HZ_DEFAULT,
|
||
stackBlockWidthTiles: null,
|
||
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */
|
||
jumpSurviveJumpHeightMult: 1.5,
|
||
/** จำกัดเวลารอบภารกิจ/มินิเกมกระโดดขึ้นแท่น (วินาที) — 0 = ไคลเอนต์ใช้ 60 วิ; ไม่ใช่ค่าเดียวกับพรมแดง */
|
||
jumpSurviveMissionTimeSec: 0,
|
||
/** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 — ว่าง = ไคลเอนต์วาด cyan; w/h 0 = ใช้ขนาดไทล์ */
|
||
jumpSurvivePlatformTiles: [
|
||
{ url: '', w: 0, h: 0 },
|
||
{ url: '', w: 0, h: 0 },
|
||
{ url: '', w: 0, h: 0 },
|
||
],
|
||
/** Stack ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — ไคลเอนต์ใช้เมื่อเล่นภารกิจ */
|
||
stackTowerMissionTimeSec: 90,
|
||
/** Stack ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนจบ (ไม่รีเซ็ตหอ) */
|
||
stackTeamMissesMax: 3,
|
||
/** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
|
||
stackTowerProgressBlocks: 50,
|
||
stackBlockNormalImageUrls: ['', '', '', '', '', ''],
|
||
stackBlockHeavyImageUrls: ['', '', '', '', '', ''],
|
||
/** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */
|
||
stackHeavyBlockPercent: 35,
|
||
/** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */
|
||
spaceShooterMissionTimeSec: 0,
|
||
spaceShooterShipImageUrls: ['', '', '', '', '', ''],
|
||
spaceShooterAsteroidSpriteUrls: [],
|
||
spaceShooterAsteroidExplodeFrameMs: 70,
|
||
/** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
|
||
spaceShooterAsteroidIntervalMs: 1040,
|
||
spaceShooterShipDamageOverlayUrls: ['', '', ''],
|
||
/** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */
|
||
balloonBossMissionTimeSec: 0,
|
||
balloonBossBossImageUrl: '',
|
||
balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''],
|
||
/** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */
|
||
balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png',
|
||
/** จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */
|
||
balloonBossBalloonsPerPlayer: 0,
|
||
/** โหมดเทสต์: เกมบังคับต่อการ์ด 3 ใบ — ['','',''] = สุ่มหมด, แต่ละช่องใส่ key เพื่อเจาะจงใบนั้น */
|
||
forcedMinigameKeys: ['', '', ''],
|
||
/** โหมดเทสต์: บังคับการ์ดพิเศษต่อเกม { mapId: cardId } */
|
||
testSpecialCardByMap: {},
|
||
/** โหมดเทสต์: บังคับเสนอบทตัวป่วน (ปกติเสนอเฉพาะเมื่อมีคนจริง > 4) */
|
||
troublesomeForceOffer: false,
|
||
/** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */
|
||
specialQuizIconExpireSec: 10,
|
||
/** เวลาโหวตเลือกผู้เล่น (การ์ด Silence/Ban) วินาที */
|
||
pickVoteSec: 25,
|
||
/** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */
|
||
trialVoteSec: 30,
|
||
};
|
||
}
|
||
|
||
/** อ่านเวลา expiry ไอคอนคำถามพิเศษ (วินาที) จาก game-timing — clamp 0..120 */
|
||
function readSpecialQuizIconExpireSec() {
|
||
const v = Math.floor(Number(runtimeGameTiming && runtimeGameTiming.specialQuizIconExpireSec));
|
||
if (!Number.isFinite(v) || v < 0) return 10;
|
||
return Math.min(120, v);
|
||
}
|
||
|
||
function clampStackSwingHz(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT;
|
||
return Math.max(0.08, Math.min(2.8, v));
|
||
}
|
||
|
||
/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */
|
||
function clampStackBlockWidthTiles(n) {
|
||
if (n === null || n === undefined || n === '') return null;
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return null;
|
||
return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100;
|
||
}
|
||
|
||
function clampStackTowerMissionTimeSec(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return 90;
|
||
return Math.max(10, Math.min(7200, Math.floor(v)));
|
||
}
|
||
|
||
function clampStackTeamMissesMax(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 3;
|
||
return Math.max(1, Math.min(20, Math.floor(v)));
|
||
}
|
||
|
||
function clampStackTowerProgressBlocks(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 50;
|
||
return Math.max(1, Math.min(500, Math.floor(v)));
|
||
}
|
||
|
||
/** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */
|
||
function normalizeStackBlockSixImageUrls(val) {
|
||
let arr = val;
|
||
if (typeof arr === 'string') {
|
||
try {
|
||
const p = JSON.parse(arr);
|
||
if (Array.isArray(p)) arr = p;
|
||
} catch (e) {
|
||
arr = arr.split(/[\n\r]+/);
|
||
}
|
||
}
|
||
if (!Array.isArray(arr)) arr = [];
|
||
const out = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
out.push(sanitizeGauntletAssetUrl(arr[i]));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clampStackHeavyBlockPercent(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 35;
|
||
return Math.max(0, Math.min(100, Math.floor(v)));
|
||
}
|
||
|
||
function clampJumpSurviveJumpHeightMult(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 1.5;
|
||
return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100;
|
||
}
|
||
|
||
/** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 10–7200 */
|
||
function clampJumpSurviveMissionTimeSec(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return 0;
|
||
return Math.max(10, Math.min(7200, Math.floor(v)));
|
||
}
|
||
|
||
/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */
|
||
function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) {
|
||
let arr = val;
|
||
if (typeof arr === 'string') {
|
||
try {
|
||
const p = JSON.parse(arr);
|
||
if (Array.isArray(p)) arr = p;
|
||
} catch (e) {
|
||
arr = [];
|
||
}
|
||
}
|
||
if (!Array.isArray(arr)) arr = [];
|
||
const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : '';
|
||
const out = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const o = arr[i];
|
||
const obj = o && typeof o === 'object' ? o : {};
|
||
let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : '');
|
||
if (i === 0 && !url && legacy) url = legacy;
|
||
let ww = Number(obj.w);
|
||
let hh = Number(obj.h);
|
||
if (!Number.isFinite(ww) || ww <= 0) ww = 0;
|
||
else ww = Math.max(8, Math.min(4096, Math.round(ww)));
|
||
if (!Number.isFinite(hh) || hh <= 0) hh = 0;
|
||
else hh = Math.max(8, Math.min(4096, Math.round(hh)));
|
||
out.push({ url, w: ww, h: hh });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clampSpaceShooterMissionTimeSec(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return 0;
|
||
return Math.max(10, Math.min(7200, Math.floor(v)));
|
||
}
|
||
|
||
/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */
|
||
function normalizeSpaceShooterShipImageUrls(val) {
|
||
let arr = val;
|
||
if (typeof arr === 'string') {
|
||
try {
|
||
const p = JSON.parse(arr);
|
||
if (Array.isArray(p)) arr = p;
|
||
} catch (e) {
|
||
arr = arr.split(/[\n\r]+/);
|
||
}
|
||
}
|
||
if (!Array.isArray(arr)) arr = [];
|
||
const out = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
out.push(sanitizeGauntletAssetUrl(arr[i]));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */
|
||
function normalizeSpaceShooterAsteroidSpriteUrls(val) {
|
||
let arr = val;
|
||
if (typeof arr === 'string') {
|
||
try {
|
||
const p = JSON.parse(arr);
|
||
if (Array.isArray(p)) arr = p;
|
||
} catch (e) {
|
||
arr = arr.split(/[\n\r]+/);
|
||
}
|
||
}
|
||
if (!Array.isArray(arr)) arr = [];
|
||
const out = [];
|
||
for (let i = 0; i < arr.length && out.length < 32; i++) {
|
||
const u = sanitizeGauntletAssetUrl(arr[i]);
|
||
if (u) out.push(u);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clampSpaceShooterAsteroidExplodeFrameMs(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 70;
|
||
return Math.max(30, Math.min(500, Math.round(v)));
|
||
}
|
||
|
||
const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040;
|
||
function clampSpaceShooterAsteroidIntervalMs(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
|
||
return Math.max(200, Math.min(10000, Math.floor(v)));
|
||
}
|
||
|
||
/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */
|
||
function normalizeSpaceShooterShipDamageOverlayUrls(val) {
|
||
let arr = val;
|
||
if (typeof arr === 'string') {
|
||
try {
|
||
const p = JSON.parse(arr);
|
||
if (Array.isArray(p)) arr = p;
|
||
} catch (e) {
|
||
arr = arr.split(/[\n\r]+/);
|
||
}
|
||
}
|
||
if (!Array.isArray(arr)) arr = [];
|
||
const out = ['', '', ''];
|
||
for (let i = 0; i < 3; i++) {
|
||
out[i] = sanitizeGauntletAssetUrl(arr[i]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clampGauntletTickMs(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
|
||
return Math.max(80, Math.min(800, Math.floor(v)));
|
||
}
|
||
|
||
function clampGauntletJumpTicks(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
|
||
return Math.max(4, Math.min(40, Math.floor(v)));
|
||
}
|
||
|
||
/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */
|
||
function clampGauntletTimeLimitSec(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v) || v <= 0) return 0;
|
||
return Math.max(10, Math.min(7200, Math.floor(v)));
|
||
}
|
||
|
||
function defaultGauntletVisuals() {
|
||
return {
|
||
gauntletLaneImageUrls: [],
|
||
gauntletLaserTopUrl: '',
|
||
gauntletLaserBottomUrl: '',
|
||
gauntletLaserLineUrl: '',
|
||
gauntletLaserFillColor: 'rgba(140,230,255,0.42)',
|
||
gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)',
|
||
gauntletLaserLineWidthPx: 2,
|
||
};
|
||
}
|
||
|
||
/** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */
|
||
function encodeSpacesInAssetUrlPath(t) {
|
||
const s = String(t || '');
|
||
const q = s.indexOf('?');
|
||
const pathPart = q === -1 ? s : s.slice(0, q);
|
||
const query = q === -1 ? '' : s.slice(q);
|
||
return pathPart.replace(/ /g, '%20') + query;
|
||
}
|
||
|
||
/** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */
|
||
function normalizeGameAssetUrlForWeb(t) {
|
||
let s = String(t || '');
|
||
s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
|
||
s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
|
||
return s;
|
||
}
|
||
|
||
/** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */
|
||
function decodeAssetUrlPercentRuns(t) {
|
||
let s = String(t || '');
|
||
const q = s.indexOf('?');
|
||
let base = q === -1 ? s : s.slice(0, q);
|
||
const query = q === -1 ? '' : s.slice(q);
|
||
for (let i = 0; i < 2; i += 1) {
|
||
try {
|
||
const d = decodeURIComponent(base);
|
||
if (d === base) break;
|
||
base = d;
|
||
} catch (e) {
|
||
break;
|
||
}
|
||
}
|
||
return base + query;
|
||
}
|
||
|
||
/** ช่อง Admin มักใส่แค่ …/Artboard ลืม 9.png — ชี้ไป Artboard%209.png */
|
||
function fixBareMegaVirusArtboardUrl(t) {
|
||
const s = String(t || '').trim().replace(/\/+$/, '');
|
||
if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png';
|
||
if (/^Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png';
|
||
return t;
|
||
}
|
||
|
||
function sanitizeGauntletAssetUrl(s) {
|
||
const t = fixBareMegaVirusArtboardUrl(
|
||
normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, '')
|
||
);
|
||
if (!t || t.length > 500) return '';
|
||
/** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */
|
||
if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return '';
|
||
/** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */
|
||
if (/\.{4,}/.test(t)) return '';
|
||
if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t);
|
||
if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t);
|
||
/** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */
|
||
if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`);
|
||
return '';
|
||
}
|
||
|
||
function clampGauntletLaserColor(s, def) {
|
||
const t = String(s || '').trim().slice(0, 100);
|
||
if (!t) return def;
|
||
if (/[<>"'`]/.test(t)) return def;
|
||
return t;
|
||
}
|
||
|
||
function clampGauntletLaserLineWidthPx(n) {
|
||
const v = Number(n);
|
||
if (Number.isNaN(v)) return 2;
|
||
return Math.max(0, Math.min(24, Math.round(v)));
|
||
}
|
||
|
||
function normalizeGauntletVisualsFromRequest(d) {
|
||
const def = defaultGauntletVisuals();
|
||
if (!d || typeof d !== 'object') return { ...def };
|
||
let laneArr = d.gauntletLaneImageUrls;
|
||
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
|
||
if (!Array.isArray(laneArr)) laneArr = [];
|
||
const gauntletLaneImageUrls = [];
|
||
for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) {
|
||
const u = sanitizeGauntletAssetUrl(laneArr[i]);
|
||
if (u) gauntletLaneImageUrls.push(u);
|
||
}
|
||
return {
|
||
gauntletLaneImageUrls,
|
||
gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl),
|
||
gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl),
|
||
gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl),
|
||
gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor),
|
||
gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor),
|
||
gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx),
|
||
};
|
||
}
|
||
|
||
function getGauntletVisualsForClient() {
|
||
const r = runtimeGameTiming;
|
||
const d = defaultGauntletVisuals();
|
||
return {
|
||
gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls,
|
||
gauntletLaserTopUrl: r.gauntletLaserTopUrl || '',
|
||
gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '',
|
||
gauntletLaserLineUrl: r.gauntletLaserLineUrl || '',
|
||
gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor,
|
||
gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor,
|
||
gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx,
|
||
};
|
||
}
|
||
|
||
function loadGameTiming() {
|
||
try {
|
||
if (fs.existsSync(GAME_TIMING_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8'));
|
||
const vis = normalizeGauntletVisualsFromRequest(j);
|
||
return {
|
||
gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs),
|
||
gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks),
|
||
gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
|
||
...vis,
|
||
stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz')
|
||
? clampStackSwingHz(j.stackSwingHz)
|
||
: STACK_SWING_HZ_DEFAULT,
|
||
stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles')
|
||
? clampStackBlockWidthTiles(j.stackBlockWidthTiles)
|
||
: null,
|
||
stackTowerMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'stackTowerMissionTimeSec')
|
||
? clampStackTowerMissionTimeSec(j.stackTowerMissionTimeSec)
|
||
: 90,
|
||
stackTeamMissesMax: Object.prototype.hasOwnProperty.call(j, 'stackTeamMissesMax')
|
||
? clampStackTeamMissesMax(j.stackTeamMissesMax)
|
||
: 3,
|
||
stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks')
|
||
? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks)
|
||
: 50,
|
||
stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls),
|
||
stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls),
|
||
stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent')
|
||
? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent)
|
||
: 35,
|
||
jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult')
|
||
? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult)
|
||
: 1.5,
|
||
jumpSurviveMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveMissionTimeSec')
|
||
? clampJumpSurviveMissionTimeSec(j.jumpSurviveMissionTimeSec)
|
||
: 0,
|
||
jumpSurvivePlatformTiles: normalizeJumpSurvivePlatformTilesFromTiming(
|
||
j.jumpSurvivePlatformTiles,
|
||
j.jumpSurvivePlatformTileUrl,
|
||
),
|
||
spaceShooterMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'spaceShooterMissionTimeSec')
|
||
? clampSpaceShooterMissionTimeSec(j.spaceShooterMissionTimeSec)
|
||
: 0,
|
||
spaceShooterShipImageUrls: normalizeSpaceShooterShipImageUrls(j.spaceShooterShipImageUrls),
|
||
spaceShooterAsteroidSpriteUrls: normalizeSpaceShooterAsteroidSpriteUrls(j.spaceShooterAsteroidSpriteUrls),
|
||
spaceShooterAsteroidExplodeFrameMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidExplodeFrameMs')
|
||
? clampSpaceShooterAsteroidExplodeFrameMs(j.spaceShooterAsteroidExplodeFrameMs)
|
||
: 70,
|
||
spaceShooterAsteroidIntervalMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidIntervalMs')
|
||
? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs)
|
||
: SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT,
|
||
spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls),
|
||
balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec')
|
||
? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec)
|
||
: 0,
|
||
balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl')
|
||
? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl)
|
||
: '',
|
||
balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls),
|
||
balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl')
|
||
? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl)
|
||
: '',
|
||
balloonBossBalloonsPerPlayer: Object.prototype.hasOwnProperty.call(j, 'balloonBossBalloonsPerPlayer')
|
||
? Math.max(0, Math.min(12, Math.floor(Number(j.balloonBossBalloonsPerPlayer)) || 0))
|
||
: 0,
|
||
forcedMinigameKeys: normalizeForcedMinigameKeys(
|
||
j.forcedMinigameKeys != null ? j.forcedMinigameKeys : j.forcedMinigameKey,
|
||
),
|
||
testSpecialCardByMap: normalizeTestSpecialCardByMap(j.testSpecialCardByMap),
|
||
troublesomeForceOffer: !!j.troublesomeForceOffer,
|
||
specialQuizIconExpireSec: Object.prototype.hasOwnProperty.call(j, 'specialQuizIconExpireSec')
|
||
? Math.max(0, Math.min(120, Math.floor(Number(j.specialQuizIconExpireSec)) || 0))
|
||
: 10,
|
||
pickVoteSec: Object.prototype.hasOwnProperty.call(j, 'pickVoteSec')
|
||
? Math.max(5, Math.min(120, Math.floor(Number(j.pickVoteSec)) || 25))
|
||
: 25,
|
||
trialVoteSec: Object.prototype.hasOwnProperty.call(j, 'trialVoteSec')
|
||
? Math.max(5, Math.min(180, Math.floor(Number(j.trialVoteSec)) || 30))
|
||
: 30,
|
||
};
|
||
}
|
||
} catch (e) { console.error('loadGameTiming', e.message); }
|
||
return defaultGameTiming();
|
||
}
|
||
|
||
let runtimeGameTiming = loadGameTiming();
|
||
|
||
/** จำนวนลูกโป่งต่อคน Mega Virus — แมปกำหนดก่อน, ไม่งั้นใช้ค่ากลางจาก admin, สุดท้าย default 3 (clamp 1-12) */
|
||
function balloonBossBalloonsForMap(md) {
|
||
const fromMap = Math.floor(Number(md && md.balloonBossBalloonsPerPlayer)) || 0;
|
||
const fromCfg = Math.floor(Number(runtimeGameTiming && runtimeGameTiming.balloonBossBalloonsPerPlayer)) || 0;
|
||
return Math.max(1, Math.min(12, fromMap || fromCfg || 3));
|
||
}
|
||
|
||
function getGauntletTickMs() {
|
||
return runtimeGameTiming.gauntletTickMs;
|
||
}
|
||
|
||
function getGauntletJumpTicks() {
|
||
return runtimeGameTiming.gauntletJumpTicks;
|
||
}
|
||
|
||
function getGauntletTimeLimitSec() {
|
||
return runtimeGameTiming.gauntletTimeLimitSec || 0;
|
||
}
|
||
|
||
function saveGameTimingToFile(d) {
|
||
try {
|
||
const dir = path.dirname(GAME_TIMING_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
const prev = { ...runtimeGameTiming };
|
||
const vis = normalizeGauntletVisualsFromRequest(d);
|
||
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
|
||
? clampStackSwingHz(d.stackSwingHz)
|
||
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
|
||
const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles')
|
||
? clampStackBlockWidthTiles(d.stackBlockWidthTiles)
|
||
: (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles')
|
||
? prev.stackBlockWidthTiles
|
||
: null);
|
||
const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult')
|
||
? prev.jumpSurviveJumpHeightMult
|
||
: 1.5;
|
||
const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult')
|
||
? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult)
|
||
: clampJumpSurviveJumpHeightMult(prevMult);
|
||
const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec')
|
||
? prev.jumpSurviveMissionTimeSec
|
||
: 0;
|
||
const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec')
|
||
? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec)
|
||
: clampJumpSurviveMissionTimeSec(prevJumpMissionSec);
|
||
const prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming(
|
||
prev.jumpSurvivePlatformTiles,
|
||
prev.jumpSurvivePlatformTileUrl,
|
||
);
|
||
let jumpSurvivePlatformTiles;
|
||
if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) {
|
||
jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming(
|
||
d.jumpSurvivePlatformTiles,
|
||
d.jumpSurvivePlatformTileUrl,
|
||
);
|
||
} else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) {
|
||
jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming(
|
||
[
|
||
{ url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 },
|
||
prevJumpTiles[1],
|
||
prevJumpTiles[2],
|
||
],
|
||
'',
|
||
);
|
||
} else {
|
||
jumpSurvivePlatformTiles = prevJumpTiles;
|
||
}
|
||
const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec')
|
||
? prev.spaceShooterMissionTimeSec
|
||
: 0;
|
||
const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec')
|
||
? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec)
|
||
: clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec);
|
||
const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls)
|
||
? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls)
|
||
: normalizeSpaceShooterShipImageUrls([]);
|
||
const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls')
|
||
? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls)
|
||
: prevShipUrls;
|
||
const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls)
|
||
? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls)
|
||
: [];
|
||
const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls')
|
||
? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls)
|
||
: prevAstUrls;
|
||
const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs')
|
||
? prev.spaceShooterAsteroidExplodeFrameMs
|
||
: 70;
|
||
const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs')
|
||
? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs)
|
||
: clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms);
|
||
const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs')
|
||
? prev.spaceShooterAsteroidIntervalMs
|
||
: SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
|
||
const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs')
|
||
? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs)
|
||
: clampSpaceShooterAsteroidIntervalMs(prevAstIv);
|
||
const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls)
|
||
? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls)
|
||
: normalizeSpaceShooterShipDamageOverlayUrls([]);
|
||
const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls')
|
||
? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls)
|
||
: prevDmg;
|
||
const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec')
|
||
? prev.balloonBossMissionTimeSec
|
||
: 0;
|
||
const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec')
|
||
? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec)
|
||
: clampSpaceShooterMissionTimeSec(prevBbMission);
|
||
const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl')
|
||
? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl)
|
||
: '';
|
||
const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl')
|
||
? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl)
|
||
: prevBbBoss;
|
||
const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls)
|
||
? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls)
|
||
: normalizeStackBlockSixImageUrls([]);
|
||
const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls')
|
||
? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls)
|
||
: prevBbBalloons;
|
||
const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl')
|
||
? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl)
|
||
: '';
|
||
const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl')
|
||
? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl)
|
||
: prevBbFb;
|
||
/* จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */
|
||
const prevBbCount = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBalloonsPerPlayer')
|
||
? Math.max(0, Math.min(12, Math.floor(Number(prev.balloonBossBalloonsPerPlayer)) || 0))
|
||
: 0;
|
||
const balloonBossBalloonsPerPlayer = Object.prototype.hasOwnProperty.call(d, 'balloonBossBalloonsPerPlayer')
|
||
? Math.max(0, Math.min(12, Math.floor(Number(d.balloonBossBalloonsPerPlayer)) || 0))
|
||
: prevBbCount;
|
||
const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec')
|
||
? prev.stackTowerMissionTimeSec
|
||
: 90;
|
||
const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec')
|
||
? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec)
|
||
: clampStackTowerMissionTimeSec(prevStackTowerSec);
|
||
const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax')
|
||
? prev.stackTeamMissesMax
|
||
: 3;
|
||
const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax')
|
||
? clampStackTeamMissesMax(d.stackTeamMissesMax)
|
||
: clampStackTeamMissesMax(prevStackMisses);
|
||
const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks')
|
||
? prev.stackTowerProgressBlocks
|
||
: 50;
|
||
const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks')
|
||
? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks)
|
||
: clampStackTowerProgressBlocks(prevStackProgBlocks);
|
||
const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls)
|
||
? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls)
|
||
: normalizeStackBlockSixImageUrls([]);
|
||
const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls)
|
||
? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls)
|
||
: normalizeStackBlockSixImageUrls([]);
|
||
const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls')
|
||
? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls)
|
||
: prevStackNorm;
|
||
const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls')
|
||
? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls)
|
||
: prevStackHeavy;
|
||
const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent')
|
||
? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent)
|
||
: 35;
|
||
const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent')
|
||
? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent)
|
||
: prevHeavyPct;
|
||
const out = {
|
||
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
|
||
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
|
||
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
|
||
...vis,
|
||
stackSwingHz: stackHz,
|
||
stackBlockWidthTiles: stackBlockW,
|
||
stackTowerMissionTimeSec,
|
||
stackTeamMissesMax,
|
||
stackTowerProgressBlocks,
|
||
stackBlockNormalImageUrls,
|
||
stackBlockHeavyImageUrls,
|
||
stackHeavyBlockPercent,
|
||
jumpSurviveJumpHeightMult: jumpMult,
|
||
jumpSurviveMissionTimeSec: jumpMissionSec,
|
||
jumpSurvivePlatformTiles,
|
||
spaceShooterMissionTimeSec: spaceShooterMissionSec,
|
||
spaceShooterShipImageUrls,
|
||
spaceShooterAsteroidSpriteUrls,
|
||
spaceShooterAsteroidExplodeFrameMs,
|
||
spaceShooterAsteroidIntervalMs,
|
||
spaceShooterShipDamageOverlayUrls,
|
||
balloonBossMissionTimeSec,
|
||
balloonBossBossImageUrl,
|
||
balloonBossPlayerBalloonImageUrls,
|
||
balloonBossPlayerBalloonFallbackUrl,
|
||
balloonBossBalloonsPerPlayer,
|
||
forcedMinigameKeys: Object.prototype.hasOwnProperty.call(d, 'forcedMinigameKeys')
|
||
? normalizeForcedMinigameKeys(d.forcedMinigameKeys)
|
||
: (Object.prototype.hasOwnProperty.call(d, 'forcedMinigameKey')
|
||
? normalizeForcedMinigameKeys(d.forcedMinigameKey)
|
||
: normalizeForcedMinigameKeys(prev.forcedMinigameKeys)),
|
||
testSpecialCardByMap: Object.prototype.hasOwnProperty.call(d, 'testSpecialCardByMap')
|
||
? normalizeTestSpecialCardByMap(d.testSpecialCardByMap)
|
||
: normalizeTestSpecialCardByMap(prev.testSpecialCardByMap),
|
||
troublesomeForceOffer: Object.prototype.hasOwnProperty.call(d, 'troublesomeForceOffer')
|
||
? !!d.troublesomeForceOffer
|
||
: !!prev.troublesomeForceOffer,
|
||
specialQuizIconExpireSec: Object.prototype.hasOwnProperty.call(d, 'specialQuizIconExpireSec')
|
||
? Math.max(0, Math.min(120, Math.floor(Number(d.specialQuizIconExpireSec)) || 0))
|
||
: (prev.specialQuizIconExpireSec != null ? Math.max(0, Math.min(120, Math.floor(Number(prev.specialQuizIconExpireSec)) || 0)) : 10),
|
||
pickVoteSec: Object.prototype.hasOwnProperty.call(d, 'pickVoteSec')
|
||
? Math.max(5, Math.min(120, Math.floor(Number(d.pickVoteSec)) || 25))
|
||
: (prev.pickVoteSec != null ? Math.max(5, Math.min(120, Math.floor(Number(prev.pickVoteSec)) || 25)) : 25),
|
||
trialVoteSec: Object.prototype.hasOwnProperty.call(d, 'trialVoteSec')
|
||
? Math.max(5, Math.min(180, Math.floor(Number(d.trialVoteSec)) || 30))
|
||
: (prev.trialVoteSec != null ? Math.max(5, Math.min(180, Math.floor(Number(prev.trialVoteSec)) || 30)) : 30),
|
||
};
|
||
fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||
runtimeGameTiming = out;
|
||
rescheduleAllGauntletTickers();
|
||
return { ok: true, ...out };
|
||
} catch (e) {
|
||
console.error('saveGameTimingToFile', e.message);
|
||
let err = 'บันทึกไม่ได้';
|
||
if (e && e.code === 'EACCES') {
|
||
err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
|
||
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
|
||
return { ok: false, error: err };
|
||
}
|
||
}
|
||
|
||
function migrateGauntletAssetsFromLegacyIfNeeded() {
|
||
try {
|
||
if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return;
|
||
if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true });
|
||
const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY);
|
||
names.forEach((f) => {
|
||
if (!safeGauntletStoredFilename(f)) return;
|
||
const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f);
|
||
const dest = path.join(GAUNTLET_ASSETS_DIR, f);
|
||
if (fs.existsSync(dest)) return;
|
||
try {
|
||
fs.copyFileSync(src, dest);
|
||
console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/');
|
||
} catch (e) {
|
||
console.error('[gauntlet-assets] migrate failed', f, e && e.message);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message);
|
||
}
|
||
}
|
||
|
||
function ensureGauntletAssetsDir() {
|
||
if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true });
|
||
migrateGauntletAssetsFromLegacyIfNeeded();
|
||
}
|
||
|
||
function loadGauntletAssetsMeta() {
|
||
try {
|
||
if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) {
|
||
const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8'));
|
||
return j && typeof j === 'object' ? j : {};
|
||
}
|
||
} catch (e) { console.error('loadGauntletAssetsMeta', e.message); }
|
||
return {};
|
||
}
|
||
|
||
function saveGauntletAssetsMeta(meta) {
|
||
const dir = path.dirname(GAUNTLET_ASSETS_META_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8');
|
||
}
|
||
|
||
function safeGauntletStoredFilename(name) {
|
||
const base = path.basename(String(name || ''));
|
||
if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
|
||
return base;
|
||
}
|
||
|
||
function safeQuizCarryPlaqueStoredFilename(name) {
|
||
const base = path.basename(String(name || ''));
|
||
if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
|
||
return base;
|
||
}
|
||
|
||
function ensureQuizCarryPlaqueAssetsDir() {
|
||
if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true });
|
||
}
|
||
|
||
function gauntletAssetMimeByExt(ext) {
|
||
const e = String(ext || '').toLowerCase();
|
||
if (e === '.png') return 'image/png';
|
||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||
if (e === '.gif') return 'image/gif';
|
||
if (e === '.webp') return 'image/webp';
|
||
return 'application/octet-stream';
|
||
}
|
||
|
||
function listGauntletAssetsApi() {
|
||
ensureGauntletAssetsDir();
|
||
const meta = loadGauntletAssetsMeta();
|
||
let files = [];
|
||
try {
|
||
files = fs.readdirSync(GAUNTLET_ASSETS_DIR);
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
return files
|
||
.filter((f) => safeGauntletStoredFilename(f))
|
||
.map((f) => {
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, f);
|
||
let st;
|
||
try {
|
||
st = fs.statSync(fp);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
const entry = meta[f];
|
||
const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : '';
|
||
return {
|
||
filename: f,
|
||
url: BASE_PATH + '/img/gauntlet-assets/' + f,
|
||
label: label || f,
|
||
bytes: st.size,
|
||
mtime: st.mtimeMs,
|
||
};
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => b.mtime - a.mtime);
|
||
}
|
||
|
||
function saveQuizSettings(d) {
|
||
try {
|
||
const prev = loadQuizSettings();
|
||
const dir = path.dirname(QUIZ_SETTINGS_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs;
|
||
const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs;
|
||
const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs;
|
||
const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs;
|
||
const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs;
|
||
const carrySessionLength = d.carrySessionLength != null
|
||
? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength)
|
||
: prev.carrySessionLength;
|
||
const questions = Array.isArray(d.questions)
|
||
? (d.questions || [])
|
||
.filter((q) => q && String(q.text || '').trim())
|
||
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }))
|
||
: prev.questions;
|
||
const carryQuestions = Array.isArray(d.carryQuestions)
|
||
? sanitizeCarryQuestions(d.carryQuestions)
|
||
: prev.carryQuestions;
|
||
const battleQuizMcq = Array.isArray(d.battleQuizMcq)
|
||
? sanitizeBattleQuizMcq(d.battleQuizMcq)
|
||
: (prev.battleQuizMcq || []);
|
||
const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object'
|
||
? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme)
|
||
: prev.carryMapPanelTheme;
|
||
const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object'
|
||
? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme)
|
||
: prev.quizMapPanelTheme;
|
||
const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object'
|
||
? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme)
|
||
: prev.carryEmbedCountdownTheme;
|
||
let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes;
|
||
if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) {
|
||
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes);
|
||
} else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') {
|
||
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme);
|
||
}
|
||
if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) {
|
||
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme);
|
||
}
|
||
const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0];
|
||
const prevScale = prev.carryChoicePlaqueMapScale != null
|
||
? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25)
|
||
: 1.25;
|
||
const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null
|
||
? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale)
|
||
: prevScale;
|
||
const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId);
|
||
const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null;
|
||
let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined
|
||
? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId)
|
||
: prevWalkId;
|
||
let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult;
|
||
if (!carryWalkSpeedMultForMapId) {
|
||
carryWalkSpeedMult = null;
|
||
} else if (carryWalkSpeedMult == null) {
|
||
carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42);
|
||
}
|
||
const quizRoundQuestionCount = d.quizRoundQuestionCount != null
|
||
? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount)
|
||
: prev.quizRoundQuestionCount;
|
||
const specialQuizSets = d.specialQuizSets != null
|
||
? sanitizeSpecialQuizSets(d.specialQuizSets)
|
||
: sanitizeSpecialQuizSets(prev.specialQuizSets);
|
||
const out = {
|
||
readMs,
|
||
answerMs,
|
||
betweenMs,
|
||
carryReadMs,
|
||
carryAnswerMs,
|
||
carrySessionLength,
|
||
carryMapPanelTheme,
|
||
quizMapPanelTheme,
|
||
carryEmbedCountdownTheme,
|
||
carryChoicePlaqueThemes,
|
||
carryChoicePlaqueTheme,
|
||
carryChoicePlaqueMapScale,
|
||
carryWalkSpeedMultForMapId,
|
||
carryWalkSpeedMult,
|
||
quizRoundQuestionCount,
|
||
questions,
|
||
carryQuestions,
|
||
battleQuizMcq,
|
||
specialQuizSets,
|
||
};
|
||
fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8');
|
||
return {
|
||
ok: true,
|
||
battleQuizMcqSaved: (out.battleQuizMcq || []).length,
|
||
carryQuestionsSaved: (out.carryQuestions || []).length,
|
||
specialQuizSets: out.specialQuizSets,
|
||
carryMapPanelTheme: out.carryMapPanelTheme,
|
||
quizMapPanelTheme: out.quizMapPanelTheme,
|
||
carryEmbedCountdownTheme: out.carryEmbedCountdownTheme,
|
||
carryChoicePlaqueThemes: out.carryChoicePlaqueThemes,
|
||
carryChoicePlaqueTheme: out.carryChoicePlaqueTheme,
|
||
carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale,
|
||
};
|
||
} catch (e) {
|
||
console.error('saveQuizSettings', e.message);
|
||
let err = 'บันทึกไม่ได้';
|
||
if (e && e.code === 'EACCES') {
|
||
err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
|
||
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
|
||
return { ok: false, error: err };
|
||
}
|
||
}
|
||
|
||
function getQuizQuestionPool(md) {
|
||
const g = loadQuizSettings();
|
||
const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim());
|
||
if (globalQ.length) return globalQ;
|
||
return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim());
|
||
}
|
||
|
||
function quizCellOn(grid, tx, ty) {
|
||
return !!(grid && grid[ty] && grid[ty][tx] === 1);
|
||
}
|
||
|
||
function getCharacterFootprintWHForMove(md) {
|
||
let cw = Math.floor(Number(md.characterCellsW));
|
||
let ch = Math.floor(Number(md.characterCellsH));
|
||
const hasW = Number.isFinite(cw) && cw >= 1;
|
||
const hasH = Number.isFinite(ch) && ch >= 1;
|
||
if (hasW && hasH) {
|
||
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
|
||
}
|
||
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
|
||
return { cw: leg, ch: leg };
|
||
}
|
||
|
||
function getCharacterCollisionFootprintWHForMove(md) {
|
||
const { cw, ch } = getCharacterFootprintWHForMove(md);
|
||
let colW = Math.floor(Number(md.characterCollisionW));
|
||
let colH = Math.floor(Number(md.characterCollisionH));
|
||
if (!Number.isFinite(colW) || colW < 1) colW = cw;
|
||
if (!Number.isFinite(colH) || colH < 1) colH = ch;
|
||
colW = Math.max(1, Math.min(cw, colW));
|
||
colH = Math.max(1, Math.min(ch, colH));
|
||
return { cw, ch, colW, colH };
|
||
}
|
||
|
||
const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15;
|
||
const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35;
|
||
|
||
function quizBattleSpriteBoundsTileKeys(md, px, py) {
|
||
const out = [];
|
||
if (!md || md.gameType !== 'quiz_battle') return out;
|
||
const x = Number(px), y = Number(py);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return out;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const { cw, ch } = getCharacterFootprintWHForMove(md);
|
||
const cx = x + cw * 0.5;
|
||
const feetY = y + ch;
|
||
const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
|
||
const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
|
||
const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H;
|
||
const bottom = feetY;
|
||
if (left < 0 || right > w || top < 0 || bottom > h) return out;
|
||
const minTx = Math.floor(left);
|
||
const maxTx = Math.min(w - 1, Math.floor(right - 1e-6));
|
||
const minTy = Math.floor(top);
|
||
const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6));
|
||
for (let ty = minTy; ty <= maxTy; ty++) {
|
||
for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function serverWallCollisionTileKeys(md, px, py) {
|
||
if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(md, px, py);
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md);
|
||
const minTx = Math.floor(Number(px));
|
||
const minTy = Math.floor(Number(py));
|
||
const offX = minTx + Math.floor((cw - colW) / 2);
|
||
const offY = minTy + (ch - colH);
|
||
const out = [];
|
||
for (let ty = offY; ty < offY + colH; ty++) {
|
||
for (let tx = offX; tx < offX + colW; tx++) {
|
||
if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function quizCarryFootprintTileKeys(md, px, py) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const { cw, ch } = getCharacterFootprintWHForMove(md);
|
||
const minTx = Math.floor(Number(px));
|
||
const minTy = Math.floor(Number(py));
|
||
const maxTx = Math.min(w - 1, minTx + cw - 1);
|
||
const maxTy = Math.min(h - 1, minTy + ch - 1);
|
||
const out = [];
|
||
for (let ty = minTy; ty <= maxTy; ty++) {
|
||
for (let tx = minTx; tx <= maxTx; tx++) {
|
||
if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function quizCarryFootprintOverlapsHubServer(md, px, py) {
|
||
if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false;
|
||
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Nearest walkable tile center not in true/false answer zones (BFS). */
|
||
function findNearestOutsideQuizAnswerZones(md, sx, sy) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const tGrid = md.quizTrueArea || [];
|
||
const fGrid = md.quizFalseArea || [];
|
||
const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y);
|
||
const walkable = (x, y) => {
|
||
if (x < 0 || x >= w || y < 0 || y >= h) return false;
|
||
const row = md.objects && md.objects[y];
|
||
return row && row[x] !== 1;
|
||
};
|
||
const fx = Math.floor(Number(sx));
|
||
const fy = Math.floor(Number(sy));
|
||
if (!walkable(fx, fy)) {
|
||
const sp = md.spawn || { x: 1, y: 1 };
|
||
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
|
||
}
|
||
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
|
||
const q = [[fx, fy]];
|
||
const seen = new Set([`${fx},${fy}`]);
|
||
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||
while (q.length) {
|
||
const [cx, cy] = q.shift();
|
||
for (let di = 0; di < dirs.length; di++) {
|
||
const nx = cx + dirs[di][0];
|
||
const ny = cy + dirs[di][1];
|
||
const k = `${nx},${ny}`;
|
||
if (seen.has(k)) continue;
|
||
if (!walkable(nx, ny)) continue;
|
||
seen.add(k);
|
||
if (!inAnswer(nx, ny)) {
|
||
return { x: nx + 0.5, y: ny + 0.5 };
|
||
}
|
||
q.push([nx, ny]);
|
||
}
|
||
}
|
||
const sp = md.spawn || { x: 1, y: 1 };
|
||
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
|
||
}
|
||
|
||
function validateQuizMap(md) {
|
||
const pool = getQuizQuestionPool(md);
|
||
if (pool.length < 1) {
|
||
return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)';
|
||
}
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const tGrid = md.quizTrueArea || [];
|
||
const fGrid = md.quizFalseArea || [];
|
||
let anyT = false;
|
||
let anyF = false;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (quizCellOn(tGrid, x, y)) anyT = true;
|
||
if (quizCellOn(fGrid, x, y)) anyF = true;
|
||
}
|
||
}
|
||
if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1';
|
||
return null;
|
||
}
|
||
|
||
function clearSpaceQuizTimers(space) {
|
||
if (!space.quizTimers || !Array.isArray(space.quizTimers)) {
|
||
space.quizTimers = [];
|
||
return;
|
||
}
|
||
space.quizTimers.forEach((t) => clearTimeout(t));
|
||
space.quizTimers = [];
|
||
}
|
||
|
||
/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */
|
||
function pauseQuizForSpecialQuiz(sid, space) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active || space.quizFrozenForSpecial) return;
|
||
const phase = sess.phase;
|
||
if (phase !== 'read' && phase !== 'answer') return;
|
||
const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now());
|
||
space.quizFrozenForSpecial = true;
|
||
space.quizFreezePhase = phase;
|
||
space.quizFreezeRemainingMs = remaining;
|
||
clearSpaceQuizTimers(space);
|
||
io.to(sid).emit('quiz-paused', { phase });
|
||
}
|
||
|
||
/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */
|
||
function resumeQuizAfterSpecialQuiz(sid, space) {
|
||
if (!space.quizFrozenForSpecial) return;
|
||
space.quizFrozenForSpecial = false;
|
||
const sess = space.quizSession;
|
||
const phase = space.quizFreezePhase;
|
||
const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0);
|
||
space.quizFreezePhase = null;
|
||
space.quizFreezeRemainingMs = 0;
|
||
if (!sess || !sess.active) return;
|
||
const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space);
|
||
if (!md) return;
|
||
/* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */
|
||
let extraMs = 0;
|
||
if (space.timeDilationPendingSec > 0) {
|
||
extraMs = space.timeDilationPendingSec * 1000;
|
||
space.timeDilationPendingSec = 0;
|
||
}
|
||
sess.phaseEndsAt = Date.now() + remaining + extraMs;
|
||
if (phase === 'read') {
|
||
sess.phase = 'read';
|
||
const q = sess.questions[sess.qIndex];
|
||
io.to(sid).emit('quiz-phase', {
|
||
phase: 'read',
|
||
questionIndex: sess.qIndex + 1,
|
||
questionTotal: sess.questions.length,
|
||
text: String((q && q.text) || '').trim(),
|
||
endsAt: sess.phaseEndsAt,
|
||
resumed: true,
|
||
});
|
||
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining);
|
||
space.quizTimers.push(t);
|
||
} else {
|
||
sess.phase = 'answer';
|
||
io.to(sid).emit('quiz-phase', {
|
||
phase: 'answer',
|
||
questionIndex: sess.qIndex + 1,
|
||
questionTotal: sess.questions.length,
|
||
endsAt: sess.phaseEndsAt,
|
||
resumed: true,
|
||
});
|
||
const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining);
|
||
space.quizTimers.push(t);
|
||
}
|
||
}
|
||
|
||
/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */
|
||
const QUIZ_TF_POINTS_PER_CORRECT = 10;
|
||
|
||
function shuffleQuizQuestions(arr) {
|
||
const a = arr.slice();
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function ensurePeerLobbyThemes(space) {
|
||
if (!space || !space.peers) return;
|
||
const seen = new Set();
|
||
space.peers.forEach((p, id) => {
|
||
let ti = parseInt(p.lobbyColorThemeIndex, 10);
|
||
if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) {
|
||
ti = pickFreeLobbyThemeIndex(space, id);
|
||
p.lobbyColorThemeIndex = ti;
|
||
}
|
||
seen.add(ti);
|
||
if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) {
|
||
p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0);
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildPeersSnapForSpace(space, extraFields) {
|
||
return [...space.peers.values()].map((p) => {
|
||
const row = {
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
nickname: p.nickname,
|
||
ready: !!p.ready,
|
||
characterId: p.characterId ?? null,
|
||
spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null,
|
||
lobbyColorThemeIndex: p.lobbyColorThemeIndex || null,
|
||
lobbySkinToneIndex: p.lobbySkinToneIndex || null,
|
||
bannedSpectator: !!p.bannedSpectator,
|
||
};
|
||
if (extraFields && extraFields.gauntlet) {
|
||
row.gauntletJumpTicks = p.gauntletJumpTicks || 0;
|
||
row.gauntletScore = p.gauntletScore || 0;
|
||
row.gauntletEliminated = !!p.gauntletEliminated;
|
||
}
|
||
return row;
|
||
});
|
||
}
|
||
|
||
/* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ
|
||
→ client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client
|
||
เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */
|
||
/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม
|
||
(เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */
|
||
function isBannedPeerForRun(space, id) {
|
||
if (!space) return false;
|
||
if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true;
|
||
const pp = space.peers && space.peers.get(id);
|
||
return !!(pp && pp.bannedSpectator);
|
||
}
|
||
|
||
/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */
|
||
function readyMapWithoutBanned(space, srcMap) {
|
||
const out = {};
|
||
if (srcMap && typeof srcMap === 'object') {
|
||
Object.keys(srcMap).forEach((id) => {
|
||
if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id];
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function quizCarryLobbySyncPayload(space) {
|
||
return {
|
||
readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady),
|
||
peers: buildPeersSnapForSpace(space),
|
||
expectedActive: (space && space.minigameExpectedActiveCount) || 0,
|
||
};
|
||
}
|
||
|
||
/* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน
|
||
แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */
|
||
function crownLobbySyncPayload(space) {
|
||
return {
|
||
readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady),
|
||
expectedActive: (space && space.minigameExpectedActiveCount) || 0,
|
||
peers: buildPeersSnapForSpace(space),
|
||
};
|
||
}
|
||
|
||
/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */
|
||
/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */
|
||
function normalizeDetectiveCardEntry(entry) {
|
||
if (!entry) return entry;
|
||
const mid = entry.mapId != null ? String(entry.mapId).trim() : '';
|
||
if (mid === 'mlpecp2j' || entry.key === 'frogger') {
|
||
const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet');
|
||
if (gaunt && maps.has(gaunt.mapId)) {
|
||
const md = maps.get(gaunt.mapId);
|
||
return {
|
||
...entry,
|
||
key: gaunt.key,
|
||
mapId: gaunt.mapId,
|
||
labelTh: gaunt.labelTh,
|
||
mapName: (md && md.name) || gaunt.mapId,
|
||
gameType: (md && md.gameType) || 'gauntlet',
|
||
};
|
||
}
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
/* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด
|
||
ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */
|
||
function detectiveLobbyBMissingParticipants(space) {
|
||
const roster = space && space.caseParticipantNicknames;
|
||
if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 };
|
||
const present = new Set();
|
||
space.peers.forEach((p) => {
|
||
if (!p || !p.nickname) return;
|
||
const n = normalizeLobbyNickname(p.nickname);
|
||
if (roster.has(n)) present.add(n);
|
||
});
|
||
const total = roster.size;
|
||
return { missing: Math.max(0, total - present.size), present: present.size, total };
|
||
}
|
||
|
||
/* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */
|
||
function emitDetectiveLobbyBPresence(sid, space) {
|
||
if (!sid || !space) return;
|
||
if (!serverMapIsPostCaseLobbyB(space)) return;
|
||
const info = detectiveLobbyBMissingParticipants(space);
|
||
if (info.total <= 0) return;
|
||
io.to(sid).emit('detective-lobbyb-presence', {
|
||
present: info.present,
|
||
total: info.total,
|
||
allHere: info.missing === 0,
|
||
});
|
||
}
|
||
|
||
function assignDetectiveSuspectCardMinigames(space) {
|
||
const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId));
|
||
/** โหมดเทสต์ (admin game-timing): เลือกเกมต่อการ์ด 3 ใบ — ช่องที่ตั้ง key = เจาะจง, ช่องว่าง = สุ่ม */
|
||
const forcedKeys = normalizeForcedMinigameKeys(runtimeGameTiming && runtimeGameTiming.forcedMinigameKeys);
|
||
const randomPool = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]);
|
||
let ri = 0;
|
||
const pick3 = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const fk = forcedKeys[i];
|
||
let entry = fk ? available.find((e) => e.key === fk) : null;
|
||
if (!entry) { entry = randomPool[ri % randomPool.length]; ri += 1; }
|
||
pick3.push(entry);
|
||
}
|
||
space.suspectCardMinigames = pick3.map((entry, i) => {
|
||
const md = maps.get(entry.mapId);
|
||
return {
|
||
cardIndex: i,
|
||
key: entry.key,
|
||
mapId: entry.mapId,
|
||
labelTh: entry.labelTh,
|
||
mapName: (md && md.name) || entry.mapId,
|
||
gameType: (md && md.gameType) || '',
|
||
};
|
||
});
|
||
// ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้
|
||
space.playerEvidence = {};
|
||
// สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย)
|
||
space.culpritIndex = Math.floor(Math.random() * 3);
|
||
space.trialPhase = null; // null | 'voting' | 'revealed'
|
||
space.trialVotes = {}; // socketId -> suspectIndex
|
||
space.trialScores = space.trialScores || {};
|
||
}
|
||
|
||
function ensureSpacePlayerEvidence(space) {
|
||
if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {};
|
||
if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {};
|
||
}
|
||
|
||
/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */
|
||
function normalizeEvidenceSlotList(cards) {
|
||
if (!Array.isArray(cards)) return [];
|
||
const out = [];
|
||
for (let i = 0; i < cards.length; i++) {
|
||
const c = sanitizeStoredEvidenceCard(cards[i]);
|
||
if (c) out.push(c);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */
|
||
function peerNickKey(space, playerId) {
|
||
if (!space || !playerId) return '';
|
||
const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
|
||
if (!p || !p.nickname) return '';
|
||
return normalizeLobbyNickname(p.nickname);
|
||
}
|
||
|
||
/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */
|
||
function clonePlayerEvidence(space, playerId) {
|
||
ensureSpacePlayerEvidence(space);
|
||
let row = space.playerEvidence[playerId];
|
||
if (!row || !row.length) {
|
||
const nk = peerNickKey(space, playerId);
|
||
if (nk && space.evidenceByNick[nk]) {
|
||
row = space.evidenceByNick[nk];
|
||
space.playerEvidence[playerId] = row;
|
||
}
|
||
}
|
||
if (!row) row = [[], [], []];
|
||
return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i]));
|
||
}
|
||
|
||
/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */
|
||
function syncEvidenceByNick(space, playerId) {
|
||
ensureSpacePlayerEvidence(space);
|
||
const nk = peerNickKey(space, playerId);
|
||
if (!nk) return;
|
||
const row = space.playerEvidence[playerId];
|
||
if (Array.isArray(row)) space.evidenceByNick[nk] = row;
|
||
}
|
||
|
||
/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */
|
||
function playerSuspectProgressFromEvidence(space, playerId) {
|
||
const row = clonePlayerEvidence(space, playerId);
|
||
return row.map((arr) => arr.length);
|
||
}
|
||
|
||
/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */
|
||
function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) {
|
||
if (suspectIndex < 0 || suspectIndex > 2) return null;
|
||
ensureSpacePlayerEvidence(space);
|
||
/* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */
|
||
if (!space.playerEvidence[playerId]) {
|
||
const nk = peerNickKey(space, playerId);
|
||
space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk])
|
||
? space.evidenceByNick[nk]
|
||
: [[], [], []];
|
||
}
|
||
const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]);
|
||
const card = pickEvidenceCardForSuspect(space, suspectIndex, grade);
|
||
if (!card) {
|
||
space.playerEvidence[playerId][suspectIndex] = list;
|
||
syncEvidenceByNick(space, playerId);
|
||
return null;
|
||
}
|
||
list.push(card);
|
||
space.playerEvidence[playerId][suspectIndex] = list;
|
||
syncEvidenceByNick(space, playerId);
|
||
return card;
|
||
}
|
||
|
||
function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
|
||
const out = {};
|
||
(playerIds || []).forEach((pid) => {
|
||
if (!space.peers.has(pid)) return;
|
||
const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
|
||
if (card != null) out[pid] = card;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function resolveDetectiveSuspectAwardIndex(space) {
|
||
if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) {
|
||
return space.suspectActiveIndex;
|
||
}
|
||
if (typeof space.lastDetectiveInvestigatedSuspect === 'number'
|
||
&& space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) {
|
||
return space.lastDetectiveInvestigatedSuspect;
|
||
}
|
||
if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) {
|
||
return space.suspectPickIndex;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */
|
||
function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) {
|
||
const suspectIdx = resolveDetectiveSuspectAwardIndex(space);
|
||
if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} };
|
||
if (space.detectiveAwardDoneForRun) {
|
||
return { suspectIdx, awarded: {}, alreadyGranted: true };
|
||
}
|
||
const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()];
|
||
const grade = computeRunGradeForEvidence(space);
|
||
const awardedList = {};
|
||
ids.forEach((pid) => { awardedList[pid] = []; });
|
||
const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade);
|
||
ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); });
|
||
/* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */
|
||
let freeEvidenceCount = 0;
|
||
const pend = findPendingSpecialCard(space, 7);
|
||
if (pend) {
|
||
const def = specialCardDef(7);
|
||
freeEvidenceCount = (def && def.evidence) || 1;
|
||
for (let n = 0; n < freeEvidenceCount; n++) {
|
||
ids.forEach((pid) => {
|
||
if (!space.peers.has(pid)) return;
|
||
const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
|
||
if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; }
|
||
});
|
||
}
|
||
pend.consumed = true;
|
||
}
|
||
space.detectiveAwardDoneForRun = true;
|
||
space.lastDetectiveInvestigatedSuspect = suspectIdx;
|
||
space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
|
||
emitLobbyEvidenceSync(sid, space);
|
||
return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount };
|
||
}
|
||
|
||
function emitLobbyEvidenceSync(sid, space) {
|
||
if (!sid || !space) return;
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
sock.emit('lobby-evidence-sync', {
|
||
myPlayerEvidence: clonePlayerEvidence(space, pid),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
|
||
});
|
||
});
|
||
}
|
||
|
||
function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) {
|
||
const msg = message || 'จบมินิเกม — กลับ LobbyB';
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
const personal = {
|
||
...basePayload,
|
||
myPlayerEvidence: clonePlayerEvidence(space, pid),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
|
||
};
|
||
sock.emit('quiz-ended', { message: msg, returnToLobbyB: true });
|
||
sock.emit('detective-minigame-ended', personal);
|
||
sock.emit('game-start', personal);
|
||
});
|
||
}
|
||
|
||
/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */
|
||
function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
|
||
const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : '';
|
||
const md = mapId && maps.has(mapId) ? maps.get(mapId) : null;
|
||
if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' };
|
||
|
||
space.suspectPhaseActive = false;
|
||
space.detectiveMinigameActive = true;
|
||
space.detectiveAwardDoneForRun = false;
|
||
space.coinAwardDoneForRun = false;
|
||
/* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */
|
||
space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; });
|
||
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
|
||
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
|
||
console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId);
|
||
space.bannedPlayerId = null;
|
||
/* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน)
|
||
สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */
|
||
space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length;
|
||
space.detectiveMinigameCardIndex = cardEntry.cardIndex;
|
||
// จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ
|
||
space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2)
|
||
? selectedIndex
|
||
: (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0);
|
||
space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID;
|
||
clearSpaceQuizTimers(space);
|
||
if (space.quizSession) space.quizSession.active = false;
|
||
space.quizSession = null;
|
||
space.mapId = mapId;
|
||
space.mapData = md;
|
||
/* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */
|
||
maybeSpawnSpecialQuizForRun(space);
|
||
/* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */
|
||
|
||
space.peers.forEach((p) => {
|
||
const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.direction = 'down';
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = 0;
|
||
p.gauntletJumpPending = false;
|
||
p.gauntletEliminated = false;
|
||
p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */
|
||
});
|
||
|
||
const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' });
|
||
const invPayload = {
|
||
selectedIndex,
|
||
cardIndex: cardEntry.cardIndex,
|
||
minigameKey: cardEntry.key,
|
||
minigameLabel: cardEntry.labelTh,
|
||
mapId,
|
||
};
|
||
io.to(sid).emit('suspect-investigation-start', invPayload);
|
||
|
||
if (md.gameType === 'quiz') {
|
||
const qErr = validateQuizMap(md);
|
||
if (qErr) {
|
||
space.detectiveMinigameActive = false;
|
||
space.gauntletReturnMapId = null;
|
||
return { ok: false, error: qErr };
|
||
}
|
||
/** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */
|
||
const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID;
|
||
if (!isQuizMissionShell) {
|
||
io.to(sid).emit('game-start', {
|
||
quizMode: true,
|
||
stayInRoomLobby: true,
|
||
mapId,
|
||
peersSnap,
|
||
detectiveMinigame: true,
|
||
minigameLabel: cardEntry.labelTh,
|
||
bannedPlayerId: space.bannedThisRunPlayerId || null,
|
||
});
|
||
setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || !spNow.peers.size) return;
|
||
const mdLive = maps.get(mapId);
|
||
if (!mdLive || mdLive.gameType !== 'quiz') return;
|
||
startQuizGame(sid, spNow, mdLive);
|
||
}, 900);
|
||
return { ok: true, mapId, gameType: md.gameType };
|
||
}
|
||
/* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */
|
||
}
|
||
|
||
stopGauntletTicker(space);
|
||
space.gauntletRun = null;
|
||
if (md.gameType === 'gauntlet') {
|
||
initGauntletPeersAll(space, md);
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
startGauntletTicker(sid, space);
|
||
} else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') {
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
|
||
space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; });
|
||
space.gauntletRun = newBalloonBossShellGauntletRunState();
|
||
}
|
||
/* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client
|
||
กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */
|
||
if (md.gameType === 'quiz_carry') {
|
||
space.quizCarryLobbyReady = {};
|
||
space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; });
|
||
io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space));
|
||
}
|
||
|
||
const gameStartPayload = {
|
||
mapId,
|
||
peersSnap,
|
||
detectiveMinigame: true,
|
||
detectiveReturnLobbyB: true,
|
||
minigameLabel: cardEntry.labelTh,
|
||
bannedPlayerId: space.bannedThisRunPlayerId || null,
|
||
};
|
||
if (md.gameType === 'gauntlet' && space.gauntletRun) {
|
||
gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
|
||
}
|
||
if (space.gauntletRun && space.gauntletRun.crownRunHeld) {
|
||
gameStartPayload.gauntletCrownRunHeld = true;
|
||
}
|
||
io.to(sid).emit('game-start', gameStartPayload);
|
||
return { ok: true, mapId, gameType: md.gameType };
|
||
}
|
||
|
||
function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
|
||
ensurePostCaseLobbyMapLoaded();
|
||
/* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */
|
||
awardMinigameRankCoins(sid, space);
|
||
clearSpaceQuizTimers(space);
|
||
clearSpecialQuizTimers(space);
|
||
space.specialQuiz = null;
|
||
if (space.quizSession) space.quizSession.active = false;
|
||
space.quizSession = null;
|
||
space.detectiveMinigameActive = false;
|
||
space.gauntletReturnMapId = null;
|
||
stopGauntletTicker(space);
|
||
space.gauntletRun = null;
|
||
const suspectIdx = resolveDetectiveSuspectAwardIndex(space);
|
||
if (suspectIdx >= 0) {
|
||
space.lastDetectiveInvestigatedSuspect = suspectIdx;
|
||
let awardIds = [];
|
||
if (awardOptions && awardOptions.allPlayers) {
|
||
awardIds = [...space.peers.keys()];
|
||
} else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) {
|
||
awardIds = [awardOptions.playerId];
|
||
} else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) {
|
||
awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id));
|
||
}
|
||
/* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน
|
||
(เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */
|
||
awardIds = awardIds.filter((id) => {
|
||
if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false;
|
||
const pp = space.peers.get(id);
|
||
if (pp && pp.bannedSpectator) return false;
|
||
return true;
|
||
});
|
||
/* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */
|
||
if (!space.detectiveAwardDoneForRun && awardIds.length) {
|
||
/* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */
|
||
grantDetectiveEvidenceForCurrentRun(sid, space, awardIds);
|
||
}
|
||
}
|
||
space.suspectActiveIndex = null;
|
||
space.bannedThisRunPlayerId = null;
|
||
const lb = maps.get(POST_CASE_LOBBY_SPACE_ID);
|
||
if (!lb) {
|
||
io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true });
|
||
return;
|
||
}
|
||
space.mapId = POST_CASE_LOBBY_SPACE_ID;
|
||
space.mapData = lb;
|
||
space.suspectPhaseActive = true;
|
||
space.peers.forEach((p) => {
|
||
const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.direction = 'down';
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = 0;
|
||
p.gauntletJumpPending = false;
|
||
p.gauntletEliminated = false;
|
||
});
|
||
const peersSnap = buildPeersSnapForSpace(space);
|
||
const basePayload = {
|
||
stayInRoomLobby: true,
|
||
mapId: POST_CASE_LOBBY_SPACE_ID,
|
||
peersSnap,
|
||
suspectPhaseActive: true,
|
||
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
detectiveReturn: true,
|
||
};
|
||
emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload);
|
||
/* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ"
|
||
ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */
|
||
const banPend = findPendingSpecialCard(space, 3);
|
||
console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space));
|
||
if (banPend && !space.pickVote) {
|
||
banPend.consumed = true;
|
||
/* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */
|
||
space.pendingBanVoteCard = banPend.card;
|
||
}
|
||
}
|
||
|
||
/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */
|
||
function emitTrialVoteUpdate(sid, space) {
|
||
const counts = [0, 0, 0];
|
||
const votes = space.trialVotes || {};
|
||
Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; });
|
||
io.to(sid).emit('trial-vote-update', {
|
||
counts,
|
||
voted: Object.keys(votes).length,
|
||
totalPlayers: trialEligibleVoterTotal(space),
|
||
});
|
||
}
|
||
|
||
function computeAndEmitTrialResult(sid, space) {
|
||
/* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */
|
||
autoVoteBotsTrial(space);
|
||
clearTrialVoteTimer(space);
|
||
const counts = [0, 0, 0];
|
||
const votes = space.trialVotes || {};
|
||
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0;
|
||
/* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */
|
||
{
|
||
/* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */
|
||
const forceWrong = !!space.testForceWrongVote;
|
||
space.testForceWrongVote = false;
|
||
const c2 = [0, 0, 0];
|
||
Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; });
|
||
let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; }
|
||
const anyVotes = (c2[0] + c2[1] + c2[2]) > 0;
|
||
const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit);
|
||
const pend = findPendingSpecialCard(space, 5);
|
||
if (wrong && pend && !space.trialBailUsed) {
|
||
pend.consumed = true;
|
||
space.trialBailUsed = true;
|
||
space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */
|
||
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' });
|
||
openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted });
|
||
return;
|
||
}
|
||
}
|
||
space.trialPhase = 'revealed';
|
||
const winners = [];
|
||
if (!space.trialScores) space.trialScores = {};
|
||
Object.keys(votes).forEach((id) => {
|
||
const v = votes[id];
|
||
if (v >= 0 && v <= 2) counts[v]++;
|
||
if (v === culprit) {
|
||
winners.push(id);
|
||
space.trialScores[id] = (space.trialScores[id] || 0) + 100;
|
||
}
|
||
});
|
||
const winnerNames = winners.map((id) => {
|
||
const p = space.peers.get(id);
|
||
if (p && p.nickname) return p.nickname;
|
||
if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) {
|
||
const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10);
|
||
return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท';
|
||
}
|
||
return String(id).slice(0, 6);
|
||
});
|
||
/* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */
|
||
let disruptorId = null;
|
||
if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) {
|
||
for (const [pid, p] of space.peers) {
|
||
const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey;
|
||
const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname;
|
||
if (keyMatch || nickMatch) { disruptorId = pid; break; }
|
||
}
|
||
}
|
||
io.to(sid).emit('trial-result', {
|
||
culpritIndex: culprit,
|
||
counts,
|
||
winners,
|
||
winnerNames,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
disruptorId,
|
||
hasDisruptor: !!disruptorId,
|
||
});
|
||
|
||
/* ===== Achievement auto-track ===== */
|
||
try {
|
||
const achv = [];
|
||
/* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */
|
||
winners.forEach((id) => {
|
||
const p = space.peers.get(id);
|
||
if (p && p.playerKey) {
|
||
achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 });
|
||
achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 });
|
||
}
|
||
});
|
||
/* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */
|
||
if (disruptorId && space.disruptorPlayerKey) {
|
||
const dKey = space.disruptorPlayerKey;
|
||
achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */
|
||
achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */
|
||
/* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */
|
||
let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; }
|
||
const anyV = (counts[0] + counts[1] + counts[2]) > 0;
|
||
if (anyV && mv !== culprit) {
|
||
achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */
|
||
achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */
|
||
}
|
||
}
|
||
submitAchievementProgress(achv);
|
||
} catch (e) { console.error('[achv] trial-result', e && e.message); }
|
||
|
||
space.silencedPlayerId = null;
|
||
}
|
||
|
||
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */
|
||
/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */
|
||
const TESTIMONY_BOT_PREFIX = '__lobby_bot_';
|
||
|
||
function testimonyBotIds(space) {
|
||
const n = effectiveBotSlotCount(space);
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i);
|
||
return out;
|
||
}
|
||
|
||
function buildTestimonyMembers(space) {
|
||
const arr = [];
|
||
space.peers.forEach((p, id) => {
|
||
arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false });
|
||
});
|
||
testimonyBotIds(space).forEach((bid, i) => {
|
||
arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true });
|
||
});
|
||
return arr;
|
||
}
|
||
|
||
/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */
|
||
function testimonyTotalParticipants(space) {
|
||
return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
|
||
}
|
||
|
||
/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */
|
||
function botTestimonyPickCount(space) {
|
||
const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0;
|
||
let maxOwned = 0;
|
||
space.peers.forEach((_p, pid) => {
|
||
const ev = clonePlayerEvidence(space, pid);
|
||
const len = Array.isArray(ev[round]) ? ev[round].length : 0;
|
||
if (len > maxOwned) maxOwned = len;
|
||
});
|
||
/* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */
|
||
if (maxOwned === 0) return 1;
|
||
return Math.max(1, Math.min(2, maxOwned));
|
||
}
|
||
|
||
/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */
|
||
function autoSubmitBotTestimonyPicks(space) {
|
||
if (!space.testimonySubmitted) space.testimonySubmitted = {};
|
||
const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0;
|
||
const want = botTestimonyPickCount(space);
|
||
testimonyBotIds(space).forEach((bid) => {
|
||
if (space.testimonySubmitted[bid] != null) return;
|
||
const picks = [];
|
||
for (let n = 0; n < want; n++) {
|
||
const c = pickEvidenceCardForSuspect(space, round, 'C');
|
||
if (c) picks.push(c);
|
||
}
|
||
space.testimonySubmitted[bid] = picks;
|
||
});
|
||
}
|
||
|
||
function emitTestimonyOpen(sid, space) {
|
||
space.testimonySubmitted = {};
|
||
space.testimonyReadyNext = {};
|
||
space.testimonyRevealed = false;
|
||
/* บอทส่งหลักฐานทันทีเมื่อเปิด round */
|
||
autoSubmitBotTestimonyPicks(space);
|
||
const submittedIds = Object.keys(space.testimonySubmitted || {});
|
||
const base = {
|
||
round: space.testimonyRound,
|
||
suspectIndex: space.testimonyRound,
|
||
members: buildTestimonyMembers(space),
|
||
totalPlayers: testimonyTotalParticipants(space),
|
||
hostId: space.hostId,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
submitted: submittedIds,
|
||
};
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
sock.emit('testimony-open', {
|
||
...base,
|
||
myPlayerEvidence: clonePlayerEvidence(space, pid),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
|
||
});
|
||
});
|
||
/* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */
|
||
emitTestimonyStatus(sid, space);
|
||
}
|
||
|
||
function emitTestimonyStatus(sid, space) {
|
||
io.to(sid).emit('testimony-status', {
|
||
round: space.testimonyRound,
|
||
submitted: Object.keys(space.testimonySubmitted || {}),
|
||
totalPlayers: testimonyTotalParticipants(space),
|
||
});
|
||
}
|
||
|
||
function doTestimonyReveal(sid, space) {
|
||
space.testimonyRevealed = true;
|
||
/* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */
|
||
autoSubmitBotTestimonyPicks(space);
|
||
/* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */
|
||
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
|
||
testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; });
|
||
io.to(sid).emit('testimony-reveal', {
|
||
round: space.testimonyRound,
|
||
picks: space.testimonySubmitted || {},
|
||
members: buildTestimonyMembers(space),
|
||
ready: Object.keys(space.testimonyReadyNext),
|
||
totalPlayers: testimonyTotalParticipants(space),
|
||
});
|
||
}
|
||
|
||
/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */
|
||
/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */
|
||
/* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */
|
||
function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; }
|
||
const TRIAL_VOTE_EXTENSION_MS = 10000;
|
||
|
||
function clearTrialVoteTimer(space) {
|
||
if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; }
|
||
}
|
||
|
||
/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */
|
||
function openTrialVotingPhase(sid, space, opts) {
|
||
opts = opts || {};
|
||
space.testimonyActive = false;
|
||
space.trialPhase = 'voting';
|
||
space.trialVotes = {};
|
||
/* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */
|
||
const excludedSuspect = opts.revote
|
||
? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect
|
||
: (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null))
|
||
: null;
|
||
if (!opts.revote) space.trialRevoteExcluded = null;
|
||
autoVoteBotsTrial(space);
|
||
clearTrialVoteTimer(space);
|
||
/* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */
|
||
let extraMs = 0;
|
||
let extensionCard = false;
|
||
const pend = findPendingSpecialCard(space, 2);
|
||
if (pend) {
|
||
extraMs = TRIAL_VOTE_EXTENSION_MS;
|
||
extensionCard = true;
|
||
pend.consumed = true;
|
||
}
|
||
const durMs = trialVoteMs() + extraMs;
|
||
space.trialVoteEndsAt = Date.now() + durMs;
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
sock.emit('trial-open', {
|
||
hostId: space.hostId,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
myPlayerEvidence: clonePlayerEvidence(space, pid),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
|
||
voteEndsAt: space.trialVoteEndsAt,
|
||
voteDurationMs: durMs,
|
||
extensionCard,
|
||
extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0,
|
||
revote: !!opts.revote,
|
||
excludedSuspect: excludedSuspect,
|
||
silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId),
|
||
});
|
||
});
|
||
if (extensionCard) {
|
||
io.to(sid).emit('special-card-applied', {
|
||
card: specialCardClientPayload(pend.card),
|
||
addSec: TRIAL_VOTE_EXTENSION_MS / 1000,
|
||
context: 'trial',
|
||
});
|
||
}
|
||
emitTrialVoteUpdate(sid, space);
|
||
space.trialVoteTimer = setTimeout(() => {
|
||
const spNow = spaces.get(sid);
|
||
if (!spNow || spNow.trialPhase !== 'voting') return;
|
||
computeAndEmitTrialResult(sid, spNow);
|
||
}, durMs + 120);
|
||
}
|
||
|
||
/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */
|
||
/* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */
|
||
function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; }
|
||
|
||
function clearPickVoteTimer(space) {
|
||
if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; }
|
||
}
|
||
|
||
function pickVoteCandidates(space) {
|
||
const arr = [];
|
||
space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }));
|
||
testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true }));
|
||
return arr;
|
||
}
|
||
|
||
function autoVotePickBots(space) {
|
||
const pv = space.pickVote;
|
||
if (!pv) return;
|
||
testimonyBotIds(space).forEach((bid) => {
|
||
if (pv.votes[bid] != null) return;
|
||
const choices = pv.candidates.filter((c) => c.id !== bid);
|
||
if (!choices.length) return;
|
||
pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id;
|
||
});
|
||
}
|
||
|
||
function emitPickVoteUpdate(sid, space) {
|
||
const pv = space.pickVote;
|
||
if (!pv) return;
|
||
const counts = {};
|
||
Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; });
|
||
io.to(sid).emit('player-pick-vote-update', {
|
||
voted: Object.keys(pv.votes).length,
|
||
total: pv.candidates.length,
|
||
counts,
|
||
});
|
||
}
|
||
|
||
function startPlayerPickVote(sid, space, purpose, onResolve) {
|
||
clearPickVoteTimer(space);
|
||
const PV_MS = pickVoteMs();
|
||
const candidates = pickVoteCandidates(space);
|
||
space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_MS, resolved: false, onResolve };
|
||
autoVotePickBots(space);
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
sock.emit('player-pick-vote-open', {
|
||
purpose,
|
||
candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })),
|
||
endsAt: space.pickVote.endsAt,
|
||
durationMs: PV_MS,
|
||
});
|
||
});
|
||
emitPickVoteUpdate(sid, space);
|
||
space.pickVoteTimer = setTimeout(() => {
|
||
const sp = spaces.get(sid);
|
||
if (!sp || !sp.pickVote || sp.pickVote.resolved) return;
|
||
resolvePlayerPickVote(sid, sp);
|
||
}, PV_MS + 120);
|
||
maybeResolvePickVote(sid, space);
|
||
}
|
||
|
||
function maybeResolvePickVote(sid, space) {
|
||
const pv = space.pickVote;
|
||
if (!pv || pv.resolved) return;
|
||
const ids = pv.candidates.map((c) => c.id);
|
||
if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space);
|
||
}
|
||
|
||
function resolvePlayerPickVote(sid, space) {
|
||
const pv = space.pickVote;
|
||
if (!pv || pv.resolved) return;
|
||
pv.resolved = true;
|
||
clearPickVoteTimer(space);
|
||
const counts = {};
|
||
Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; });
|
||
let max = 0;
|
||
Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; });
|
||
const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max);
|
||
/* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */
|
||
const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : null;
|
||
const tc = pv.candidates.find((c) => c.id === target);
|
||
const targetName = tc ? tc.nickname : '';
|
||
io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts });
|
||
const cb = pv.onResolve;
|
||
space.pickVote = null;
|
||
if (typeof cb === 'function') cb(target, targetName);
|
||
}
|
||
|
||
function trialSilencedIsParticipant(space) {
|
||
const s = space.silencedPlayerId;
|
||
if (!s) return false;
|
||
if (space.peers.has(s)) return true;
|
||
return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0);
|
||
}
|
||
|
||
function trialEligibleVoterTotal(space) {
|
||
let t = testimonyTotalParticipants(space);
|
||
if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1);
|
||
return t;
|
||
}
|
||
|
||
function autoVoteBotsTrial(space) {
|
||
if (!space.trialVotes) space.trialVotes = {};
|
||
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3);
|
||
const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1;
|
||
testimonyBotIds(space).forEach((bid) => {
|
||
if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */
|
||
if (space.trialVotes[bid] != null) return;
|
||
/* 40% โหวตถูก, 60% โหวตสุ่ม */
|
||
let pick;
|
||
if (Math.random() < 0.4) {
|
||
pick = culprit;
|
||
} else {
|
||
pick = Math.floor(Math.random() * 3);
|
||
}
|
||
/* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */
|
||
if (pick === excluded) pick = culprit;
|
||
space.trialVotes[bid] = pick;
|
||
});
|
||
}
|
||
|
||
function advanceTestimony(sid, space) {
|
||
space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1;
|
||
if (space.testimonyRound >= 3) {
|
||
space.trialBailUsed = false;
|
||
space.silencedPlayerId = null;
|
||
/* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */
|
||
const pend = findPendingSpecialCard(space, 4);
|
||
console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space));
|
||
if (pend) {
|
||
pend.consumed = true;
|
||
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' });
|
||
startPlayerPickVote(sid, space, 'silence', (targetId) => {
|
||
const sp = spaces.get(sid);
|
||
if (!sp) return;
|
||
sp.silencedPlayerId = targetId || null;
|
||
openTrialVotingPhase(sid, sp, { revote: false });
|
||
});
|
||
return;
|
||
}
|
||
openTrialVotingPhase(sid, space, { revote: false });
|
||
} else {
|
||
emitTestimonyOpen(sid, space);
|
||
}
|
||
}
|
||
|
||
function endQuizGame(sid, space, message) {
|
||
clearSpaceQuizTimers(space);
|
||
if (space.quizSession) space.quizSession.active = false;
|
||
if (space.detectiveMinigameActive) {
|
||
/* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น)
|
||
เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด"
|
||
การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */
|
||
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true });
|
||
/* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้
|
||
(ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */
|
||
return;
|
||
}
|
||
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' });
|
||
space.quizSession = null;
|
||
}
|
||
|
||
function emitQuizPlayerStates(sid, space) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.players) return;
|
||
space.peers.forEach((_, peerId) => {
|
||
const st = sess.players[peerId];
|
||
if (st) io.to(peerId).emit('quiz-player-state', { ...st });
|
||
});
|
||
}
|
||
|
||
function scheduleQuizReadPhase(sid, space, md) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active) return;
|
||
const idx = sess.qIndex;
|
||
const qList = sess.questions;
|
||
if (idx >= qList.length) {
|
||
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
|
||
return;
|
||
}
|
||
const q = qList[idx];
|
||
const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000;
|
||
sess.phase = 'read';
|
||
sess.phaseEndsAt = Date.now() + readMs;
|
||
io.to(sid).emit('quiz-phase', {
|
||
phase: 'read',
|
||
questionIndex: idx + 1,
|
||
questionTotal: qList.length,
|
||
text: String(q.text || '').trim(),
|
||
endsAt: sess.phaseEndsAt,
|
||
});
|
||
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs);
|
||
space.quizTimers.push(t);
|
||
}
|
||
|
||
function scheduleQuizAnswerPhase(sid, space, md) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active) return;
|
||
const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000;
|
||
sess.phase = 'answer';
|
||
sess.phaseEndsAt = Date.now() + answerMs;
|
||
io.to(sid).emit('quiz-phase', {
|
||
phase: 'answer',
|
||
questionIndex: sess.qIndex + 1,
|
||
questionTotal: sess.questions.length,
|
||
endsAt: sess.phaseEndsAt,
|
||
});
|
||
emitQuizPlayerStates(sid, space);
|
||
const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs);
|
||
space.quizTimers.push(t);
|
||
}
|
||
|
||
function resolveQuizRound(sid, space, md) {
|
||
const sess = space.quizSession;
|
||
if (!sess || !sess.active) return;
|
||
/* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */
|
||
const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md;
|
||
if (!mdLive) return;
|
||
const idx = sess.qIndex;
|
||
const q = sess.questions[idx];
|
||
const correctTrue = !!q.answerTrue;
|
||
const tGrid = mdLive.quizTrueArea || [];
|
||
const fGrid = mdLive.quizFalseArea || [];
|
||
|
||
let eligibleCount = 0;
|
||
let anyRight = false;
|
||
const results = [];
|
||
|
||
const ejectMoves = [];
|
||
|
||
space.peers.forEach((p, peerId) => {
|
||
const st = sess.players[peerId];
|
||
if (!st) return;
|
||
eligibleCount++;
|
||
const tx = Math.floor(Number(p.x));
|
||
const ty = Math.floor(Number(p.y));
|
||
const inT = quizCellOn(tGrid, tx, ty);
|
||
const inF = quizCellOn(fGrid, tx, ty);
|
||
let choice = null;
|
||
if (inT && !inF) choice = true;
|
||
else if (inF && !inT) choice = false;
|
||
else if (inT && inF) choice = true;
|
||
|
||
let right = false;
|
||
if (choice === null) right = false;
|
||
else right = (choice === correctTrue);
|
||
|
||
if (right) {
|
||
st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT;
|
||
anyRight = true;
|
||
}
|
||
/* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */
|
||
if (!right) {
|
||
st.cannotTrue = true;
|
||
st.cannotFalse = true;
|
||
const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder)
|
||
? Math.max(0, Math.floor(p.spawnJoinOrder))
|
||
: [...space.peers.keys()].indexOf(peerId);
|
||
const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd);
|
||
p.x = pos.x;
|
||
p.y = pos.y;
|
||
ejectMoves.push({
|
||
id: peerId,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
characterId: p.characterId ?? null,
|
||
});
|
||
}
|
||
|
||
results.push({
|
||
id: peerId,
|
||
nickname: p.nickname || '',
|
||
right,
|
||
choice,
|
||
eliminated: !!st.eliminated,
|
||
score: typeof st.score === 'number' ? st.score : 0,
|
||
});
|
||
});
|
||
|
||
ejectMoves.forEach((ev) => {
|
||
io.to(sid).emit('user-move', ev);
|
||
try {
|
||
const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id);
|
||
if (sock) sock.emit('user-move', ev);
|
||
} catch (e) { /* ignore */ }
|
||
});
|
||
|
||
const allWrong = eligibleCount > 0 && !anyRight;
|
||
|
||
const scores = {};
|
||
space.peers.forEach((_, peerId) => {
|
||
const st = sess.players[peerId];
|
||
scores[peerId] = st && typeof st.score === 'number' ? st.score : 0;
|
||
});
|
||
|
||
io.to(sid).emit('quiz-result', {
|
||
questionIndex: idx + 1,
|
||
correctTrue,
|
||
results,
|
||
allWrong,
|
||
scores,
|
||
});
|
||
emitQuizPlayerStates(sid, space);
|
||
|
||
/** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */
|
||
const playFullQuizRound =
|
||
!!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space);
|
||
if (allWrong && !playFullQuizRound) {
|
||
endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้');
|
||
return;
|
||
}
|
||
|
||
sess.qIndex++;
|
||
if (sess.qIndex >= sess.questions.length) {
|
||
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
|
||
return;
|
||
}
|
||
|
||
const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500;
|
||
const mapRef = sess.quizMapMd || md;
|
||
const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs);
|
||
space.quizTimers.push(t);
|
||
}
|
||
|
||
function startQuizGame(sid, space, md) {
|
||
clearSpaceQuizTimers(space);
|
||
const settings = loadQuizSettings();
|
||
const pool = getQuizQuestionPool(md);
|
||
const shuffled = shuffleQuizQuestions(pool);
|
||
const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10);
|
||
const picked = shuffled.slice(0, Math.min(cap, shuffled.length));
|
||
const players = {};
|
||
space.peers.forEach((_, peerId) => {
|
||
players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
|
||
});
|
||
space.quizSession = {
|
||
active: true,
|
||
questions: picked,
|
||
qIndex: 0,
|
||
phase: 'idle',
|
||
phaseEndsAt: 0,
|
||
readMs: settings.readMs,
|
||
answerMs: settings.answerMs,
|
||
betweenMs: settings.betweenMs,
|
||
players,
|
||
quizMapMd: md,
|
||
};
|
||
scheduleQuizReadPhase(sid, space, md);
|
||
}
|
||
|
||
function safeMapId(id) {
|
||
return (id || '').replace(/[^a-z0-9_-]/gi, '') || null;
|
||
}
|
||
|
||
/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 2–16หาย) */
|
||
function normalizeQuizCarryLayersOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_carry') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const normHub = () => {
|
||
const src = m.quizCarryHubArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.quizCarryHubArea = rows;
|
||
};
|
||
const normOptions = () => {
|
||
const src = m.quizCarryOptionArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) {
|
||
const v = r && r[x];
|
||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||
row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
m.quizCarryOptionArea = rows;
|
||
};
|
||
const normCountdown = () => {
|
||
const src = m.carryEmbedCountdownArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) {
|
||
const v = r && r[x];
|
||
row.push(Number(v) === 1 ? 1 : 0);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
m.carryEmbedCountdownArea = rows;
|
||
};
|
||
normHub();
|
||
normOptions();
|
||
normCountdown();
|
||
}
|
||
|
||
/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
|
||
function normalizeQuizBattleDomeAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.quizBattleDomeArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.quizBattleDomeArea = rows;
|
||
}
|
||
|
||
/** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */
|
||
function normalizeQuizBattleDomeCompOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle') return;
|
||
normalizeQuizBattleDomeAreaOnMap(m);
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const grid = m.quizBattleDomeArea;
|
||
const comp = Array(h).fill(0).map(() => Array(w).fill(0));
|
||
let nextId = 0;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue;
|
||
nextId++;
|
||
const stack = [[x, y]];
|
||
comp[y][x] = nextId;
|
||
while (stack.length) {
|
||
const c = stack.pop();
|
||
const cx = c[0], cy = c[1];
|
||
for (let i = 0; i < 4; i++) {
|
||
const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
|
||
const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
|
||
const nx = cx + dx, ny = cy + dy;
|
||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||
if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue;
|
||
comp[ny][nx] = nextId;
|
||
stack.push([nx, ny]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
m.quizBattleDomeComp = comp;
|
||
}
|
||
|
||
/**
|
||
* ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน
|
||
* ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน
|
||
*/
|
||
function hasQuizBattleManualPathGates(m) {
|
||
const mg = m && m.quizBattlePathGateArea;
|
||
if (!mg || !Array.isArray(mg)) return false;
|
||
for (let ty = 0; ty < mg.length; ty++) {
|
||
const row = mg[ty];
|
||
if (!row) continue;
|
||
for (let tx = 0; tx < row.length; tx++) {
|
||
if ((row[tx] | 0) > 0) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function buildQuizBattlePathGateFromManualOnMap(m) {
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const path = m.quizBattlePathArea;
|
||
const dome = m.quizBattleDomeArea;
|
||
const manual = m.quizBattlePathGateArea;
|
||
const gate = Array(h).fill(0).map(() => Array(w).fill(0));
|
||
const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
|
||
const queue = [];
|
||
|
||
function enqueue(tx, ty, req) {
|
||
if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
|
||
const onPath = path[ty] && path[ty][tx] === 1;
|
||
const onDome = dome && dome[ty] && dome[ty][tx] === 1;
|
||
if (!onPath && !onDome) return;
|
||
/* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */
|
||
if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
|
||
bestReq[ty][tx] = req;
|
||
queue.push({ tx, ty, req });
|
||
}
|
||
|
||
const sp = m.spawn || { x: 1, y: 1 };
|
||
const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
|
||
const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
|
||
enqueue(stx, sty, 0);
|
||
if (bestReq[sty][stx] < 0) {
|
||
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
|
||
}
|
||
const sa = m.spawnArea;
|
||
if (sa && Array.isArray(sa)) {
|
||
for (let ty = 0; ty < h; ty++) {
|
||
const row = sa[ty];
|
||
if (!row) continue;
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (row[tx] === 1) enqueue(tx, ty, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
while (queue.length) {
|
||
const cur = queue.shift();
|
||
const { tx, ty, req } = cur;
|
||
if (bestReq[ty][tx] !== req) continue;
|
||
const onDome = dome && dome[ty] && dome[ty][tx] === 1;
|
||
let enterReq = req;
|
||
const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
|
||
if (onDome) enterReq = 0;
|
||
else if (mg > 0) enterReq = Math.max(enterReq, mg);
|
||
if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
|
||
let outReq = enterReq;
|
||
if (mg > 0) outReq = Math.max(outReq, mg);
|
||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||
enqueue(tx + dx, ty + dy, outReq);
|
||
}
|
||
}
|
||
m.quizBattlePathGateRequired = gate;
|
||
}
|
||
|
||
function buildQuizBattlePathGateRequiredOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) {
|
||
if (m) m.quizBattlePathGateRequired = null;
|
||
return;
|
||
}
|
||
if (hasQuizBattleManualPathGates(m)) {
|
||
buildQuizBattlePathGateFromManualOnMap(m);
|
||
return;
|
||
}
|
||
normalizeQuizBattleDomeCompOnMap(m);
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const path = m.quizBattlePathArea;
|
||
const dome = m.quizBattleDomeArea;
|
||
const domeComp = m.quizBattleDomeComp;
|
||
const gate = Array(h).fill(0).map(() => Array(w).fill(0));
|
||
const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
|
||
const queue = [];
|
||
const sp = m.spawn || { x: 1, y: 1 };
|
||
const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
|
||
const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
|
||
|
||
function tryEnqueue(tx, ty, d) {
|
||
if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
|
||
if (dist[ty][tx] >= 0) return;
|
||
const onPath = path[ty] && path[ty][tx] === 1;
|
||
const onDome = dome && dome[ty] && dome[ty][tx] === 1;
|
||
if (!onPath && !onDome) return;
|
||
dist[ty][tx] = d;
|
||
queue.push({ tx, ty, d });
|
||
}
|
||
|
||
tryEnqueue(stx, sty, 0);
|
||
if (dist[sty][stx] < 0) {
|
||
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
|
||
}
|
||
const sa = m.spawnArea;
|
||
if (sa && Array.isArray(sa)) {
|
||
for (let ty = 0; ty < h; ty++) {
|
||
const row = sa[ty];
|
||
if (!row) continue;
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (row[tx] === 1) tryEnqueue(tx, ty, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
const domeMinDist = {};
|
||
let maxComp = 0;
|
||
while (queue.length) {
|
||
const cur = queue.shift();
|
||
const { tx, ty, d } = cur;
|
||
const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
|
||
if (dc > 0) {
|
||
maxComp = Math.max(maxComp, dc);
|
||
if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
|
||
}
|
||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||
const nx = tx + dx, ny = ty + dy;
|
||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||
if (dist[ny][nx] >= 0) continue;
|
||
const onPath = path[ny] && path[ny][nx] === 1;
|
||
const onDome = dome && dome[ny] && dome[ny][nx] === 1;
|
||
if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
|
||
}
|
||
}
|
||
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!path[ty] || path[ty][tx] !== 1) continue;
|
||
const d = dist[ty][tx];
|
||
if (d < 0) {
|
||
gate[ty][tx] = maxComp;
|
||
continue;
|
||
}
|
||
let req = 0;
|
||
for (let c = 1; c <= maxComp; c++) {
|
||
if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
|
||
}
|
||
gate[ty][tx] = req;
|
||
}
|
||
}
|
||
m.quizBattlePathGateRequired = gate;
|
||
}
|
||
|
||
function finalizeQuizBattleMapLayersOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle') return;
|
||
normalizeQuizBattleDomeAreaOnMap(m);
|
||
normalizeQuizBattlePathAreaOnMap(m);
|
||
normalizeQuizBattleDomeCompOnMap(m);
|
||
buildQuizBattlePathGateRequiredOnMap(m);
|
||
}
|
||
|
||
function normalizeQuizBattlePathAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'quiz_battle') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.quizBattlePathArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.quizBattlePathArea = rows;
|
||
}
|
||
|
||
function quizBattlePathModeActiveServer(m) {
|
||
if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
|
||
const g = m.quizBattlePathArea;
|
||
for (let y = 0; y < g.length; y++) {
|
||
const row = g[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizBattleFootprintFullyOnPathServer(m, px, py) {
|
||
if (!quizBattlePathModeActiveServer(m)) return true;
|
||
const g = m.quizBattlePathArea;
|
||
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
|
||
if (!keys.length) return false;
|
||
for (const k of keys) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (!g[ty] || g[ty][tx] !== 1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */
|
||
function quizBattlePositionAllowedServer(m, px, py) {
|
||
if (!quizBattlePathModeActiveServer(m)) return true;
|
||
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
|
||
if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true;
|
||
const sa = m.spawnArea;
|
||
if (!sa || !Array.isArray(sa)) return false;
|
||
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
|
||
if (!keys.length) return false;
|
||
for (const k of keys) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (!sa[ty] || sa[ty][tx] !== 1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function quizBattlePlayerSolvedCompsServer(space, socketId) {
|
||
if (!space || !space.qbwSolved) return new Set();
|
||
const set = space.qbwSolved.get(socketId);
|
||
return set || new Set();
|
||
}
|
||
|
||
function quizBattlePathGateRequiredAtServer(m, px, py) {
|
||
const gate = m && m.quizBattlePathGateRequired;
|
||
if (!gate || !quizBattlePathModeActiveServer(m)) return 0;
|
||
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
|
||
const path = m.quizBattlePathArea;
|
||
let maxReq = 0;
|
||
for (const k of keys) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (!path[ty] || path[ty][tx] !== 1) continue;
|
||
maxReq = Math.max(maxReq, gate[ty][tx] || 0);
|
||
}
|
||
return maxReq;
|
||
}
|
||
|
||
function quizBattleFootprintOnDomeServer(m, px, py) {
|
||
const dome = m && m.quizBattleDomeArea;
|
||
if (!dome) return false;
|
||
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
|
||
for (const k of keys) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (dome[ty] && dome[ty][tx] === 1) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function quizBattlePathGateAllowsServer(m, space, socketId, px, py) {
|
||
if (!m || !quizBattlePathModeActiveServer(m)) return true;
|
||
if (quizBattleFootprintOnDomeServer(m, px, py)) return true;
|
||
const maxReq = quizBattlePathGateRequiredAtServer(m, px, py);
|
||
if (maxReq <= 0) return true;
|
||
const solved = quizBattlePlayerSolvedCompsServer(space, socketId);
|
||
for (let c = 1; c <= maxReq; c++) {
|
||
if (!solved.has(c)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function serverFootprintClearOfWalls(md, px, py) {
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const keys = serverWallCollisionTileKeys(md, px, py);
|
||
if (!keys.length) return false;
|
||
for (const k of keys) {
|
||
const [tx, ty] = k.split(',').map(Number);
|
||
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
|
||
const row = md.objects && md.objects[ty];
|
||
if (!row || row[tx] === 1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
|
||
function snapPositionOntoQuizBattlePathServer(md, px, py) {
|
||
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
|
||
if (quizBattleSnapTargetValidServer(md, px, py)) return { x: px, y: py };
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const g = md.quizBattlePathArea;
|
||
let bestX = null, bestY = null, bestD = Infinity;
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
const nx = tx + 0.01;
|
||
const ny = ty + 0.01;
|
||
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
|
||
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
const nx = tx + 0.5;
|
||
const ny = ty + 0.5;
|
||
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
|
||
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
const sa = md.spawnArea;
|
||
if (sa && Array.isArray(sa)) {
|
||
for (let ty = 0; ty < h; ty++) {
|
||
const row = sa[ty];
|
||
if (!row) continue;
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (row[tx] !== 1) continue;
|
||
for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
|
||
if (!quizBattleSnapTargetValidServer(md, nx, ny)) continue;
|
||
const d = Math.abs(nx - px) + Math.abs(ny - py);
|
||
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (bestX != null) return { x: bestX, y: bestY };
|
||
return { x: px, y: py };
|
||
}
|
||
|
||
function collectQuizBattleValidSpawnCentersServer(md) {
|
||
const out = [];
|
||
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const g = md.quizBattlePathArea;
|
||
const seen = new Set();
|
||
for (let ty = 0; ty < h; ty++) {
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (!g[ty] || g[ty][tx] !== 1) continue;
|
||
for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
|
||
const key = nx.toFixed(3) + ',' + ny.toFixed(3);
|
||
if (seen.has(key)) continue;
|
||
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
|
||
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
|
||
seen.add(key);
|
||
out.push({ tx, ty, x: nx, y: ny });
|
||
}
|
||
}
|
||
}
|
||
out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
|
||
return out;
|
||
}
|
||
|
||
function quizBattleSnapTargetValidServer(md, x, y) {
|
||
if (!md || !md.objects) return false;
|
||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||
if (!serverFootprintClearOfWalls(md, x, y)) return false;
|
||
if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false;
|
||
return true;
|
||
}
|
||
|
||
function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) {
|
||
const ptx = Math.floor(Number(prefTx)) || 0;
|
||
const pty = Math.floor(Number(prefTy)) || 0;
|
||
for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
|
||
if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny };
|
||
}
|
||
const pool = collectQuizBattleSpawnPoolServer(md);
|
||
if (pool.length) {
|
||
let best = pool[0], bestD = Infinity;
|
||
for (const c of pool) {
|
||
const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
|
||
if (d < bestD) { bestD = d; best = c; }
|
||
}
|
||
return { x: best.x, y: best.y };
|
||
}
|
||
const valid = collectQuizBattleValidSpawnCentersServer(md);
|
||
if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
|
||
let best = valid[0], bestD = Infinity;
|
||
for (const c of valid) {
|
||
const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
|
||
if (d < bestD) { bestD = d; best = c; }
|
||
}
|
||
return { x: best.x, y: best.y };
|
||
}
|
||
|
||
function collectQuizBattleSpawnPoolServer(md) {
|
||
const out = [];
|
||
if (!md) return out;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
const grid = md.spawnArea;
|
||
const seen = new Set();
|
||
const cells = [];
|
||
if (grid && Array.isArray(grid)) {
|
||
for (let ty = 0; ty < h; ty++) {
|
||
const row = grid[ty];
|
||
if (!row) continue;
|
||
for (let tx = 0; tx < w; tx++) {
|
||
if (Number(row[tx]) === 1) cells.push({ tx, ty });
|
||
}
|
||
}
|
||
}
|
||
if (!cells.length && md.spawn) {
|
||
cells.push({
|
||
tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
|
||
ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
|
||
});
|
||
}
|
||
for (const { tx, ty } of cells) {
|
||
for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
|
||
const key = nx.toFixed(3) + ',' + ny.toFixed(3);
|
||
if (seen.has(key)) continue;
|
||
if (quizBattlePathModeActiveServer(md)) {
|
||
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
|
||
} else if (!isMapTileWalkableForSpawn(md, tx, ty)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
out.push({ tx, ty, x: nx, y: ny });
|
||
}
|
||
}
|
||
if (!out.length && quizBattlePathModeActiveServer(md)) {
|
||
return collectQuizBattleValidSpawnCentersServer(md);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function pickQuizBattleRandomSpawnWorldServer(md) {
|
||
const pool = collectQuizBattleSpawnPoolServer(md);
|
||
const fb = md.spawn || { x: 1, y: 1 };
|
||
if (!pool.length) {
|
||
return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y);
|
||
}
|
||
const idx = typeof crypto.randomInt === 'function'
|
||
? crypto.randomInt(0, pool.length)
|
||
: Math.floor(Math.random() * pool.length);
|
||
const pick = pool[idx];
|
||
return { x: pick.x, y: pick.y };
|
||
}
|
||
|
||
function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) {
|
||
if (!md) return { x: 1.5, y: 1.5 };
|
||
const ord = joinOrderIndex | 0;
|
||
const mode = md.lobbySpawnMode;
|
||
if (mode === 'slots6') {
|
||
const slots = parseLobbyPlayerSpawnsFromMap(md);
|
||
const j = Math.min(Math.max(0, ord), 5);
|
||
const slot = slots[j];
|
||
if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y);
|
||
return pickQuizBattleRandomSpawnWorldServer(md);
|
||
}
|
||
if (mode === 'fixed' && md.spawn) {
|
||
const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
|
||
return quizBattleNearestValidSpawnWorldServer(md, sx, sy);
|
||
}
|
||
return pickQuizBattleRandomSpawnWorldServer(md);
|
||
}
|
||
|
||
function pickQuizBattleSpawnFromMap(md, joinOrderIndex) {
|
||
const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex);
|
||
return { x: Math.floor(world.x), y: Math.floor(world.y) };
|
||
}
|
||
|
||
function normalizeJumpSurvivePlatformAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'jump_survive') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.jumpSurvivePlatformArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.jumpSurvivePlatformArea = rows;
|
||
}
|
||
|
||
function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'jump_survive') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const pa = m.jumpSurvivePlatformArea || [];
|
||
const src = m.jumpSurvivePlatformVariantArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) {
|
||
const has = pa[y] && pa[y][x] === 1;
|
||
let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
|
||
if (has && (!Number.isFinite(v) || v < 1)) v = 1;
|
||
if (v > 3) v = 3;
|
||
if (!has) v = 0;
|
||
row.push(v);
|
||
}
|
||
rows.push(row);
|
||
}
|
||
m.jumpSurvivePlatformVariantArea = rows;
|
||
}
|
||
|
||
function normalizeJumpSurviveHazardAreaOnMap(m) {
|
||
if (!m || m.gameType !== 'jump_survive') return;
|
||
const w = m.width || 20, h = m.height || 15;
|
||
const src = m.jumpSurviveHazardArea || [];
|
||
const rows = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const r = src[y];
|
||
const row = [];
|
||
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
|
||
rows.push(row);
|
||
}
|
||
m.jumpSurviveHazardArea = rows;
|
||
}
|
||
|
||
/**
|
||
* แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
|
||
*/
|
||
function applyStackTowerMissionMapPlayPolicy(safeId, m) {
|
||
if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
|
||
const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
|
||
const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
|
||
const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
|
||
const spNum = Number(raw.speedPxPerSec);
|
||
const speedPxPerSec = Number.isFinite(spNum)
|
||
? Math.max(0, Math.min(400, Math.floor(spNum)))
|
||
: 0;
|
||
const sel = Math.floor(Number(raw.stepEveryLayers));
|
||
const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
|
||
const ssp = Math.floor(Number(raw.stepScrollPx));
|
||
const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
|
||
const sam = Math.floor(Number(raw.stepAnimMs));
|
||
const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
|
||
const st = {
|
||
enabled: raw.enabled === true,
|
||
speedPxPerSec,
|
||
scrollDirection,
|
||
stepEveryLayers,
|
||
stepScrollPx,
|
||
stepAnimMs,
|
||
};
|
||
if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
|
||
if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
|
||
const rg = Number(raw.releaseGapWorldPx);
|
||
if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
|
||
m.stackTowerBgScroll = st;
|
||
const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
|
||
const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
|
||
const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
|
||
m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
|
||
}
|
||
|
||
function loadMaps() {
|
||
try {
|
||
if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
|
||
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(MAPS_DIR);
|
||
for (const f of files) {
|
||
if (!f.endsWith('.json')) continue;
|
||
const id = f.slice(0, -5);
|
||
if (!safeMapId(id)) continue;
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
|
||
normalizeQuizCarryLayersOnMap(data);
|
||
normalizeJumpSurvivePlatformAreaOnMap(data);
|
||
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
|
||
normalizeJumpSurviveHazardAreaOnMap(data);
|
||
finalizeQuizBattleMapLayersOnMap(data);
|
||
applyStackTowerMissionMapPlayPolicy(id, data);
|
||
maps.set(id, data);
|
||
} catch (e) { console.error('load map', f, e.message); }
|
||
}
|
||
} catch (e) { console.error('loadMaps', e.message); }
|
||
}
|
||
|
||
/**
|
||
* อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
|
||
* (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
|
||
*/
|
||
function rehydrateSingleMapFromDisk(safeId) {
|
||
if (!safeId) return false;
|
||
const fp = path.join(MAPS_DIR, safeId + '.json');
|
||
if (!fs.existsSync(fp)) return false;
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
||
normalizeQuizCarryLayersOnMap(data);
|
||
normalizeJumpSurvivePlatformAreaOnMap(data);
|
||
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
|
||
normalizeJumpSurviveHazardAreaOnMap(data);
|
||
finalizeQuizBattleMapLayersOnMap(data);
|
||
applyStackTowerMissionMapPlayPolicy(safeId, data);
|
||
maps.set(safeId, data);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('rehydrate map', safeId, e.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
|
||
function ensurePostCaseLobbyMapLoaded() {
|
||
if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
|
||
try {
|
||
const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
|
||
if (!fs.existsSync(fp)) return false;
|
||
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
||
normalizeQuizCarryLayersOnMap(data);
|
||
maps.set(POST_CASE_LOBBY_SPACE_ID, data);
|
||
return true;
|
||
} catch (e) {
|
||
console.error('ensurePostCaseLobbyMapLoaded', e.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function saveMap(id, m) {
|
||
const safe = safeMapId(id);
|
||
if (!safe) return;
|
||
try {
|
||
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
|
||
fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
|
||
} catch (e) { console.error('saveMap', id, e.message); }
|
||
}
|
||
|
||
function isMapNameTaken(name, excludeId) {
|
||
const n = (name || '').trim().toLowerCase();
|
||
for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
|
||
return false;
|
||
}
|
||
|
||
function applyDevCors(req, res) {
|
||
const origin = req.headers.origin;
|
||
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
|
||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||
res.setHeader('Vary', 'Origin');
|
||
}
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
applyDevCors(req, res);
|
||
if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
|
||
res.writeHead(204);
|
||
res.end();
|
||
return;
|
||
}
|
||
let url = req.url;
|
||
if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
|
||
if (url.startsWith(BASE_PATH + '/api/maps')) {
|
||
let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
|
||
if (id.includes('?')) id = id.split('?')[0];
|
||
if (req.method === 'GET' && id) {
|
||
const sid = safeMapId(decodeURIComponent(id));
|
||
if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
|
||
rehydrateSingleMapFromDisk(sid);
|
||
const m = maps.get(sid);
|
||
if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
|
||
}
|
||
if (req.method === 'GET' && !id) {
|
||
const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
|
||
}
|
||
if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(body);
|
||
const name = (d.name || '').trim() || 'ฉากใหม่';
|
||
if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
|
||
const id = Date.now().toString(36);
|
||
const m = { ...defaultMap(), ...d, name };
|
||
if (!m.blockPlayer) m.blockPlayer = [];
|
||
if (!m.interactive) m.interactive = [];
|
||
if (!m.startGameArea) m.startGameArea = [];
|
||
if (!m.spawnArea) m.spawnArea = [];
|
||
if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
|
||
if (!m.quizTrueArea) m.quizTrueArea = [];
|
||
if (!m.quizFalseArea) m.quizFalseArea = [];
|
||
if (!m.quizQuestionArea) m.quizQuestionArea = [];
|
||
if (!m.quizQuestions) m.quizQuestions = [];
|
||
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
|
||
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
|
||
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
|
||
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
|
||
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
|
||
if (!m.stackReleaseArea) m.stackReleaseArea = [];
|
||
if (!m.stackLandArea) m.stackLandArea = [];
|
||
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
|
||
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
|
||
if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
|
||
if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
|
||
normalizeQuizCarryLayersOnMap(m);
|
||
normalizeJumpSurvivePlatformAreaOnMap(m);
|
||
normalizeJumpSurvivePlatformVariantAreaOnMap(m);
|
||
normalizeJumpSurviveHazardAreaOnMap(m);
|
||
finalizeQuizBattleMapLayersOnMap(m);
|
||
maps.set(id, m);
|
||
saveMap(id, m);
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, mapId: id }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (req.method === 'PUT' && id && maps.has(id)) {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(body);
|
||
const name = (d.name || '').trim() || maps.get(id).name;
|
||
if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
|
||
const m = { ...maps.get(id), ...d, name };
|
||
if (!m.blockPlayer) m.blockPlayer = [];
|
||
if (!m.interactive) m.interactive = [];
|
||
if (!m.startGameArea) m.startGameArea = [];
|
||
if (!m.spawnArea) m.spawnArea = [];
|
||
if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
|
||
if (!m.quizTrueArea) m.quizTrueArea = [];
|
||
if (!m.quizFalseArea) m.quizFalseArea = [];
|
||
if (!m.quizQuestionArea) m.quizQuestionArea = [];
|
||
if (!m.quizQuestions) m.quizQuestions = [];
|
||
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
|
||
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
|
||
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
|
||
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
|
||
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
|
||
if (!m.stackReleaseArea) m.stackReleaseArea = [];
|
||
if (!m.stackLandArea) m.stackLandArea = [];
|
||
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
|
||
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
|
||
if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
|
||
if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
|
||
normalizeQuizCarryLayersOnMap(m);
|
||
normalizeJumpSurvivePlatformAreaOnMap(m);
|
||
normalizeJumpSurvivePlatformVariantAreaOnMap(m);
|
||
normalizeJumpSurviveHazardAreaOnMap(m);
|
||
finalizeQuizBattleMapLayersOnMap(m);
|
||
applyStackTowerMissionMapPlayPolicy(id, m);
|
||
maps.set(id, m);
|
||
saveMap(id, m);
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, mapId: id }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
const quizCarryDiskPaths = new Set([
|
||
BASE_PATH + '/api/quiz-carry-from-disk',
|
||
BASE_PATH + '/api-quiz-carry-from-disk.php',
|
||
]);
|
||
if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
|
||
if (req.method === 'GET') {
|
||
const g = loadQuizSettings();
|
||
const slice = {
|
||
carryMapPanelTheme: g.carryMapPanelTheme,
|
||
carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
|
||
carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
|
||
carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
|
||
carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
|
||
carryReadMs: g.carryReadMs,
|
||
carryAnswerMs: g.carryAnswerMs,
|
||
carrySessionLength: g.carrySessionLength,
|
||
carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
|
||
carryWalkSpeedMult: g.carryWalkSpeedMult,
|
||
carryQuestions: g.carryQuestions,
|
||
};
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify(slice));
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify(getSystemStats()));
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify(loadQuizSettings()));
|
||
}
|
||
if (req.method === 'PUT') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const saved = saveQuizSettings(d);
|
||
if (!saved.ok) {
|
||
res.writeHead(500);
|
||
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
|
||
}
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({
|
||
ok: true,
|
||
battleQuizMcqSaved: saved.battleQuizMcqSaved,
|
||
carryQuestionsSaved: saved.carryQuestionsSaved,
|
||
specialQuizSets: saved.specialQuizSets,
|
||
carryMapPanelTheme: saved.carryMapPanelTheme,
|
||
carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
|
||
carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
|
||
carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
|
||
carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
|
||
}));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify(loadEvidenceCards()));
|
||
}
|
||
if (req.method === 'PUT') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const saved = saveEvidenceCards(d);
|
||
if (!saved.ok) {
|
||
res.writeHead(500);
|
||
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
|
||
}
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify(loadCaseMedia()));
|
||
}
|
||
if (req.method === 'PUT') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const saved = saveCaseMedia(d);
|
||
if (!saved.ok) {
|
||
res.writeHead(500);
|
||
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
|
||
}
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
Pragma: 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
|
||
if (req.method === 'GET') {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
});
|
||
return res.end(JSON.stringify({ ...runtimeGameTiming }));
|
||
}
|
||
if (req.method === 'PUT') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const saved = saveGameTimingToFile(d);
|
||
if (!saved.ok) {
|
||
res.writeHead(500);
|
||
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
|
||
}
|
||
res.writeHead(200);
|
||
const { ok: _ok, error: _err, ...timingRest } = saved;
|
||
return res.end(JSON.stringify({ ok: true, ...timingRest }));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(405);
|
||
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
|
||
}
|
||
const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
|
||
}
|
||
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const dataUrl = d.imageDataUrl;
|
||
if (!dataUrl || typeof dataUrl !== 'string') {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
|
||
}
|
||
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
|
||
if (!m) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
|
||
}
|
||
const extRaw = m[1].toLowerCase();
|
||
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
|
||
const b64 = m[2].replace(/\s/g, '');
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
|
||
}
|
||
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
|
||
}
|
||
ensureGauntletAssetsDir();
|
||
const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
|
||
const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
|
||
let fname;
|
||
if (replaceFilename) {
|
||
const s = safeGauntletStoredFilename(replaceFilename);
|
||
if (!s) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
|
||
}
|
||
const cur = path.join(GAUNTLET_ASSETS_DIR, s);
|
||
if (!fs.existsSync(cur)) {
|
||
res.writeHead(404);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
|
||
}
|
||
const norm = (e) => {
|
||
let x = String(e || '').toLowerCase();
|
||
if (x.startsWith('.')) x = x.slice(1);
|
||
return x === 'jpeg' ? 'jpg' : x;
|
||
};
|
||
if (norm(path.extname(s)) !== norm(ext)) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
|
||
}
|
||
fname = s;
|
||
} else {
|
||
fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
||
}
|
||
fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
|
||
const meta = loadGauntletAssetsMeta();
|
||
const prev = meta[fname] || {};
|
||
meta[fname] = {
|
||
...prev,
|
||
label: labelIn || prev.label || fname,
|
||
updatedAt: Date.now(),
|
||
};
|
||
saveGauntletAssetsMeta(meta);
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({
|
||
ok: true,
|
||
filename: fname,
|
||
url: BASE_PATH + '/img/gauntlet-assets/' + fname,
|
||
}));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
|
||
if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const dataUrl = d.imageDataUrl;
|
||
if (!dataUrl || typeof dataUrl !== 'string') {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
|
||
}
|
||
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
|
||
if (!m) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
|
||
}
|
||
const extRaw = m[1].toLowerCase();
|
||
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
|
||
const b64 = m[2].replace(/\s/g, '');
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
|
||
}
|
||
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
|
||
}
|
||
ensureQuizCarryPlaqueAssetsDir();
|
||
const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
||
fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({
|
||
ok: true,
|
||
filename: fname,
|
||
url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
|
||
}));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; });
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const fname = safeGauntletStoredFilename(d.filename);
|
||
if (!fname) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
|
||
}
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
|
||
if (!fs.existsSync(fp)) {
|
||
res.writeHead(404);
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
|
||
}
|
||
const meta = loadGauntletAssetsMeta();
|
||
const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
|
||
meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
|
||
saveGauntletAssetsMeta(meta);
|
||
res.writeHead(200);
|
||
return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
|
||
} catch (e) {
|
||
res.writeHead(400);
|
||
return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
|
||
try {
|
||
const q = url.includes('?') ? url.split('?')[1] : '';
|
||
const sp = new URLSearchParams(q);
|
||
const rawFn = sp.get('file');
|
||
const fname = safeGauntletStoredFilename(rawFn);
|
||
if (!fname) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
|
||
}
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
|
||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||
const meta = loadGauntletAssetsMeta();
|
||
delete meta[fname];
|
||
saveGauntletAssetsMeta(meta);
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
} catch (e) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
|
||
}
|
||
}
|
||
if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
|
||
try {
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||
const ids = new Set();
|
||
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
|
||
files.forEach(f => {
|
||
let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
|
||
if (m) ids.add(m[1]);
|
||
m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
|
||
if (m) ids.add(m[1]);
|
||
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
|
||
if (m) ids.add(m[1]);
|
||
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
|
||
if (m) ids.add(m[1]);
|
||
});
|
||
const idArr = [...ids];
|
||
const oldestMsById = {};
|
||
idArr.forEach((id) => {
|
||
let oldest = Infinity;
|
||
const prefix = id + '_';
|
||
files.forEach((f) => {
|
||
if (!f.startsWith(prefix) || !ext.test(f)) return;
|
||
try {
|
||
const st = fs.statSync(path.join(CHARACTERS_DIR, f));
|
||
const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
|
||
if (t < oldest) oldest = t;
|
||
} catch (e) { /* ignore */ }
|
||
});
|
||
oldestMsById[id] = oldest === Infinity ? 0 : oldest;
|
||
});
|
||
idArr.sort((a, b) => {
|
||
const ta = oldestMsById[a] || 0;
|
||
const tb = oldestMsById[b] || 0;
|
||
if (ta !== tb) return ta - tb;
|
||
return String(a).localeCompare(String(b));
|
||
});
|
||
const list = idArr.map((id) => {
|
||
const prefix = id + '_';
|
||
const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
|
||
const layerNameSet = new Set();
|
||
if (hasLayerFiles) {
|
||
files.forEach((f) => {
|
||
if (!f.startsWith(prefix) || !ext.test(f)) return;
|
||
let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
|
||
if (m) {
|
||
layerNameSet.add(m[1]);
|
||
return;
|
||
}
|
||
m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
|
||
if (m) layerNameSet.add(m[1]);
|
||
});
|
||
}
|
||
const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
|
||
const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
|
||
return { id, name: id, hasLayerFiles, layers, layerManifest };
|
||
});
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify(list));
|
||
} catch (e) {
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify([]));
|
||
}
|
||
}
|
||
const urlPath = url.split('?')[0];
|
||
{
|
||
const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
|
||
if (lm && req.method === 'GET') {
|
||
const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
|
||
if (!id) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
|
||
}
|
||
try {
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||
const manifest = buildCharacterLayerManifest(id, files);
|
||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify(manifest));
|
||
} catch (e) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
|
||
}
|
||
}
|
||
}
|
||
if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
|
||
if (/\/layer-manifest$/i.test(urlPath)) {
|
||
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
|
||
}
|
||
const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
|
||
const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
|
||
if (!id) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
|
||
}
|
||
try {
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||
const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||
let removed = 0;
|
||
files.forEach(f => {
|
||
if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
|
||
try {
|
||
fs.unlinkSync(path.join(CHARACTERS_DIR, f));
|
||
removed++;
|
||
} catch (e) {
|
||
// ignore single file errors
|
||
}
|
||
}
|
||
});
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, removed }));
|
||
} catch (e) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
|
||
}
|
||
return;
|
||
}
|
||
if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
if (!body || body.length < 2) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
|
||
}
|
||
const d = JSON.parse(body);
|
||
const dirs = ['up', 'down', 'left', 'right'];
|
||
const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
|
||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||
const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
|
||
let walkWritten = 0;
|
||
for (const dir of dirs) {
|
||
let data = d[dir];
|
||
if (!data) continue;
|
||
const list = Array.isArray(data) ? data : [data];
|
||
let frameIndex = 0;
|
||
for (const dataUrl of list) {
|
||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||
if (!m) continue;
|
||
const b64 = m[1].replace(/\s/g, '');
|
||
if (!b64.length) continue;
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
if (buf.length === 0) continue;
|
||
// เขียนไฟล์เฟรม (สำหรับ animation)
|
||
const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
|
||
// ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก <id>_<dir>.png เพื่อใช้เป็น preview / fallback
|
||
if (list.length > 1 && frameIndex === 0) {
|
||
const baseName = id + '_' + dir + '.png';
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
|
||
}
|
||
walkWritten++;
|
||
frameIndex++;
|
||
}
|
||
}
|
||
let layerWritten = 0;
|
||
// ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
|
||
const lf = d.layerFrames;
|
||
if (lf && typeof lf === 'object') {
|
||
for (const dir of dirs) {
|
||
const arr = lf[dir];
|
||
if (!Array.isArray(arr)) continue;
|
||
const multi = arr.length > 1;
|
||
arr.forEach((frameObj, frameIndex) => {
|
||
if (!frameObj || typeof frameObj !== 'object') return;
|
||
for (const layerName of Object.keys(frameObj)) {
|
||
if (!ALLOWED_LAYER[layerName]) continue;
|
||
const dataUrl = frameObj[layerName];
|
||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||
if (!m) continue;
|
||
const b64 = m[1].replace(/\s/g, '');
|
||
if (!b64.length) continue;
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
if (buf.length === 0) continue;
|
||
const fnameLayer = multi
|
||
? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
|
||
: (id + '_' + dir + '_layer_' + layerName + '.png');
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
|
||
layerWritten++;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
const lfIdle = d.layerFramesIdle;
|
||
if (lfIdle && typeof lfIdle === 'object') {
|
||
for (const dir of dirs) {
|
||
const arr = lfIdle[dir];
|
||
if (!Array.isArray(arr)) continue;
|
||
const multi = arr.length > 1;
|
||
arr.forEach((frameObj, frameIndex) => {
|
||
if (!frameObj || typeof frameObj !== 'object') return;
|
||
for (const layerName of Object.keys(frameObj)) {
|
||
if (!ALLOWED_LAYER[layerName]) continue;
|
||
const dataUrl = frameObj[layerName];
|
||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||
if (!m) continue;
|
||
const b64 = m[1].replace(/\s/g, '');
|
||
if (!b64.length) continue;
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
if (buf.length === 0) continue;
|
||
const fnameLayer = multi
|
||
? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
|
||
: (id + '_' + dir + '_idle_layer_' + layerName + '.png');
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
|
||
layerWritten++;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
|
||
if (idleObj) {
|
||
for (const dir of dirs) {
|
||
let data = idleObj[dir];
|
||
if (!data) continue;
|
||
const list = Array.isArray(data) ? data : [data];
|
||
let frameIndex = 0;
|
||
for (const dataUrl of list) {
|
||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||
if (!m) continue;
|
||
const b64 = m[1].replace(/\s/g, '');
|
||
if (!b64.length) continue;
|
||
let buf;
|
||
try {
|
||
buf = Buffer.from(b64, 'base64');
|
||
} catch (err) {
|
||
continue;
|
||
}
|
||
if (buf.length === 0) continue;
|
||
const fnameFrame = list.length > 1
|
||
? (id + '_' + dir + '_idle_' + frameIndex + '.png')
|
||
: (id + '_' + dir + '_idle.png');
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
|
||
if (list.length > 1 && frameIndex === 0) {
|
||
const baseName = id + '_' + dir + '_idle.png';
|
||
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
|
||
}
|
||
frameIndex++;
|
||
}
|
||
}
|
||
}
|
||
if (walkWritten === 0 && layerWritten === 0) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
|
||
}
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, characterId: id }));
|
||
} catch (e) {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url.startsWith(BASE_PATH + '/api/spaces')) {
|
||
const parts = url.slice((BASE_PATH + '/api/spaces').length).split('?')[0].split('#')[0].split('/').filter(Boolean);
|
||
const spaceId = parts[0];
|
||
if (req.method === 'GET' && !spaceId) {
|
||
const now = Date.now();
|
||
for (const [id, s] of [...spaces.entries()]) {
|
||
if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
|
||
const n = s.peers ? s.peers.size : 0;
|
||
if (n !== 0) continue;
|
||
/* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */
|
||
const since = s.emptySince || s.createdAt;
|
||
const stale = since == null || now - since > SPACE_EMPTY_TTL_MS;
|
||
if (stale) spaces.delete(id);
|
||
}
|
||
const list = [...spaces]
|
||
.filter(([, s]) => !s.isPrivate)
|
||
.filter(([, s]) => (s.peers && s.peers.size > 0))
|
||
.map(([id, s]) => ({
|
||
spaceId: id,
|
||
spaceName: s.spaceName || id,
|
||
mapName: (s.mapData && s.mapData.name) || '—',
|
||
peerCount: s.peers ? s.peers.size : 0,
|
||
maxPlayers: s.maxPlayers || 10
|
||
}));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
|
||
}
|
||
if (req.method === 'GET' && spaceId) {
|
||
const s = spaces.get(spaceId);
|
||
if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' }));
|
||
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name }));
|
||
}
|
||
if (req.method === 'POST' && !spaceId) {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const mapData = d.mapId ? maps.get(d.mapId) : null;
|
||
if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' }));
|
||
const isPrivate = !!d.isPrivate;
|
||
let sid;
|
||
let roomPassword = '';
|
||
if (isPrivate) {
|
||
/* ห้องส่วนตัว: ใช้ "ชื่อห้อง" เป็น spaceId (canonical = trim+lowercase) → join ด้วยชื่อ+รหัสได้
|
||
(เดิมเป็นรหัสสุ่ม 6 ตัว ทำให้ join ด้วยชื่อไม่เจอ) */
|
||
const canon = String(d.name || '').trim().toLowerCase();
|
||
if (!canon) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'กรุณาใส่ชื่อห้อง' }));
|
||
if (spaces.has(canon)) return res.writeHead(409), res.end(JSON.stringify({ ok: false, error: 'ชื่อห้องนี้ถูกใช้แล้ว ลองชื่ออื่น' }));
|
||
sid = canon;
|
||
roomPassword = String(d.password == null ? '' : d.password).replace(/\D/g, '').slice(0, 4);
|
||
} else {
|
||
const base = (d.name || '').trim() || 'space';
|
||
sid = base + '-' + Date.now();
|
||
}
|
||
const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid);
|
||
let maxPlayers = parseInt(d.maxPlayers, 10);
|
||
if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10;
|
||
/* Quiz Battle (ฉากเดินตอบ แบบ ZEP) รองรับได้ถึง 50 คน · โหมดอื่นคงเพดานเดิม 10 */
|
||
const mpCap = mapData.gameType === 'quiz_battle' ? 50 : 10;
|
||
maxPlayers = Math.min(mpCap, Math.max(1, maxPlayers));
|
||
if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6);
|
||
const mapId = d.mapId ? String(d.mapId).trim() : null;
|
||
const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview';
|
||
let botSlotCount = parseInt(d.botSlotCount, 10);
|
||
if (isNaN(botSlotCount) || botSlotCount < 0) botSlotCount = 0;
|
||
botSlotCount = effectiveBotSlotCount({ botSlotCount, maxPlayers });
|
||
spaces.set(sid, {
|
||
mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(),
|
||
roomPassword,
|
||
previewEditorTest,
|
||
botSlotCount,
|
||
suspectCardMinigames: null,
|
||
suspectPhaseActive: false,
|
||
suspectPickIndex: 0,
|
||
detectiveMinigameActive: false,
|
||
});
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') {
|
||
function sendIceServers(iceServers) {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ iceServers: iceServers || [] }));
|
||
}
|
||
function fallback() {
|
||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
|
||
const turnUser = process.env.TURN_USER;
|
||
const turnCred = process.env.TURN_CRED;
|
||
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
|
||
sendIceServers(iceServers);
|
||
}
|
||
const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY;
|
||
const meteredAppId = process.env.METERED_TURN_APP_ID;
|
||
let meteredBase = process.env.METERED_TURN_URL;
|
||
if (!meteredBase) {
|
||
let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret';
|
||
if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live';
|
||
meteredBase = 'https://' + meteredHost;
|
||
}
|
||
meteredBase = meteredBase.replace(/\/$/, '');
|
||
if (meteredKey) {
|
||
function tryNext(credUrl) {
|
||
return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) {
|
||
if (data && (data.error || data.message)) throw new Error(data.error || data.message);
|
||
const list = Array.isArray(data) ? data : (data && data.iceServers);
|
||
if (list && list.length) return sendIceServers(list);
|
||
if (data && data.data && Array.isArray(data.data) && data.data.length) {
|
||
const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca';
|
||
const built = data.data.map(function (c) {
|
||
return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password };
|
||
});
|
||
return sendIceServers(built);
|
||
}
|
||
throw new Error('No iceServers');
|
||
});
|
||
}
|
||
const urlsToTry = [
|
||
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey),
|
||
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey),
|
||
meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey)
|
||
];
|
||
let p = tryNext(urlsToTry[0]);
|
||
for (let i = 1; i < urlsToTry.length; i++) {
|
||
p = p.catch(function () { return tryNext(urlsToTry[i]); });
|
||
}
|
||
p.catch(fallback);
|
||
return;
|
||
}
|
||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
|
||
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
|
||
const turnUser = process.env.TURN_USER;
|
||
const turnCred = process.env.TURN_CRED;
|
||
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
|
||
sendIceServers(iceServers);
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const password = (d.password || '').trim();
|
||
if (password !== ADMIN_PASSWORD) {
|
||
return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' }));
|
||
}
|
||
const token = require('crypto').randomBytes(24).toString('hex');
|
||
adminTokens.add(token);
|
||
res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax');
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ ok: true }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') {
|
||
const token = getAdminToken(req.headers.cookie);
|
||
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
|
||
const s = loadAiSettings();
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({
|
||
model: s.model || 'gpt-4o-mini',
|
||
models: AI_MODELS,
|
||
hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0),
|
||
intent: s.intent != null ? s.intent : '',
|
||
rag_enabled: !!s.rag_enabled,
|
||
rag_context: s.rag_context != null ? s.rag_context : '',
|
||
}));
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') {
|
||
const token = getAdminToken(req.headers.cookie);
|
||
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const s = loadAiSettings();
|
||
if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim();
|
||
if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini';
|
||
if (d.intent !== undefined) s.intent = String(d.intent);
|
||
if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled;
|
||
if (d.rag_context !== undefined) s.rag_context = String(d.rag_context);
|
||
saveAiSettings(s);
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ ok: true }));
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', c => body += c);
|
||
req.on('end', () => {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
try {
|
||
const d = JSON.parse(body || '{}');
|
||
const message = (d.message || d.text || '').trim();
|
||
if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
|
||
const s = loadAiSettings();
|
||
if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
|
||
const model = s.model || 'gpt-4o-mini';
|
||
const messages = [];
|
||
const systemParts = [];
|
||
if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
|
||
if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
|
||
systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
|
||
}
|
||
if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
|
||
messages.push({ role: 'user', content: message });
|
||
const payload = JSON.stringify({
|
||
model: model,
|
||
messages: messages,
|
||
max_tokens: 500,
|
||
});
|
||
const urlObj = new URL('https://api.openai.com/v1/chat/completions');
|
||
const opts = {
|
||
hostname: urlObj.hostname,
|
||
path: urlObj.pathname,
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + s.openai_api_key,
|
||
'Content-Length': Buffer.byteLength(payload, 'utf8'),
|
||
},
|
||
};
|
||
const reqOut = require('https').request(opts, (resOut) => {
|
||
let data = '';
|
||
resOut.on('data', chunk => data += chunk);
|
||
resOut.on('end', () => {
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
|
||
if (text != null) {
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ response: String(text).trim() }));
|
||
} else {
|
||
res.writeHead(502);
|
||
res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
|
||
}
|
||
} catch (e) {
|
||
res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
|
||
}
|
||
});
|
||
});
|
||
reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
|
||
reqOut.write(payload);
|
||
reqOut.end();
|
||
} catch (e) {
|
||
res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (url.startsWith(BASE_PATH + '/img/characters/') && (req.method === 'GET' || req.method === 'HEAD')) {
|
||
const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
|
||
const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
|
||
if (fname && fname.length > 0) {
|
||
const fp = path.join(CHARACTERS_DIR, fname);
|
||
if (fs.existsSync(fp)) {
|
||
return fs.readFile(fp, (err, data) => {
|
||
if (err) return res.writeHead(500), res.end();
|
||
res.writeHead(200, { 'Content-Type': 'image/png' });
|
||
if (req.method === 'HEAD') return res.end();
|
||
res.end(data);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
|
||
const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
|
||
// production: nginx เสิร์ฟ /Game/img/* เป็น static ทั้งหมด — local node จึงต้องเสิร์ฟไฟล์ที่มีอยู่จริงในโฟลเดอร์ด้วย
|
||
// (ไม่จำกัดเฉพาะไฟล์อัปโหลด gauntlet-<hash>) ไม่งั้น asset ดีไซน์ (popup-Howto, Avartar, btn-ready…) จะ 400 เฉพาะตอนรันผ่าน node ตรง ๆ
|
||
const fname = safeGauntletStoredFilename(sub) || path.basename(sub); // basename กัน path traversal
|
||
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
|
||
if (!fname || !fs.existsSync(fp)) {
|
||
res.writeHead(404);
|
||
return res.end();
|
||
}
|
||
const ext = path.extname(fname);
|
||
return fs.readFile(fp, (err, data) => {
|
||
if (err) return res.writeHead(500), res.end();
|
||
res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
|
||
res.end(data);
|
||
});
|
||
}
|
||
if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
|
||
// ตัด query string / hash ออกก่อน resolve เป็น path ไฟล์ (เช่น /play.html?space=...&map=... → /play.html)
|
||
// ไม่ตัด จะหาไฟล์ชื่อ "play.html?space=..." ไม่เจอ → 404 (เข้า node ตรง ๆ พร้อม query)
|
||
url = url.split('?')[0].split('#')[0] || '/index.html';
|
||
const filePath = path.join(__dirname, 'public', url);
|
||
const ext = path.extname(filePath);
|
||
const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
|
||
fs.readFile(filePath, (err, data) => {
|
||
if (err) return res.writeHead(404), res.end('Not Found');
|
||
res.writeHead(200, {
|
||
'Content-Type': types[ext] || 'application/octet-stream',
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0'
|
||
});
|
||
res.end(data);
|
||
});
|
||
});
|
||
|
||
const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
|
||
const spaces = new Map();
|
||
|
||
const GAUNTLET_MAX_PLAYERS = 6;
|
||
/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */
|
||
/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */
|
||
const GAUNTLET_LASER_SPAWN_CHANCE = 0.14;
|
||
/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */
|
||
const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34;
|
||
/** Last Light / museum gauntlet — scoring, mission UI, how-to flow */
|
||
const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07';
|
||
/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */
|
||
const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7';
|
||
/** Space Shooter ภารกิจ Violent Crime — lobby READY / START เดียวกับ Jumper */
|
||
const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp';
|
||
|
||
function isGauntletCrownHeistMapBySpace(space) {
|
||
return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID);
|
||
}
|
||
|
||
function isBalloonBossMissionShellSpace(space) {
|
||
if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
return !!(md && md.gameType === 'balloon_boss');
|
||
}
|
||
|
||
/** ภารกิจคำถาม quiz + แมป mng8a80o — lobby READY เดียวกับ Mega Virus */
|
||
function isQuizQuestionMissionShellSpace(space) {
|
||
if (!space || !space.mapId || String(space.mapId) !== SUSPECT_INVESTIGATION_QUIZ_MAP_ID) return false;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
return !!(md && md.gameType === 'quiz');
|
||
}
|
||
|
||
function isStackTowerMissionShellSpace(space) {
|
||
if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
return !!(md && md.gameType === 'stack');
|
||
}
|
||
|
||
function isJumpSurviveMissionShellSpace(space) {
|
||
if (!space || !space.mapId || String(space.mapId) !== JUMP_SURVIVE_MISSION_MAP_ID) return false;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
return !!(md && md.gameType === 'jump_survive');
|
||
}
|
||
|
||
function isSpaceShooterMissionShellSpace(space) {
|
||
if (!space || !space.mapId || String(space.mapId) !== SPACE_SHOOTER_MISSION_MAP_ID) return false;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
return !!(md && md.gameType === 'space_shooter');
|
||
}
|
||
|
||
function isCrownLobbyShellSpace(space) {
|
||
return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space);
|
||
}
|
||
|
||
function newBalloonBossShellGauntletRunState() {
|
||
return {
|
||
obstacles: [],
|
||
nextObsId: 1,
|
||
spawnAcc: 0,
|
||
nextSpawnIn: 999,
|
||
crownRunHeld: true,
|
||
timerId: null,
|
||
endsAt: null,
|
||
};
|
||
}
|
||
|
||
function emitBalloonBossMissionShellGauntletSync(sid, space) {
|
||
if (!isBalloonBossMissionShellSpace(space)) return;
|
||
const gr = space.gauntletRun;
|
||
io.to(sid).emit('gauntlet-sync', {
|
||
gauntletCrownRunHeld: !!(gr && gr.crownRunHeld),
|
||
});
|
||
}
|
||
|
||
function rollGauntletCrownRewardCard(grade) {
|
||
const r = Math.random();
|
||
if (grade === 'A') {
|
||
if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
|
||
if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
|
||
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
|
||
}
|
||
if (grade === 'B') {
|
||
if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
|
||
if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
|
||
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
|
||
}
|
||
if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
|
||
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
|
||
}
|
||
|
||
function gauntletCrownRankBonus(rankOrdinal) {
|
||
if (rankOrdinal === 1) return 100;
|
||
if (rankOrdinal === 2) return 80;
|
||
if (rankOrdinal === 3) return 60;
|
||
return 0;
|
||
}
|
||
|
||
function buildGauntletCrownMissionPayload(space) {
|
||
const peersArr = [...space.peers.values()];
|
||
const rows = peersArr.map((p) => ({
|
||
id: p.id,
|
||
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
|
||
characterId: p.characterId ?? null,
|
||
baseScore: p.gauntletEliminated ? 0 : Math.max(0, p.gauntletScore | 0),
|
||
eliminated: !!p.gauntletEliminated,
|
||
}));
|
||
rows.sort((a, b) => {
|
||
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
|
||
return String(a.nickname).localeCompare(String(b.nickname), 'th');
|
||
});
|
||
let lastScore = null;
|
||
let rankOrdinal = 0;
|
||
const ranked = [];
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const pos = i + 1;
|
||
if (lastScore !== rows[i].baseScore) {
|
||
rankOrdinal = pos;
|
||
lastScore = rows[i].baseScore;
|
||
}
|
||
const bonus = gauntletCrownRankBonus(rankOrdinal);
|
||
const finalScore = rows[i].baseScore + bonus;
|
||
const rankLabel = rankOrdinal === 1 ? '1st' : rankOrdinal === 2 ? '2nd' : rankOrdinal === 3 ? '3rd' : String(rankOrdinal);
|
||
ranked.push({
|
||
id: rows[i].id,
|
||
nickname: rows[i].nickname,
|
||
characterId: rows[i].characterId,
|
||
baseScore: rows[i].baseScore,
|
||
eliminated: rows[i].eliminated,
|
||
rank: rankOrdinal,
|
||
rankLabel,
|
||
rankBonus: bonus,
|
||
finalScore,
|
||
});
|
||
}
|
||
const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0);
|
||
const n = ranked.length || 1;
|
||
const averageScore = Math.floor(totalSum / n);
|
||
const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
|
||
const rewardCard = rollGauntletCrownRewardCard(grade);
|
||
return {
|
||
ranked,
|
||
totalSum,
|
||
averageScore,
|
||
grade,
|
||
rewardCard,
|
||
totalParts: ranked.map((r) => r.finalScore),
|
||
};
|
||
}
|
||
|
||
function gauntletSpawnYs(md, playerCount) {
|
||
const h = md.height || 15;
|
||
const lo = 1;
|
||
const hi = Math.max(lo, h - 2);
|
||
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
|
||
if (hi <= lo) return Array.from({ length: n }, () => lo);
|
||
const ys = [];
|
||
for (let i = 0; i < n; i++) {
|
||
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
|
||
}
|
||
return ys;
|
||
}
|
||
|
||
/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */
|
||
function collectGauntletSpawnSlotsFromSpawnArea(md) {
|
||
const grid = md.spawnArea;
|
||
if (!grid || !Array.isArray(grid)) return [];
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const slots = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = grid[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y });
|
||
}
|
||
}
|
||
slots.sort((a, b) => a.y - b.y || a.x - b.x);
|
||
return slots;
|
||
}
|
||
|
||
/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */
|
||
function collectGauntletSpawnSlotsFromMap(md) {
|
||
const explicit = md.gauntletPlayerSpawns;
|
||
if (Array.isArray(explicit) && explicit.length > 0) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const slots = [];
|
||
for (const raw of explicit) {
|
||
if (slots.length >= GAUNTLET_MAX_PLAYERS) break;
|
||
const x = Math.floor(Number(raw && raw.x));
|
||
const y = Math.floor(Number(raw && raw.y));
|
||
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
|
||
if (!isMapTileWalkableForSpawn(md, x, y)) continue;
|
||
slots.push({ x, y });
|
||
}
|
||
if (slots.length > 0) return slots;
|
||
}
|
||
return collectGauntletSpawnSlotsFromSpawnArea(md);
|
||
}
|
||
|
||
function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) {
|
||
const w = md.width || 20;
|
||
if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX;
|
||
if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback;
|
||
for (let d = 0; d < w; d++) {
|
||
if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d;
|
||
if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d;
|
||
}
|
||
return Math.max(0, Math.min(w - 1, spawnColX));
|
||
}
|
||
|
||
/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */
|
||
function gauntletSpawnPositions(md, playerCount) {
|
||
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
|
||
const slots = collectGauntletSpawnSlotsFromMap(md);
|
||
const fallbackYs = gauntletSpawnYs(md, n);
|
||
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const y = i < slots.length
|
||
? slots[i].y
|
||
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
|
||
const slotX = i < slots.length ? slots[i].x : null;
|
||
const x = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX);
|
||
out.push({ x, y });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stopGauntletTicker(space) {
|
||
if (space.gauntletRun && space.gauntletRun.timerId) {
|
||
clearInterval(space.gauntletRun.timerId);
|
||
space.gauntletRun.timerId = null;
|
||
}
|
||
}
|
||
|
||
/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */
|
||
function ensureGauntletEndsAtIfNeeded(space) {
|
||
const gr = space.gauntletRun;
|
||
if (!gr) return;
|
||
if (gr.crownRunHeld) return;
|
||
const lim = getGauntletTimeLimitSec();
|
||
if (lim > 0 && gr.endsAt == null) {
|
||
gr.endsAt = Date.now() + lim * 1000;
|
||
}
|
||
}
|
||
|
||
function newGauntletRunState(space) {
|
||
const crown = !!(space && isGauntletCrownHeistMapBySpace(space));
|
||
return {
|
||
obstacles: [],
|
||
nextObsId: 1,
|
||
spawnAcc: 0,
|
||
nextSpawnIn: 3,
|
||
crownRunHeld: crown,
|
||
finishPhase: false,
|
||
};
|
||
}
|
||
|
||
function emitGauntletSync(sid, space) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
const gr = space.gauntletRun;
|
||
if (!gr) return;
|
||
const h = md.height || 15;
|
||
const obsOut = gr.obstacles.map((o) => {
|
||
if (o.kind === 'laser') {
|
||
const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.floor(Number(o.y0)) : 0;
|
||
const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.floor(Number(o.y1)) : h - 1;
|
||
return { id: o.id, kind: 'laser', x: o.x, y: null, y0, y1 };
|
||
}
|
||
return { id: o.id, kind: o.kind, x: o.x, y: o.y };
|
||
});
|
||
const playersOut = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
characterId: p.characterId ?? null,
|
||
gauntletJumpTicks: p.gauntletJumpTicks || 0,
|
||
gauntletScore: p.gauntletScore || 0,
|
||
gauntletEliminated: !!p.gauntletEliminated,
|
||
}));
|
||
io.to(sid).emit('gauntlet-sync', {
|
||
obstacles: obsOut,
|
||
players: playersOut,
|
||
gauntletTickMs: getGauntletTickMs(),
|
||
gauntletJumpTicks: getGauntletJumpTicks(),
|
||
gauntletTimeLimitSec: getGauntletTimeLimitSec(),
|
||
gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null,
|
||
gauntletCrownRunHeld: !!gr.crownRunHeld,
|
||
...getGauntletVisualsForClient(),
|
||
});
|
||
}
|
||
|
||
function startGauntletTicker(sid, space) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if (space.gauntletRun && space.gauntletRun.timerId) return;
|
||
if (!space.gauntletRun) {
|
||
space.gauntletRun = newGauntletRunState(space);
|
||
}
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
space.gauntletRun.timerId = setInterval(() => {
|
||
runGauntletTick(sid, space);
|
||
}, getGauntletTickMs());
|
||
}
|
||
|
||
function initGauntletPeersAll(space, md) {
|
||
const list = [...space.peers.values()];
|
||
const pos = gauntletSpawnPositions(md, list.length);
|
||
const crown = isGauntletCrownHeistMapBySpace(space);
|
||
list.forEach((p, i) => {
|
||
const ord = typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : i;
|
||
const pt = pos[ord] || pos[i] || pos[pos.length - 1];
|
||
p.x = pt.x;
|
||
p.y = pt.y;
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = crown ? 100 : 0;
|
||
p.gauntletJumpPending = false;
|
||
p.gauntletEliminated = false;
|
||
});
|
||
space.gauntletRun = newGauntletRunState(space);
|
||
if (crown) {
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
|
||
space.peers.forEach((_p, id) => {
|
||
space.gauntletCrownLobbyReady[id] = false;
|
||
});
|
||
}
|
||
space.gauntletPreviewRowsBySocket = new Map();
|
||
}
|
||
|
||
function findFallbackLobbyMapForGauntletEnd() {
|
||
for (const [id, m] of maps) {
|
||
if (m && m.gameType === 'lobby') return { id, m };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */
|
||
function endGauntletGame(sid, space, reason) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if (space.detectiveMinigameActive) {
|
||
const msg = reason === 'time' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม';
|
||
returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true });
|
||
return;
|
||
}
|
||
stopGauntletTicker(space);
|
||
const wasCrown = isGauntletCrownHeistMapBySpace(space);
|
||
const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null;
|
||
const rankings = crownMission
|
||
? crownMission.ranked.map((r) => ({
|
||
id: r.id,
|
||
nickname: r.nickname,
|
||
score: r.finalScore,
|
||
}))
|
||
: [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
|
||
score: Math.max(0, p.gauntletScore | 0),
|
||
})).sort((a, b) => b.score - a.score);
|
||
|
||
let retId = space.gauntletReturnMapId;
|
||
let retMap = retId && maps.has(retId) ? maps.get(retId) : null;
|
||
if (!retMap || retMap.gameType === 'gauntlet') {
|
||
const fb = findFallbackLobbyMapForGauntletEnd();
|
||
if (fb) {
|
||
retId = fb.id;
|
||
retMap = fb.m;
|
||
}
|
||
}
|
||
if (retMap && retMap.gameType !== 'gauntlet') {
|
||
space.mapId = retId;
|
||
space.mapData = retMap;
|
||
let gi = 0;
|
||
space.peers.forEach((p) => {
|
||
const sp = pickSpawnForJoin(retMap, gi++);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.gauntletJumpTicks = 0;
|
||
p.gauntletScore = 0;
|
||
p.gauntletJumpPending = false;
|
||
p.gauntletEliminated = false;
|
||
});
|
||
} else {
|
||
console.warn('endGauntletGame: no lobby map to return to', sid);
|
||
}
|
||
space.gauntletReturnMapId = null;
|
||
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear();
|
||
space.gauntletRun = null;
|
||
const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว';
|
||
io.to(sid).emit('gauntlet-ended', {
|
||
reason: reason || 'time',
|
||
message: msg,
|
||
rankings,
|
||
crownMission: crownMission || undefined,
|
||
});
|
||
}
|
||
|
||
/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */
|
||
function gauntletLaserVerticalSpanFromMap(md) {
|
||
const hRaw = md && md.height != null ? Number(md.height) : 15;
|
||
const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15));
|
||
const rs = md && md.gauntletLaserRowStart;
|
||
const re = md && md.gauntletLaserRowEnd;
|
||
if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) {
|
||
return { y0: 0, y1: h - 1 };
|
||
}
|
||
let y0 = Math.floor(Number(rs));
|
||
let y1 = Math.floor(Number(re));
|
||
y0 = Math.max(0, Math.min(h - 1, y0));
|
||
y1 = Math.max(0, Math.min(h - 1, y1));
|
||
if (y1 < y0) {
|
||
const t = y0;
|
||
y0 = y1;
|
||
y1 = t;
|
||
}
|
||
return { y0, y1 };
|
||
}
|
||
|
||
/**
|
||
* แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y)
|
||
* — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม)
|
||
*/
|
||
function gauntletLaneGridRowFromPlayerTop(md, topY, h) {
|
||
const { ch } = getCharacterFootprintWHForMove(md);
|
||
const top = Math.floor(Number(topY));
|
||
if (!Number.isFinite(top) || top < 0 || top >= h) return null;
|
||
const lr = top + ch - 1;
|
||
if (lr < 0 || lr >= h) return null;
|
||
return lr;
|
||
}
|
||
|
||
function runGauntletTick(sid, space) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') {
|
||
stopGauntletTicker(space);
|
||
return;
|
||
}
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const gr = space.gauntletRun;
|
||
if (!gr) return;
|
||
/* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */
|
||
if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) {
|
||
if (gr.endsAt != null) gr.endsAt += getGauntletTickMs();
|
||
emitGauntletSync(sid, space);
|
||
return;
|
||
}
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
|
||
endGauntletGame(sid, space, 'time');
|
||
return;
|
||
}
|
||
|
||
/* ถึงเส้นชัยแล้ว (client แจ้ง) — จบรอบทันที (ผู้ใช้ต้องการ: ไม่ให้เดินเลยเส้นชัย) → สรุปผล */
|
||
if (gr.finishPhase) {
|
||
if (gr.obstacles.length) { gr.obstacles = []; }
|
||
endGauntletGame(sid, space, 'finish');
|
||
return;
|
||
}
|
||
|
||
if (gr.crownRunHeld) {
|
||
space.peers.forEach((p) => {
|
||
p.gauntletJumpPending = false;
|
||
});
|
||
emitGauntletSync(sid, space);
|
||
return;
|
||
}
|
||
|
||
/* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */
|
||
space.peers.forEach((p) => {
|
||
if (p.gauntletJumpPending) {
|
||
if ((p.gauntletJumpTicks || 0) === 0) {
|
||
p.gauntletJumpTicks = getGauntletJumpTicks();
|
||
}
|
||
p.gauntletJumpPending = false;
|
||
}
|
||
});
|
||
|
||
for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1;
|
||
gr.obstacles = gr.obstacles.filter((o) => o.x >= 0);
|
||
|
||
const laneSpawnRows = new Set();
|
||
space.peers.forEach((peer) => {
|
||
const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h);
|
||
if (lr != null) laneSpawnRows.add(lr);
|
||
});
|
||
/* แถวบนที่ client แจ้ง (บอท preview) — แปลงเป็นแถว lane เดียวกับ peer */
|
||
const previewRows = space.gauntletPreviewRowsBySocket;
|
||
if (previewRows && previewRows.size) {
|
||
previewRows.forEach((set) => {
|
||
set.forEach((y) => {
|
||
const lr = gauntletLaneGridRowFromPlayerTop(md, y, h);
|
||
if (lr != null) laneSpawnRows.add(lr);
|
||
});
|
||
});
|
||
}
|
||
/* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */
|
||
gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y));
|
||
|
||
gr.spawnAcc = (gr.spawnAcc || 0) + 1;
|
||
if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) {
|
||
gr.spawnAcc = 0;
|
||
gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6);
|
||
/* ประเภท 2: เลเซอร์ — ทุกแถว */
|
||
let _gauntletLaserThisTick = false;
|
||
if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) {
|
||
const span = gauntletLaserVerticalSpanFromMap(md);
|
||
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 });
|
||
_gauntletLaserThisTick = true;
|
||
}
|
||
/* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */
|
||
if (!_gauntletLaserThisTick) {
|
||
laneSpawnRows.forEach((ly) => {
|
||
if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) {
|
||
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */
|
||
const crownHeist = isGauntletCrownHeistMapBySpace(space);
|
||
const { ch: gauntletCh } = getCharacterFootprintWHForMove(md);
|
||
space.peers.forEach((p) => {
|
||
if (crownHeist && p.gauntletEliminated) {
|
||
return;
|
||
}
|
||
let px = Math.floor(Number(p.x)) || 0;
|
||
let py = Math.floor(Number(p.y)) || 0;
|
||
px = Math.max(0, Math.min(w - 1, px));
|
||
py = Math.max(0, Math.min(h - 1, py));
|
||
const air = (p.gauntletJumpTicks || 0) > 0;
|
||
let advanceX = false;
|
||
let hitBack = false;
|
||
for (const o of gr.obstacles) {
|
||
if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) {
|
||
if (air) advanceX = true;
|
||
else hitBack = true;
|
||
}
|
||
if (o.kind === 'laser' && o.x === px) {
|
||
const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0;
|
||
const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1;
|
||
const lo = Math.min(y0, y1);
|
||
const hi = Math.max(y0, y1);
|
||
if (py < lo || py > hi) {
|
||
/* เลเซอร์ไม่ครอบแถวนี้ */
|
||
} else if (air) {
|
||
advanceX = true;
|
||
} else {
|
||
hitBack = true;
|
||
}
|
||
}
|
||
}
|
||
if (advanceX) {
|
||
px = Math.min(w - 2, px + 1);
|
||
p.gauntletJumpTicks = 0;
|
||
if (!crownHeist) {
|
||
p.gauntletScore = (p.gauntletScore || 0) + 1;
|
||
}
|
||
} else if (hitBack) {
|
||
if (crownHeist) {
|
||
if (px <= 0) {
|
||
p.gauntletEliminated = true;
|
||
p.gauntletScore = 0;
|
||
} else {
|
||
p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10);
|
||
px = Math.max(0, px - 1);
|
||
}
|
||
} else {
|
||
px = Math.max(0, px - 1);
|
||
}
|
||
}
|
||
if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--;
|
||
p.x = px;
|
||
p.y = py;
|
||
});
|
||
|
||
emitGauntletSync(sid, space);
|
||
}
|
||
|
||
function rescheduleAllGauntletTickers() {
|
||
for (const [sid, space] of spaces) {
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') continue;
|
||
if (space.gauntletRun && space.gauntletRun.timerId) {
|
||
clearInterval(space.gauntletRun.timerId);
|
||
space.gauntletRun.timerId = null;
|
||
}
|
||
if (space.peers.size === 0) continue;
|
||
if (!space.gauntletRun) {
|
||
space.gauntletRun = newGauntletRunState(space);
|
||
}
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
space.gauntletRun.timerId = setInterval(() => {
|
||
runGauntletTick(sid, space);
|
||
}, getGauntletTickMs());
|
||
}
|
||
}
|
||
|
||
function getLobbyLayoutMapForSpace(space) {
|
||
return (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
}
|
||
|
||
/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */
|
||
function serverMapIsPostCaseLobbyB(space) {
|
||
if (!space) return false;
|
||
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
|
||
const md = getLobbyLayoutMapForSpace(space);
|
||
if (!md || md.gameType !== 'lobby') return false;
|
||
const nm = String(md.name || '').trim().toLowerCase();
|
||
return nm === 'lobbyb';
|
||
}
|
||
|
||
/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */
|
||
function normalizeLobbyNickname(nickname) {
|
||
const raw = String(nickname || '').trim();
|
||
if (!raw) return 'ผู้เล่น';
|
||
try {
|
||
return raw.normalize('NFKC').toLowerCase();
|
||
} catch (e) {
|
||
return raw.toLowerCase();
|
||
}
|
||
}
|
||
|
||
/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */
|
||
function lobbySpaceAlreadyLobbyB(space, md) {
|
||
if (!md || md.gameType !== 'lobby') return false;
|
||
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
|
||
if (String(md.name || '').trim() === 'LobbyB') return true;
|
||
return false;
|
||
}
|
||
|
||
function lobbyMapHasStartGameArea(md) {
|
||
if (!md) return false;
|
||
const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya';
|
||
if (md.gameType !== 'lobby' && !namedLobbyA) return false;
|
||
const area = md.startGameArea;
|
||
if (!area || !Array.isArray(area)) return false;
|
||
for (let y = 0; y < area.length; y++) {
|
||
const row = area[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */
|
||
function lobbyHostStandingInStartArea(space, hostId) {
|
||
const md = getLobbyLayoutMapForSpace(space);
|
||
if (!lobbyMapHasStartGameArea(md)) return true;
|
||
const p = space.peers.get(hostId);
|
||
if (!p || p.x == null || p.y == null) return false;
|
||
const tx = Math.floor(p.x);
|
||
const ty = Math.floor(p.y);
|
||
const row = md.startGameArea[ty];
|
||
return !!(row && row[tx] === 1);
|
||
}
|
||
|
||
function isMapTileWalkableForSpawn(md, x, y) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
if (x < 0 || x >= w || y < 0 || y >= h) return false;
|
||
const row = md.objects && md.objects[y];
|
||
if (row && row[x] === 1) return false;
|
||
return true;
|
||
}
|
||
|
||
function mapCharacterFootprintWH(md) {
|
||
const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1)));
|
||
const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1)));
|
||
return { cw, ch };
|
||
}
|
||
|
||
/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */
|
||
function isMapSpawnAnchorWalkable(md, x, y) {
|
||
if (!isMapTileWalkableForSpawn(md, x, y)) return false;
|
||
const { cw, ch } = mapCharacterFootprintWH(md);
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const maxX = Math.min(w, x + cw);
|
||
const maxY = Math.min(h, y + ch);
|
||
for (let ty = y; ty < maxY; ty++) {
|
||
for (let tx = x; tx < maxX; tx++) {
|
||
if (!isMapTileWalkableForSpawn(md, tx, ty)) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */
|
||
function parseLobbyPlayerSpawnsFromMap(md) {
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const out = [null, null, null, null, null, null];
|
||
const raw = md && md.lobbyPlayerSpawns;
|
||
if (!Array.isArray(raw)) return out;
|
||
for (let i = 0; i < 6 && i < raw.length; i++) {
|
||
const cell = raw[i];
|
||
if (!cell || typeof cell !== 'object') continue;
|
||
const x = Math.floor(Number(cell.x));
|
||
const y = Math.floor(Number(cell.y));
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||
if (x < 0 || x >= w || y < 0 || y >= h) continue;
|
||
out[i] = { x, y };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */
|
||
function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) {
|
||
if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return;
|
||
const g = md.shooterSpawnSlots;
|
||
if (!g) return;
|
||
const w = md.width || 20, h = md.height || 15;
|
||
for (let slot = 1; slot <= 6; slot++) {
|
||
const idx = slot - 1;
|
||
if (slots6[idx]) continue;
|
||
let found = false;
|
||
for (let yy = 0; yy < h && !found; yy++) {
|
||
const row = g[yy];
|
||
if (!row) continue;
|
||
for (let xx = 0; xx < w; xx++) {
|
||
if (row[xx] === slot) {
|
||
slots6[idx] = { x: xx, y: yy };
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก)
|
||
* โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม
|
||
*/
|
||
function pickSpawnForJoin(md, joinOrderIndex) {
|
||
if (!md) return { x: 1, y: 1 };
|
||
if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
|
||
const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex);
|
||
if (qb) return qb;
|
||
}
|
||
const mode = md.lobbySpawnMode;
|
||
const ord = joinOrderIndex | 0;
|
||
if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md);
|
||
const j = Math.min(Math.max(0, ord), 5);
|
||
if (mode === 'fixed' && md.spawn) {
|
||
const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1;
|
||
const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1;
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const x = Math.max(0, Math.min(w - 1, fx));
|
||
const y = Math.max(0, Math.min(h - 1, fy));
|
||
if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y };
|
||
return pickRandomSpawnFromMap(md);
|
||
}
|
||
if (mode === 'slots6') {
|
||
const slots = parseLobbyPlayerSpawnsFromMap(md);
|
||
augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots);
|
||
const pick = slots[j];
|
||
if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
|
||
return pickRandomSpawnFromMap(md);
|
||
}
|
||
return pickRandomSpawnFromMap(md);
|
||
}
|
||
|
||
/* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser)
|
||
ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */
|
||
function resolveSpawnJoinOrder(space, playerKey) {
|
||
if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {};
|
||
const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '';
|
||
const used = new Set();
|
||
space.peers.forEach((p) => {
|
||
if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder);
|
||
});
|
||
/* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */
|
||
if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) {
|
||
const prev = space.spawnOrderByKey[key];
|
||
if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev;
|
||
}
|
||
let ord = 0;
|
||
while (used.has(ord)) ord++;
|
||
if (key) space.spawnOrderByKey[key] = ord;
|
||
return ord;
|
||
}
|
||
|
||
/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */
|
||
function pickRandomSpawnFromMap(md) {
|
||
const fallback = md.spawn || { x: 1, y: 1 };
|
||
const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
|
||
const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
|
||
const grid = md.spawnArea;
|
||
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
const pool = [];
|
||
for (let y = 0; y < h; y++) {
|
||
const row = grid[y];
|
||
if (!row) continue;
|
||
for (let x = 0; x < w; x++) {
|
||
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y });
|
||
}
|
||
}
|
||
if (!pool.length) return { x: fx, y: fy };
|
||
const idx = typeof crypto.randomInt === 'function'
|
||
? crypto.randomInt(0, pool.length)
|
||
: Math.floor(Math.random() * pool.length);
|
||
const pick = pool[idx];
|
||
return { x: pick.x, y: pick.y };
|
||
}
|
||
|
||
/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */
|
||
function quizWrongAnswerRespawnPosition(md, joinOrderIndex) {
|
||
const ord = joinOrderIndex | 0;
|
||
const sp = pickSpawnForJoin(md, ord);
|
||
return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5);
|
||
}
|
||
|
||
function initTroublesomeState(space) {
|
||
if (!space.troublesomeEligible) space.troublesomeEligible = new Set();
|
||
if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false;
|
||
if (space.suspectPickIndex == null) space.suspectPickIndex = 0;
|
||
if (space.suspectPhaseActive == null) space.suspectPhaseActive = false;
|
||
}
|
||
|
||
/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน ≤ 4) */
|
||
function openSuspectPhaseForRoom(sid, space, disruptorAccepted) {
|
||
if (!space) return;
|
||
let idx = Math.floor(Number(space.suspectPickIndex));
|
||
if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0;
|
||
space.suspectPickIndex = idx;
|
||
if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) {
|
||
assignDetectiveSuspectCardMinigames(space);
|
||
}
|
||
space.suspectPhaseActive = true;
|
||
const phaseBase = {
|
||
hostId: space.hostId,
|
||
selectedIndex: space.suspectPickIndex,
|
||
disruptorAccepted: !!disruptorAccepted,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
};
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
sock.emit('suspect-phase-open', {
|
||
...phaseBase,
|
||
myPlayerEvidence: clonePlayerEvidence(space, pid),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
|
||
});
|
||
});
|
||
}
|
||
|
||
function attemptTroublesomeRoll(sid, force) {
|
||
const space = spaces.get(sid);
|
||
if (!space || space.troublesomeOfferSent) return;
|
||
const peerIds = [...space.peers.keys()];
|
||
if (peerIds.length === 0) return;
|
||
if (!force && space.troublesomeEligible.size < peerIds.length) return;
|
||
/* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง > 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */
|
||
const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer);
|
||
if (peerIds.length <= 4 && !forceOffer) {
|
||
/* ไม่เสนอบทตัวป่วน (คน ≤ 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */
|
||
space.troublesomeOfferSent = true;
|
||
space.troublesomeResponseReceived = true;
|
||
space.troublesomeAccept = false;
|
||
space.disruptorPlayerKey = '';
|
||
space.disruptorNickname = '';
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
|
||
space.troublesomeDebTimer = null;
|
||
space.troublesomeMaxTimer = null;
|
||
openSuspectPhaseForRoom(sid, space, false);
|
||
return;
|
||
}
|
||
space.troublesomeOfferSent = true;
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
|
||
space.troublesomeDebTimer = null;
|
||
space.troublesomeMaxTimer = null;
|
||
let pool = peerIds.filter((id) => space.troublesomeEligible.has(id));
|
||
if (pool.length === 0) pool = peerIds;
|
||
const chosen = pool[Math.floor(Math.random() * pool.length)];
|
||
space.troublesomeTargetId = chosen;
|
||
space.troublesomeResponseReceived = false;
|
||
io.to(chosen).emit('troublesome-offer', { seconds: 15 });
|
||
}
|
||
|
||
function scheduleTroublesomeRoll(sid) {
|
||
const space = spaces.get(sid);
|
||
if (!space || space.troublesomeOfferSent) return;
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450);
|
||
if (!space.troublesomeMaxTimer) {
|
||
space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ห้องทดสอบเอดิเตอร์ / join ด้วย mapId: mapData บน space อาจยังเป็น lobby แต่ไคลเอนต์เล่น quiz_carry จากพารามิเตอร์
|
||
* — ให้ lobby Ready/START ทำงานถ้า mapId ชี้แมป quiz_carry หรือเป็นห้อง preview
|
||
*/
|
||
function spaceAllowsQuizCarryLobbyRelaxed(space) {
|
||
if (!space) return false;
|
||
if (space.mapId) {
|
||
const byId = maps.get(space.mapId);
|
||
if (byId && byId.gameType === 'quiz_carry') return true;
|
||
}
|
||
const md = space.mapData;
|
||
if (md && md.gameType === 'quiz_carry') return true;
|
||
return !!(space.previewEditorTest
|
||
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
|
||
}
|
||
|
||
/* ============================================================
|
||
* Quiz Battle (Kahoot/ZEP-style) — ห้องตอบคำถามพร้อมกัน รับได้สูงสุด 50 คน
|
||
* 10 ห้อง · auto-start (กด Start แล้วนับถอยหลังเริ่มเอง) · คำถามจาก battleQuizMcq
|
||
* แยกจากระบบ spaces (โลกเดิน) ใช้ io เดียวกัน · event ขึ้นต้น qb-*
|
||
* ============================================================ */
|
||
const QB_ROOM_COUNT = 10;
|
||
const QB_MAX_PLAYERS = 50;
|
||
const QB_QUESTIONS_PER_ROUND = 10; // จำนวนข้อต่อรอบ (สุ่มจาก pool battleQuizMcq)
|
||
const QB_ANSWER_MS = 20000; // เวลาตอบต่อข้อ
|
||
const QB_REVEAL_MS = 4000; // โชว์เฉลย + leaderboard ก่อนข้อต่อไป
|
||
const QB_COUNTDOWN_MS = 5000; // นับถอยหลังก่อนเริ่ม (auto-start)
|
||
const QB_BASE_POINTS = 1000; // คะแนนเต็มต่อข้อ (ตอบถูก + เร็วสุด)
|
||
const QB_STREAK_BONUS = 100; // โบนัสต่อ streak ตอบถูกติดกัน
|
||
const QB_MIN_PLAYERS_TO_START = 1; // ขั้นต่ำที่กด Start ได้
|
||
const QB_END_LINGER_MS = 12000; // โชว์อันดับจบเกมก่อนรีเซ็ตห้องกลับ lobby
|
||
const QB_LOBBY_WATCH_ROOM = 'qb-lobby-watchers';
|
||
|
||
const qbRooms = new Map(); // roomId -> room state
|
||
|
||
function qbRoomId(n) { return 'qb-' + n; }
|
||
|
||
function qbCreateRoom(n) {
|
||
return {
|
||
n,
|
||
id: qbRoomId(n),
|
||
status: 'lobby', // lobby | countdown | question | reveal | ended
|
||
players: new Map(), // socketId -> player
|
||
questions: [],
|
||
qIndex: -1,
|
||
qStartAt: 0,
|
||
qEndsAt: 0,
|
||
timers: [],
|
||
};
|
||
}
|
||
|
||
function qbGetRoom(n) {
|
||
const id = qbRoomId(n);
|
||
let r = qbRooms.get(id);
|
||
if (!r) { r = qbCreateRoom(n); qbRooms.set(id, r); }
|
||
return r;
|
||
}
|
||
|
||
function qbClearTimers(room) {
|
||
if (Array.isArray(room.timers)) room.timers.forEach((t) => clearTimeout(t));
|
||
room.timers = [];
|
||
}
|
||
|
||
function qbCountsSnapshot() {
|
||
const rooms = [];
|
||
for (let n = 1; n <= QB_ROOM_COUNT; n++) {
|
||
const r = qbRooms.get(qbRoomId(n));
|
||
rooms.push({ n, count: r ? r.players.size : 0, status: r ? r.status : 'lobby' });
|
||
}
|
||
return rooms;
|
||
}
|
||
|
||
function qbBroadcastCounts() {
|
||
const payload = { rooms: qbCountsSnapshot() };
|
||
io.to(QB_LOBBY_WATCH_ROOM).emit('qb-counts', payload);
|
||
for (let n = 1; n <= QB_ROOM_COUNT; n++) {
|
||
const r = qbRooms.get(qbRoomId(n));
|
||
if (r && r.players.size) io.to(r.id).emit('qb-counts', payload);
|
||
}
|
||
}
|
||
|
||
function qbPlayerList(room) {
|
||
return [...room.players.values()]
|
||
.map((p) => ({ id: p.id, name: p.name, score: p.score }))
|
||
.sort((a, b) => b.score - a.score);
|
||
}
|
||
|
||
function qbLeaderboard(room) {
|
||
return qbPlayerList(room).map((p, i) => ({ rank: i + 1, id: p.id, name: p.name, score: p.score }));
|
||
}
|
||
|
||
function qbEmitRoomState(room) {
|
||
io.to(room.id).emit('qb-room-state', {
|
||
n: room.n,
|
||
status: room.status,
|
||
max: QB_MAX_PLAYERS,
|
||
players: qbPlayerList(room),
|
||
});
|
||
}
|
||
|
||
function qbShuffle(arr) {
|
||
const a = arr.slice();
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
/** หยิบคำถามจาก battleQuizMcq (อ่านสด) → สุ่ม → ตัด N ข้อ · คืน [] ถ้าไม่มีคำถาม */
|
||
function qbPickQuestions() {
|
||
let pool = [];
|
||
try { pool = loadQuizSettings().battleQuizMcq || []; } catch (e) { pool = []; }
|
||
pool = pool.filter((q) => q && q.text && Array.isArray(q.choices) && q.choices.length >= 2);
|
||
if (!pool.length) return [];
|
||
return qbShuffle(pool).slice(0, QB_QUESTIONS_PER_ROUND);
|
||
}
|
||
|
||
function qbResetRoom(room) {
|
||
qbClearTimers(room);
|
||
room.status = 'lobby';
|
||
room.questions = [];
|
||
room.qIndex = -1;
|
||
for (const p of room.players.values()) {
|
||
p.score = 0;
|
||
p.streak = 0;
|
||
p.answeredIndex = -1;
|
||
}
|
||
io.to(room.id).emit('qb-reset', {});
|
||
qbEmitRoomState(room);
|
||
qbBroadcastCounts();
|
||
}
|
||
|
||
function qbStartCountdown(room) {
|
||
if (room.status !== 'lobby') return { ok: false, error: 'เกมเริ่มไปแล้ว' };
|
||
if (room.players.size < QB_MIN_PLAYERS_TO_START) return { ok: false, error: 'ผู้เล่นไม่พอ' };
|
||
const questions = qbPickQuestions();
|
||
if (!questions.length) return { ok: false, error: 'ยังไม่มีคำถามในคลัง (เพิ่มที่หน้า Admin)' };
|
||
qbClearTimers(room);
|
||
room.questions = questions;
|
||
room.qIndex = -1;
|
||
room.status = 'countdown';
|
||
for (const p of room.players.values()) { p.score = 0; p.streak = 0; p.answeredIndex = -1; }
|
||
const endsAt = Date.now() + QB_COUNTDOWN_MS;
|
||
io.to(room.id).emit('qb-countdown', { endsAt, ms: QB_COUNTDOWN_MS, totalQuestions: questions.length });
|
||
qbBroadcastCounts();
|
||
room.timers.push(setTimeout(() => qbNextQuestion(room), QB_COUNTDOWN_MS));
|
||
return { ok: true };
|
||
}
|
||
|
||
function qbNextQuestion(room) {
|
||
qbClearTimers(room);
|
||
room.qIndex += 1;
|
||
if (room.qIndex >= room.questions.length) { qbEndGame(room); return; }
|
||
const q = room.questions[room.qIndex];
|
||
room.status = 'question';
|
||
room.qStartAt = Date.now();
|
||
room.qEndsAt = room.qStartAt + QB_ANSWER_MS;
|
||
for (const p of room.players.values()) { p.answeredIndex = -1; p.answeredAt = 0; p.lastDelta = 0; p.lastCorrect = false; }
|
||
io.to(room.id).emit('qb-question', {
|
||
index: room.qIndex,
|
||
total: room.questions.length,
|
||
text: q.text,
|
||
choices: q.choices,
|
||
endsAt: room.qEndsAt,
|
||
durationMs: QB_ANSWER_MS,
|
||
});
|
||
io.to(room.id).emit('qb-answered-count', { answered: 0, total: room.players.size });
|
||
room.timers.push(setTimeout(() => qbReveal(room), QB_ANSWER_MS));
|
||
}
|
||
|
||
function qbAllAnswered(room) {
|
||
for (const p of room.players.values()) { if (p.answeredIndex < 0) return false; }
|
||
return room.players.size > 0;
|
||
}
|
||
|
||
function qbReveal(room) {
|
||
qbClearTimers(room);
|
||
if (room.status !== 'question') return;
|
||
room.status = 'reveal';
|
||
const q = room.questions[room.qIndex];
|
||
const correctIndex = q.correctIndex;
|
||
const results = [...room.players.values()].map((p) => ({
|
||
id: p.id, name: p.name, correct: !!p.lastCorrect, delta: p.lastDelta || 0, score: p.score, choice: p.answeredIndex,
|
||
}));
|
||
const isLast = room.qIndex >= room.questions.length - 1;
|
||
io.to(room.id).emit('qb-reveal', {
|
||
index: room.qIndex,
|
||
correctIndex,
|
||
results,
|
||
leaderboard: qbLeaderboard(room),
|
||
isLast,
|
||
nextInMs: QB_REVEAL_MS,
|
||
});
|
||
room.timers.push(setTimeout(() => qbNextQuestion(room), QB_REVEAL_MS));
|
||
}
|
||
|
||
function qbEndGame(room) {
|
||
qbClearTimers(room);
|
||
room.status = 'ended';
|
||
io.to(room.id).emit('qb-ended', { leaderboard: qbLeaderboard(room), lingerMs: QB_END_LINGER_MS });
|
||
qbBroadcastCounts();
|
||
room.timers.push(setTimeout(() => qbResetRoom(room), QB_END_LINGER_MS));
|
||
}
|
||
|
||
/** ให้คะแนนแบบ Kahoot: ตอบถูก = ฐาน × (0.5 + 0.5×เศษเวลาที่เหลือ) + โบนัส streak */
|
||
function qbScoreAnswer(room, p, choiceIndex) {
|
||
const q = room.questions[room.qIndex];
|
||
const now = Date.now();
|
||
p.answeredIndex = choiceIndex;
|
||
p.answeredAt = now;
|
||
const correct = Number(choiceIndex) === Number(q.correctIndex);
|
||
p.lastCorrect = correct;
|
||
if (correct) {
|
||
const remain = Math.max(0, room.qEndsAt - now);
|
||
const frac = QB_ANSWER_MS > 0 ? remain / QB_ANSWER_MS : 0;
|
||
p.streak = (p.streak || 0) + 1;
|
||
const base = Math.round(QB_BASE_POINTS * (0.5 + 0.5 * frac));
|
||
const bonus = (p.streak - 1) * QB_STREAK_BONUS;
|
||
p.lastDelta = base + bonus;
|
||
p.score += p.lastDelta;
|
||
} else {
|
||
p.streak = 0;
|
||
p.lastDelta = 0;
|
||
}
|
||
return p.lastDelta;
|
||
}
|
||
|
||
function qbLeaveRoom(socket) {
|
||
const roomId = socket.data.qbRoom;
|
||
if (!roomId) return;
|
||
const room = qbRooms.get(roomId);
|
||
socket.data.qbRoom = null;
|
||
socket.leave(roomId);
|
||
if (!room) return;
|
||
if (room.players.delete(socket.id)) {
|
||
if (room.players.size === 0) {
|
||
qbClearTimers(room);
|
||
room.status = 'lobby';
|
||
room.questions = [];
|
||
room.qIndex = -1;
|
||
} else {
|
||
qbEmitRoomState(room);
|
||
if (room.status === 'question' && qbAllAnswered(room)) qbReveal(room);
|
||
}
|
||
qbBroadcastCounts();
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* Quiz Battle (ฉากเดินตอบ แบบ ZEP) — sync คะแนน/อันดับข้ามผู้เล่นแบบ authoritative
|
||
* เก็บบน space: qbwSolved = Map(socketId -> Set(compId ที่ตอบถูก)) · score = จำนวนโดมที่ปลด
|
||
* event: quizbattle-hello (ขอคะแนนปัจจุบัน) · quizbattle-solve {compId} · server ส่งกลับ quizbattle-scores
|
||
* ============================================================ */
|
||
function qbwScoresSnapshot(space) {
|
||
const scores = {};
|
||
if (space && space.qbwSolved) {
|
||
for (const [pid, set] of space.qbwSolved) scores[pid] = set ? set.size : 0;
|
||
}
|
||
if (space && space.peers) {
|
||
for (const id of space.peers.keys()) if (scores[id] == null) scores[id] = 0;
|
||
}
|
||
return scores;
|
||
}
|
||
|
||
function qbwBroadcastScores(sid, space) {
|
||
if (!sid || !space) return;
|
||
io.to(sid).emit('quizbattle-scores', { scores: qbwScoresSnapshot(space) });
|
||
}
|
||
|
||
io.on('connection', (socket) => {
|
||
/* ===== Quiz Battle global leaderboard (คะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */
|
||
socket.on('qb-leaderboard-submit', (d) => {
|
||
if (!d || typeof d !== 'object') return;
|
||
qbLeaderboardSubmit(d.roomId, d.name, d.score, d.characterId);
|
||
});
|
||
socket.on('qb-leaderboard-get', (d, cb) => {
|
||
if (typeof cb !== 'function') return;
|
||
try { cb(qbLeaderboardGet(d && d.roomId, d && d.name)); } catch (e) { cb({ top: [], me: null, total: 0 }); }
|
||
});
|
||
/* ===== Quiz Battle ฉากเดินตอบ (ZEP) — sync คะแนน ===== */
|
||
socket.on('quizbattle-hello', (cb) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (typeof cb === 'function') cb({ ok: !!space, scores: space ? qbwScoresSnapshot(space) : {} });
|
||
});
|
||
|
||
socket.on('quizbattle-solve', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'quiz_battle') return reply({ ok: false });
|
||
const compId = parseInt(data && data.compId, 10);
|
||
if (!(compId > 0)) return reply({ ok: false });
|
||
if (!space.qbwSolved) space.qbwSolved = new Map();
|
||
let set = space.qbwSolved.get(socket.id);
|
||
if (!set) { set = new Set(); space.qbwSolved.set(socket.id, set); }
|
||
set.add(compId);
|
||
qbwBroadcastScores(sid, space);
|
||
reply({ ok: true, solved: set.size });
|
||
});
|
||
|
||
/* ===== Quiz Battle (Kahoot-style 50 คน) ===== */
|
||
socket.on('qb-watch-counts', (cb) => {
|
||
socket.join(QB_LOBBY_WATCH_ROOM);
|
||
if (typeof cb === 'function') cb({ ok: true, rooms: qbCountsSnapshot() });
|
||
});
|
||
|
||
socket.on('qb-join', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const n = parseInt(data && data.room, 10);
|
||
if (!(n >= 1 && n <= QB_ROOM_COUNT)) return reply({ ok: false, error: 'ห้องไม่ถูกต้อง' });
|
||
const room = qbGetRoom(n);
|
||
if (socket.data.qbRoom && socket.data.qbRoom !== room.id) qbLeaveRoom(socket);
|
||
if (!room.players.has(socket.id) && room.players.size >= QB_MAX_PLAYERS) {
|
||
return reply({ ok: false, error: 'ห้องเต็ม (50 คน)' });
|
||
}
|
||
const name = String((data && data.name) || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น';
|
||
const characterId = String((data && data.characterId) || '').trim().slice(0, 64);
|
||
let p = room.players.get(socket.id);
|
||
if (!p) {
|
||
p = { id: socket.id, name, characterId, score: 0, streak: 0, answeredIndex: -1, answeredAt: 0, lastDelta: 0, lastCorrect: false };
|
||
room.players.set(socket.id, p);
|
||
} else {
|
||
p.name = name; p.characterId = characterId;
|
||
}
|
||
socket.data.qbRoom = room.id;
|
||
socket.join(room.id);
|
||
socket.leave(QB_LOBBY_WATCH_ROOM);
|
||
qbEmitRoomState(room);
|
||
qbBroadcastCounts();
|
||
// ส่งสถานะปัจจุบันให้คนที่เพิ่งเข้า (เผื่อเกมกำลังเล่นอยู่)
|
||
const snapshot = { n: room.n, status: room.status, max: QB_MAX_PLAYERS };
|
||
if (room.status === 'question') {
|
||
const q = room.questions[room.qIndex];
|
||
snapshot.question = { index: room.qIndex, total: room.questions.length, text: q.text, choices: q.choices, endsAt: room.qEndsAt, durationMs: QB_ANSWER_MS };
|
||
} else if (room.status === 'countdown') {
|
||
snapshot.totalQuestions = room.questions.length;
|
||
}
|
||
reply({ ok: true, ...snapshot, players: qbPlayerList(room) });
|
||
});
|
||
|
||
socket.on('qb-leave', (cb) => {
|
||
qbLeaveRoom(socket);
|
||
if (typeof cb === 'function') cb({ ok: true });
|
||
});
|
||
|
||
socket.on('qb-start', (cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const roomId = socket.data.qbRoom;
|
||
const room = roomId ? qbRooms.get(roomId) : null;
|
||
if (!room || !room.players.has(socket.id)) return reply({ ok: false, error: 'ยังไม่ได้เข้าห้อง' });
|
||
reply(qbStartCountdown(room));
|
||
});
|
||
|
||
socket.on('qb-answer', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const roomId = socket.data.qbRoom;
|
||
const room = roomId ? qbRooms.get(roomId) : null;
|
||
if (!room || room.status !== 'question') return reply({ ok: false });
|
||
const p = room.players.get(socket.id);
|
||
if (!p || p.answeredIndex >= 0) return reply({ ok: false, already: true });
|
||
const q = room.questions[room.qIndex];
|
||
const ci = parseInt(data && data.choiceIndex, 10);
|
||
if (!(ci >= 0 && ci < q.choices.length)) return reply({ ok: false });
|
||
const delta = qbScoreAnswer(room, p, ci);
|
||
let answered = 0;
|
||
for (const pp of room.players.values()) if (pp.answeredIndex >= 0) answered++;
|
||
io.to(room.id).emit('qb-answered-count', { answered, total: room.players.size });
|
||
reply({ ok: true, accepted: true });
|
||
if (qbAllAnswered(room)) qbReveal(room);
|
||
});
|
||
|
||
socket.on('join-space', ({ spaceId, nickname, characterId, playMapId, playerKey, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex, bannedSpectator, roomPassword }, cb) => {
|
||
const space = spaces.get(spaceId);
|
||
if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' });
|
||
/* ห้องส่วนตัวที่ตั้งรหัส → ต้องกรอกรหัสให้ตรง ครั้งแรก แล้วจำด้วย playerKey
|
||
(กันด่านรหัสพังตอนเปลี่ยนหน้า LobbyA→play→LobbyB ที่ socket.id เปลี่ยน — ไม่ต้องแนบ pass ทุกลิงก์) */
|
||
if (space.isPrivate && space.roomPassword) {
|
||
space.privateVerifiedKeys = space.privateVerifiedKeys || new Set();
|
||
const pk = String(playerKey == null ? '' : playerKey);
|
||
const provided = String(roomPassword == null ? '' : roomPassword).replace(/\D/g, '').slice(0, 4);
|
||
const passOk = provided === space.roomPassword;
|
||
const alreadyOk = (pk && space.privateVerifiedKeys.has(pk)) || space.peers.has(socket.id);
|
||
if (!passOk && !alreadyOk) {
|
||
return cb && cb({ ok: false, error: 'รหัสผ่านห้องไม่ถูกต้อง' });
|
||
}
|
||
if (passOk && pk) space.privateVerifiedKeys.add(pk);
|
||
}
|
||
const maxPlayers = space.maxPlayers || 10;
|
||
const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
|
||
return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
|
||
}
|
||
if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
|
||
return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
|
||
}
|
||
if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
|
||
return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
|
||
}
|
||
if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' });
|
||
const joinNickNorm = normalizeLobbyNickname(nickname);
|
||
if (space.joinLocked) {
|
||
const wl = space.caseParticipantNicknames;
|
||
if (wl && wl.size > 0) {
|
||
if (!wl.has(joinNickNorm)) {
|
||
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
|
||
}
|
||
} else {
|
||
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
|
||
}
|
||
}
|
||
socket.join(spaceId);
|
||
socket.data.spaceId = spaceId;
|
||
/* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser)
|
||
ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */
|
||
const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '';
|
||
const prevHostId = space.hostId;
|
||
if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) {
|
||
space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */
|
||
} else if (!space.hostId) {
|
||
space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */
|
||
if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey;
|
||
}
|
||
/* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers
|
||
จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost)
|
||
แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */
|
||
if (joinPlayerKey) {
|
||
const staleIds = [];
|
||
space.peers.forEach((p, pid) => {
|
||
if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid);
|
||
});
|
||
staleIds.forEach((pid) => {
|
||
space.peers.delete(pid);
|
||
if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid];
|
||
if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid];
|
||
io.to(spaceId).emit('user-left', { id: pid });
|
||
});
|
||
}
|
||
if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space);
|
||
ensurePeerLobbyThemes(space);
|
||
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey);
|
||
const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder);
|
||
const bbStartBalloons = balloonBossBalloonsForMap(mdJoin);
|
||
/* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */
|
||
let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10);
|
||
if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) {
|
||
/* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */
|
||
let takenByHuman = false;
|
||
space.peers.forEach((p) => {
|
||
const ti = parseInt(p.lobbyColorThemeIndex, 10);
|
||
if (ti === chosenThemeIdx) takenByHuman = true;
|
||
});
|
||
if (takenByHuman) {
|
||
chosenThemeIdx = pickFreeLobbyThemeIndex(space);
|
||
} else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') {
|
||
/* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */
|
||
Object.keys(space.lobbyBotThemeBySlot).forEach((k) => {
|
||
const slot = space.lobbyBotThemeBySlot[k];
|
||
if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0;
|
||
});
|
||
}
|
||
} else {
|
||
chosenThemeIdx = pickFreeLobbyThemeIndex(space);
|
||
}
|
||
let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10);
|
||
if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) {
|
||
chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder);
|
||
}
|
||
const peer = {
|
||
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
|
||
spawnJoinOrder,
|
||
lobbyColorThemeIndex: chosenThemeIdx,
|
||
lobbySkinToneIndex: chosenSkinIdx,
|
||
gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0,
|
||
balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false,
|
||
playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '',
|
||
reportedMiniScore: 0, reportedMiniSurvived: true,
|
||
bannedSpectator: !!bannedSpectator,
|
||
};
|
||
if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) {
|
||
const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder);
|
||
peer.x = sn.x;
|
||
peer.y = sn.y;
|
||
}
|
||
space.peers.set(socket.id, peer);
|
||
/* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */
|
||
if (space.detectiveMinigameActive) {
|
||
const clientMap = playMapId != null ? String(playMapId).trim() : '';
|
||
const activeMap = space.mapId != null ? String(space.mapId).trim() : '';
|
||
if (!clientMap || !activeMap || clientMap !== activeMap) {
|
||
returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB');
|
||
}
|
||
}
|
||
if (spaceAllowsQuizCarryLobbyRelaxed(space)) {
|
||
if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {};
|
||
space.quizCarryLobbyReady[socket.id] = false;
|
||
io.to(spaceId).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space));
|
||
}
|
||
if (mdJoin.gameType === 'gauntlet') {
|
||
const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id);
|
||
const pos = gauntletSpawnPositions(mdJoin, space.peers.size);
|
||
const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1];
|
||
peer.x = pt.x;
|
||
peer.y = pt.y;
|
||
peer.gauntletJumpTicks = 0;
|
||
if (isGauntletCrownHeistMapBySpace(space)) {
|
||
peer.gauntletScore = 100;
|
||
peer.gauntletEliminated = false;
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
|
||
space.gauntletCrownLobbyReady[socket.id] = false;
|
||
io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space));
|
||
}
|
||
startGauntletTicker(spaceId, space);
|
||
} else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) {
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
|
||
space.gauntletCrownLobbyReady[socket.id] = false;
|
||
if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState();
|
||
io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space));
|
||
}
|
||
const peersList = [...space.peers.values()];
|
||
const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin;
|
||
const joinCb = {
|
||
ok: true,
|
||
mapData: mapDataOut,
|
||
mapId: space.mapId || null,
|
||
peers: peersList,
|
||
hostId: space.hostId,
|
||
spaceName: space.spaceName || spaceId,
|
||
isPrivate: !!space.isPrivate,
|
||
roomCode: space.isPrivate ? spaceId : null,
|
||
maxPlayers: space.maxPlayers || 10,
|
||
suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space),
|
||
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
|
||
botSlotCount: effectiveBotSlotCount(space),
|
||
lobbyBotThemes: syncLobbyBotThemeSlots(space),
|
||
joinLocked: !!space.joinLocked,
|
||
missionLiveBeginsAt: space.missionLiveBeginsAt || null,
|
||
};
|
||
if (serverMapIsPostCaseLobbyB(space)) {
|
||
joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null;
|
||
joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null;
|
||
}
|
||
if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) {
|
||
try {
|
||
const qs = loadQuizSettings();
|
||
joinCb.quizCarrySettingsSnap = {
|
||
carryMapPanelTheme: qs.carryMapPanelTheme,
|
||
carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme,
|
||
carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes,
|
||
carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme,
|
||
carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale,
|
||
carryReadMs: qs.carryReadMs,
|
||
carryAnswerMs: qs.carryAnswerMs,
|
||
carrySessionLength: qs.carrySessionLength,
|
||
carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId,
|
||
carryWalkSpeedMult: qs.carryWalkSpeedMult,
|
||
};
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
if (mdJoin.gameType === 'quiz') {
|
||
try {
|
||
const qsQuiz = loadQuizSettings();
|
||
joinCb.quizSettingsSnap = {
|
||
quizMapPanelTheme: qsQuiz.quizMapPanelTheme,
|
||
};
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) {
|
||
joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
|
||
}
|
||
if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) {
|
||
joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld;
|
||
}
|
||
/* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */
|
||
const sqIcon = specialQuizIconPayload(space);
|
||
if (sqIcon) joinCb.specialQuizIcon = sqIcon;
|
||
if (typeof cb === 'function') cb(joinCb);
|
||
if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */
|
||
emitLobbyTintSync(spaceId, space);
|
||
socket.to(spaceId).emit('user-joined', peer);
|
||
if (space.voiceInits) {
|
||
for (const [peerId, initData] of Object.entries(space.voiceInits)) {
|
||
if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData });
|
||
}
|
||
}
|
||
/* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที
|
||
(คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */
|
||
if (serverMapIsPostCaseLobbyB(space)) {
|
||
/* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */
|
||
emitDetectiveLobbyBPresence(spaceId, space);
|
||
console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id);
|
||
if (space.pendingBanVoteCard && !space.pickVote) {
|
||
const banCard = space.pendingBanVoteCard;
|
||
space.pendingBanVoteCard = null;
|
||
/* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */
|
||
setTimeout(() => {
|
||
const sp = spaces.get(spaceId);
|
||
if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) {
|
||
console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp)));
|
||
return;
|
||
}
|
||
console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')');
|
||
io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' });
|
||
startPlayerPickVote(spaceId, sp, 'ban', (targetId) => {
|
||
const sp2 = spaces.get(spaceId);
|
||
console.log('[ban-debug] ban vote RESOLVED target=', targetId);
|
||
if (sp2) sp2.bannedPlayerId = targetId || null;
|
||
});
|
||
}, 2000);
|
||
} else if (space.pickVote && !space.pickVote.resolved) {
|
||
const pv = space.pickVote;
|
||
socket.emit('player-pick-vote-open', {
|
||
purpose: pv.purpose,
|
||
candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })),
|
||
endsAt: pv.endsAt,
|
||
durationMs: pickVoteMs(),
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
/** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */
|
||
/** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */
|
||
socket.on('special-quiz-force', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
space.forceSpecialQuiz = !!(data && data.on);
|
||
console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id);
|
||
});
|
||
|
||
/** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
|
||
socket.on('special-quiz-testmode', (data) => {
|
||
socket.data.testMode = !!(data && data.on);
|
||
});
|
||
|
||
/** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */
|
||
socket.on('test-force-wrong-vote', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return;
|
||
space.testForceWrongVote = true;
|
||
console.log('[test] force-wrong-vote armed by', socket.id);
|
||
});
|
||
|
||
/** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */
|
||
socket.on('special-quiz-arm', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const sq = space.specialQuiz;
|
||
if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return;
|
||
sq.armScheduled = true;
|
||
scheduleSpecialQuizIconAppearAndExpiry(sid, space);
|
||
});
|
||
|
||
socket.on('special-quiz-collide', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
|
||
const sq = space.specialQuiz;
|
||
if (!sq || sq.triggered || sq.done) return reply({ ok: false });
|
||
const started = startSpecialQuizSession(sid, space);
|
||
reply({ ok: !!started });
|
||
});
|
||
|
||
/** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */
|
||
socket.on('special-quiz-answer', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
|
||
const sess = space.specialQuiz && space.specialQuiz.session;
|
||
if (!sess || sess.resolving) return reply({ ok: false });
|
||
const q = sess.questions[sess.qIndex];
|
||
if (!q) return reply({ ok: false });
|
||
/* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */
|
||
let choice;
|
||
if (data && data.debugCorrect && socket.data.testMode) {
|
||
choice = q.correctIndex;
|
||
} else {
|
||
choice = Number(data && data.choiceIndex);
|
||
}
|
||
if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) {
|
||
return reply({ ok: false });
|
||
}
|
||
if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' });
|
||
sess.answers[socket.id] = choice;
|
||
reply({ ok: true });
|
||
/* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */
|
||
io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true });
|
||
/* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */
|
||
maybeResolveSpecialQuizAllAnswered(sid, space);
|
||
});
|
||
|
||
/** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */
|
||
socket.on('special-quiz-continue', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
|
||
/* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */
|
||
if (!space.specialQuizAwaitContinue) return reply({ ok: true });
|
||
/* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */
|
||
if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set();
|
||
space.specialQuizContinueAcks.add(socket.id);
|
||
reply({ ok: true });
|
||
maybeFinishSpecialQuizContinue(sid, space);
|
||
});
|
||
|
||
/** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */
|
||
socket.on('lobby-tint-update', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
const peer = space && space.peers.get(socket.id);
|
||
if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
let themeIndex = Math.floor(Number(data && data.themeIndex));
|
||
let skinIndex = Math.floor(Number(data && data.skinToneIndex));
|
||
if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) {
|
||
return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' });
|
||
}
|
||
if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) {
|
||
skinIndex = peer.lobbySkinToneIndex || 1;
|
||
}
|
||
/* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join)
|
||
กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */
|
||
let humanTaken = false;
|
||
space.peers.forEach((op, oid) => {
|
||
if (oid === socket.id) return;
|
||
if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true;
|
||
});
|
||
if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' });
|
||
if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') {
|
||
Object.keys(space.lobbyBotThemeBySlot).forEach((k) => {
|
||
const slot = space.lobbyBotThemeBySlot[k];
|
||
if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */
|
||
});
|
||
}
|
||
peer.lobbyColorThemeIndex = themeIndex;
|
||
peer.lobbySkinToneIndex = skinIndex;
|
||
emitLobbyTintSync(sid, space);
|
||
reply({
|
||
ok: true,
|
||
lobbyColorThemeIndex: themeIndex,
|
||
lobbySkinToneIndex: skinIndex,
|
||
lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [],
|
||
});
|
||
});
|
||
|
||
/* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง
|
||
(กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */
|
||
socket.on('lobby-bot-move', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */
|
||
if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return;
|
||
const bots = [];
|
||
for (const b of data.bots) {
|
||
if (!b || typeof b.id !== 'string' || b.id.length > 40) continue;
|
||
const x = Number(b.x), y = Number(b.y);
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||
bots.push({
|
||
id: b.id, x, y,
|
||
direction: typeof b.direction === 'string' ? b.direction.slice(0, 5) : 'down',
|
||
isWalking: !!b.isWalking,
|
||
});
|
||
}
|
||
if (bots.length) socket.to(sid).emit('lobby-bot-move', { bots });
|
||
});
|
||
|
||
/* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง
|
||
(กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */
|
||
socket.on('fill-bot-state', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */
|
||
if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return;
|
||
/* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host"
|
||
แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */
|
||
const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined);
|
||
socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans });
|
||
});
|
||
|
||
/* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host)
|
||
payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */
|
||
socket.on('balloon-boss-shot', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !data) return;
|
||
const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k]));
|
||
if (!ok) return;
|
||
socket.to(sid).emit('balloon-boss-shot', {
|
||
sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy,
|
||
ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()),
|
||
});
|
||
});
|
||
|
||
/* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */
|
||
socket.on('balloon-boss-over', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !data) return;
|
||
const reason = (data.reason === 'victory' || data.reason === 'all_dead') ? data.reason : 'all_dead';
|
||
const scores = Array.isArray(data.scores)
|
||
? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated }))
|
||
: [];
|
||
socket.to(sid).emit('balloon-boss-over', { reason, scores });
|
||
});
|
||
|
||
/* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ
|
||
→ ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */
|
||
socket.on('balloon-boss-sync', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */
|
||
const bossDmg = Math.max(0, Number(data.bossDmg) || 0);
|
||
const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */
|
||
const players = Array.isArray(data.players)
|
||
? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({
|
||
id: p.id,
|
||
d: Math.max(0, Number(p.d) || 0),
|
||
b: Math.max(0, Number(p.b) || 0),
|
||
e: !!p.e,
|
||
}))
|
||
: [];
|
||
socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor });
|
||
});
|
||
|
||
/* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */
|
||
socket.on('stack-drop', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !data) return;
|
||
const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0;
|
||
socket.to(sid).emit('stack-drop', {
|
||
placedCx: num(data.placedCx), placedW: num(data.placedW),
|
||
miss: !!data.miss, perfect: !!data.perfect,
|
||
pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles),
|
||
overlap: num(data.overlap),
|
||
supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)),
|
||
delta: num(data.delta),
|
||
seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy,
|
||
actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot,
|
||
});
|
||
});
|
||
/* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */
|
||
socket.on('quiz-end-request', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */
|
||
if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'quiz') return;
|
||
endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด');
|
||
});
|
||
|
||
/* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */
|
||
socket.on('stack-swing', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !data) return;
|
||
const ph = Number(data.phase);
|
||
if (!Number.isFinite(ph)) return;
|
||
socket.to(sid).emit('stack-swing', { phase: ph });
|
||
});
|
||
/* MG3 stack — host เป็นเจ้าของลำดับตา+index relay ให้ non-host (กัน host/client มองลำดับตาไม่ตรง) */
|
||
socket.on('stack-state', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */
|
||
const order = Array.isArray(data.order)
|
||
? data.order.filter((e) => e && (e.kind === 'human' || e.kind === 'bot')).slice(0, 8).map((e) => ({
|
||
kind: e.kind === 'bot' ? 'bot' : 'human',
|
||
seat: Math.max(1, Math.min(8, Math.floor(Number(e.seat)) || 1)),
|
||
id: e.id != null ? String(e.id).slice(0, 64) : null,
|
||
botId: e.botId != null ? String(e.botId).slice(0, 64) : null,
|
||
}))
|
||
: [];
|
||
const idx = Math.max(0, Math.floor(Number(data.idx)) || 0);
|
||
socket.to(sid).emit('stack-state', { order, idx });
|
||
});
|
||
|
||
/* MG3 stack — host ตัดสินจบรอบ relay ให้ทุกคนจบพร้อมกัน */
|
||
socket.on('stack-over', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space) return;
|
||
const outcome = (data && typeof data.outcome === 'string') ? data.outcome.slice(0, 32) : 'time_up';
|
||
socket.to(sid).emit('stack-over', { outcome });
|
||
});
|
||
|
||
/* MG6 space_shooter — host ตัดสินจบเกม + แนบคะแนนสุดท้าย → ทุกคนจบ/อันดับตรงกัน */
|
||
socket.on('space-shooter-over', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !data) return;
|
||
const kind = (data.kind === 'all_dead') ? 'all_dead' : 'time_up';
|
||
const scores = Array.isArray(data.scores)
|
||
? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), hits: Math.max(0, Number(s.hits) || 0), eliminated: !!s.eliminated }))
|
||
: [];
|
||
socket.to(sid).emit('space-shooter-over', { kind, scores });
|
||
});
|
||
|
||
/* MG6 space_shooter — relay กระสุนผู้เล่น/บอท ให้ทุกคนเห็นตรงกัน (host เห็นยังไง client เห็นแบบนั้น) */
|
||
socket.on('space-shooter-shot', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !data) return;
|
||
const ok = ['x', 'y', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k]));
|
||
if (!ok) return;
|
||
socket.to(sid).emit('space-shooter-shot', {
|
||
x: data.x, y: data.y, vy: data.vy,
|
||
ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()),
|
||
});
|
||
});
|
||
|
||
/** เปลี่ยนชื่อที่แสดงใน lobby / เกม (ซิงก์ทุก client ในห้อง) */
|
||
socket.on('update-display-name', ({ nickname }, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
const p = space.peers.get(socket.id);
|
||
if (!p) return reply({ ok: false, error: 'ไม่พบผู้เล่น' });
|
||
let safe = String(nickname || '').trim();
|
||
if (!safe) return reply({ ok: false, error: 'ชื่อว่าง' });
|
||
try {
|
||
safe = safe.normalize('NFKC');
|
||
} catch (e) { /* ignore */ }
|
||
if (safe.length > 32) safe = safe.slice(0, 32);
|
||
const oldNorm = normalizeLobbyNickname(p.nickname);
|
||
const newNorm = normalizeLobbyNickname(safe);
|
||
p.nickname = safe;
|
||
if (space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0) {
|
||
if (space.caseParticipantNicknames.has(oldNorm)) {
|
||
space.caseParticipantNicknames.delete(oldNorm);
|
||
space.caseParticipantNicknames.add(newNorm);
|
||
}
|
||
}
|
||
io.to(sid).emit('peer-display-name', { id: socket.id, nickname: safe });
|
||
reply({ ok: true, nickname: safe });
|
||
});
|
||
|
||
/** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */
|
||
socket.on('preview-claim-host', (cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
const isPreviewEditorRoom = !!(space.previewEditorTest
|
||
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
|
||
if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' });
|
||
space.hostId = socket.id;
|
||
const claimPeer = space.peers.get(socket.id);
|
||
if (claimPeer && claimPeer.playerKey) space.hostPlayerKey = claimPeer.playerKey;
|
||
io.to(sid).emit('host-changed', { hostId: socket.id });
|
||
return reply({ ok: true });
|
||
});
|
||
|
||
socket.on('troublesome-eligible', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return;
|
||
initTroublesomeState(space);
|
||
space.troublesomeEligible.add(socket.id);
|
||
scheduleTroublesomeRoll(sid);
|
||
});
|
||
|
||
socket.on('troublesome-response', (data) => {
|
||
const accept = !!(data && data.accept);
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space)) return;
|
||
if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return;
|
||
if (space.troublesomeResponseReceived) return;
|
||
space.troublesomeResponseReceived = true;
|
||
space.troublesomeAccept = accept;
|
||
/* เก็บตัวตนตัวป่วนแบบถาวร (playerKey/nickname) — socket.id เปลี่ยนหลังเล่นมินิเกม ใช้ id ตรง ๆ ไม่ได้ */
|
||
if (accept) {
|
||
const dPeer = space.peers.get(socket.id);
|
||
space.disruptorPlayerKey = (dPeer && dPeer.playerKey) ? dPeer.playerKey : '';
|
||
space.disruptorNickname = dPeer ? normalizeLobbyNickname(dPeer.nickname) : '';
|
||
} else {
|
||
space.disruptorPlayerKey = '';
|
||
space.disruptorNickname = '';
|
||
}
|
||
openSuspectPhaseForRoom(sid, space, accept);
|
||
socket.emit('troublesome-ack', { ok: true, accept });
|
||
});
|
||
|
||
socket.on('suspect-pick-select', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return;
|
||
if (space.hostId !== socket.id) return;
|
||
let idx = Math.floor(Number(data && data.index));
|
||
if (Number.isNaN(idx) || idx < 0 || idx > 2) return;
|
||
space.suspectPickIndex = idx;
|
||
io.to(sid).emit('suspect-pick-update', { selectedIndex: idx });
|
||
});
|
||
|
||
socket.on('suspect-pick-start', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (space && Array.isArray(space.suspectCardMinigames)) {
|
||
space.suspectCardMinigames = space.suspectCardMinigames.map(normalizeDetectiveCardEntry);
|
||
}
|
||
if (!space || !serverMapIsPostCaseLobbyB(space)) {
|
||
return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' });
|
||
}
|
||
if (!space.suspectPhaseActive) {
|
||
return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' });
|
||
}
|
||
if (space.hostId !== socket.id) {
|
||
return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' });
|
||
}
|
||
/* ต้องรอผู้เล่นทุกคนในคดีกลับเข้า LobbyB ให้ครบก่อน host ถึงเริ่มมินิเกมรอบถัดไปได้
|
||
(เดิม: หลังจบเกม client บางคนยังโหลด LobbyB ไม่เสร็จ แต่ host กดเริ่มได้เลย → คนนั้นหลุดรอบ) */
|
||
const waitInfo = detectiveLobbyBMissingParticipants(space);
|
||
if (waitInfo.missing > 0) {
|
||
return reply({
|
||
ok: false,
|
||
waitingPlayers: true,
|
||
present: waitInfo.present,
|
||
total: waitInfo.total,
|
||
error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')',
|
||
});
|
||
}
|
||
if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) {
|
||
assignDetectiveSuspectCardMinigames(space);
|
||
}
|
||
const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0;
|
||
const cardEntry = space.suspectCardMinigames[selectedIndex];
|
||
if (!cardEntry || !cardEntry.mapId) {
|
||
return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' });
|
||
}
|
||
/* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */
|
||
const pend = findPendingSpecialCard(space, 3);
|
||
if (pend && !space.pickVote) {
|
||
pend.consumed = true;
|
||
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' });
|
||
startPlayerPickVote(sid, space, 'ban', (targetId) => {
|
||
const sp = spaces.get(sid);
|
||
if (!sp) return;
|
||
sp.bannedPlayerId = targetId || null;
|
||
const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex);
|
||
if (!res || !res.ok) {
|
||
io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true });
|
||
}
|
||
});
|
||
return reply({ ok: true, banVote: true });
|
||
}
|
||
const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex);
|
||
reply(started);
|
||
});
|
||
|
||
socket.on('detective-minigame-finished', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) {
|
||
return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
}
|
||
/* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
|
||
const finPeer = space.peers.get(socket.id);
|
||
if (finPeer && data && typeof data === 'object') {
|
||
/* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
|
||
หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */
|
||
if (Number.isFinite(Number(data.score))) {
|
||
let rs = Math.max(0, Math.floor(Number(data.score)));
|
||
if (rs > REPORTED_MINI_SCORE_MAX) {
|
||
console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX);
|
||
rs = REPORTED_MINI_SCORE_MAX;
|
||
}
|
||
finPeer.reportedMiniScore = rs;
|
||
}
|
||
if (data.survived === false) finPeer.reportedMiniSurvived = false;
|
||
}
|
||
if (!space.detectiveMinigameActive) {
|
||
if (serverMapIsPostCaseLobbyB(space)) {
|
||
const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
|
||
return reply({
|
||
ok: true,
|
||
alreadyOnLobbyB: true,
|
||
suspectIndex: grant.suspectIdx,
|
||
awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
|
||
awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
|
||
grade: grant.grade || null,
|
||
freeEvidenceCount: grant.freeEvidenceCount || 0,
|
||
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
|
||
});
|
||
}
|
||
return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
|
||
}
|
||
/* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก"
|
||
จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด
|
||
ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */
|
||
const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) {
|
||
if (finPeer) finPeer.detectiveFinished = true;
|
||
const allDone = [...space.peers.values()].every((p) =>
|
||
p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished
|
||
);
|
||
if (!allDone) {
|
||
/* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */
|
||
return reply({ ok: true, holdInScene: true });
|
||
}
|
||
/* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */
|
||
}
|
||
// เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
|
||
returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
|
||
const g = space.lastEvidenceGrant || {};
|
||
reply({
|
||
ok: true,
|
||
suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
|
||
awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
|
||
awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
|
||
grade: g.grade || null,
|
||
freeEvidenceCount: g.freeEvidenceCount || 0,
|
||
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
|
||
});
|
||
});
|
||
|
||
/**
|
||
* DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที
|
||
* (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)
|
||
*/
|
||
socket.on('detective-debug-award-evidence', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
|
||
const idx = Math.floor(Number(data && data.suspectIndex));
|
||
if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' });
|
||
const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C');
|
||
if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' });
|
||
emitLobbyEvidenceSync(sid, space);
|
||
reply({
|
||
ok: true,
|
||
suspectIndex: idx,
|
||
awardedCard: card,
|
||
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
|
||
});
|
||
});
|
||
|
||
// Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย)
|
||
socket.on('suspect-accuse-open', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
|
||
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
|
||
const prog = playerSuspectProgressFromEvidence(space, socket.id);
|
||
if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
|
||
if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3);
|
||
space.suspectPhaseActive = false;
|
||
// เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต
|
||
space.testimonyActive = true;
|
||
space.testimonyRound = 0;
|
||
emitTestimonyOpen(sid, space);
|
||
reply({ ok: true });
|
||
});
|
||
|
||
// ผู้เล่นส่งหลักฐาน 1–2 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง
|
||
socket.on('testimony-submit', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' });
|
||
if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' });
|
||
const raw = (data && Array.isArray(data.picks)) ? data.picks : [];
|
||
const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []);
|
||
const required = Math.max(1, Math.min(2, owned.length));
|
||
/* raw = ตำแหน่ง index ใน owned (0..n-1) → เก็บเป็น card object */
|
||
const usedIdx = [];
|
||
const picks = [];
|
||
raw.forEach((n) => {
|
||
const v = Math.floor(Number(n));
|
||
if (v >= 0 && v < owned.length && usedIdx.indexOf(v) === -1) { usedIdx.push(v); picks.push(owned[v]); }
|
||
});
|
||
if (owned.length === 0) {
|
||
/* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */
|
||
if (!space.testimonySubmitted) space.testimonySubmitted = {};
|
||
space.testimonySubmitted[socket.id] = [];
|
||
emitTestimonyStatus(sid, space);
|
||
/* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design */
|
||
return reply({ ok: true, skipped: true });
|
||
}
|
||
if (picks.length !== required) {
|
||
return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' });
|
||
}
|
||
if (!space.testimonySubmitted) space.testimonySubmitted = {};
|
||
space.testimonySubmitted[socket.id] = picks.slice(0, required);
|
||
emitTestimonyStatus(sid, space);
|
||
/* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design
|
||
(UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */
|
||
reply({ ok: true });
|
||
});
|
||
|
||
// Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง)
|
||
socket.on('testimony-reveal', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
|
||
if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' });
|
||
doTestimonyReveal(sid, space);
|
||
reply({ ok: true });
|
||
});
|
||
|
||
// ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) — ไม่ auto-advance รอ host กด "เริ่มพิจารณาคดี"
|
||
socket.on('testimony-ready-next', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' });
|
||
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
|
||
space.testimonyReadyNext[socket.id] = true;
|
||
const totalRN = testimonyTotalParticipants(space);
|
||
io.to(sid).emit('testimony-ready-update', {
|
||
ready: Object.keys(space.testimonyReadyNext),
|
||
totalPlayers: totalRN,
|
||
hostId: space.hostId,
|
||
});
|
||
/* ไม่ auto-advance — รอ host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote.png) เอง */
|
||
reply({ ok: true });
|
||
});
|
||
|
||
// Host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote) — ไปปากคำถัดไป/เข้าโหวต
|
||
socket.on('testimony-start-vote', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
|
||
if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังไม่ถึงเวลา' });
|
||
/* host อาจกดก่อนทุกคน READY — บังคับให้ทุกคน (รวมบอท) ready ก่อน advance เพื่อให้ flow consistent */
|
||
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
|
||
testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; });
|
||
advanceTestimony(sid, space);
|
||
reply({ ok: true });
|
||
});
|
||
|
||
// ผู้เล่นโหวตชี้ตัวคนร้าย
|
||
socket.on('trial-vote', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' });
|
||
if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true });
|
||
let idx = Math.floor(Number(data && data.index));
|
||
if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' });
|
||
/* รอบจับผิดตัว: ห้ามโหวตผู้ต้องสงสัยที่โหวตผิดไปแล้ว */
|
||
if (Number.isInteger(space.trialRevoteExcluded) && idx === space.trialRevoteExcluded) {
|
||
return reply({ ok: false, error: 'ผู้ต้องสงสัยนี้ถูกโหวตไปแล้ว (ผิด) — เลือกคนอื่น', excluded: true });
|
||
}
|
||
space.trialVotes = space.trialVotes || {};
|
||
space.trialVotes[socket.id] = idx;
|
||
emitTrialVoteUpdate(sid, space);
|
||
const totalTV = trialEligibleVoterTotal(space);
|
||
if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) {
|
||
computeAndEmitTrialResult(sid, space);
|
||
}
|
||
reply({ ok: true, index: idx });
|
||
});
|
||
|
||
// Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต)
|
||
socket.on('trial-reveal', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
|
||
if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' });
|
||
computeAndEmitTrialResult(sid, space);
|
||
reply({ ok: true });
|
||
});
|
||
|
||
/* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */
|
||
socket.on('player-pick-vote', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
const pv = space.pickVote;
|
||
if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' });
|
||
const tid = String((data && data.targetId) || '');
|
||
if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' });
|
||
pv.votes[socket.id] = tid;
|
||
reply({ ok: true, targetId: tid });
|
||
emitPickVoteUpdate(sid, space);
|
||
maybeResolvePickVote(sid, space);
|
||
});
|
||
|
||
/** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */
|
||
socket.on('host-console-apply', (data, cb) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) {
|
||
return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' });
|
||
}
|
||
const humans = space.peers.size;
|
||
let total = parseInt(data && data.totalSlots, 10);
|
||
if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space);
|
||
total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total));
|
||
let bots = parseInt(data && data.botSlotCount, 10);
|
||
if (!Number.isFinite(bots)) bots = total - humans;
|
||
bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots));
|
||
total = humans + bots;
|
||
space.botSlotCount = bots;
|
||
space.maxPlayers = Math.max(humans, total - bots);
|
||
const lobbyBotThemes = syncLobbyBotThemeSlots(space);
|
||
io.to(sid).emit('host-console-settings', {
|
||
maxPlayers: space.maxPlayers,
|
||
botSlotCount: space.botSlotCount,
|
||
totalSlots: total,
|
||
lobbyBotThemes,
|
||
});
|
||
emitLobbyTintSync(sid, space);
|
||
cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes });
|
||
});
|
||
|
||
socket.on('host-console-kick-peer', ({ targetId }, cb) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) {
|
||
return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' });
|
||
}
|
||
const tid = targetId != null ? String(targetId) : '';
|
||
if (!tid || tid === socket.id || tid === space.hostId) {
|
||
return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' });
|
||
}
|
||
if (!space.peers.has(tid)) {
|
||
return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' });
|
||
}
|
||
const targetSock = io.sockets.sockets.get(tid);
|
||
if (targetSock) {
|
||
targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' });
|
||
targetSock.leave(sid);
|
||
delete targetSock.data.spaceId;
|
||
}
|
||
space.peers.delete(tid);
|
||
io.to(sid).emit('user-left', { id: tid });
|
||
cb && cb({ ok: true });
|
||
});
|
||
|
||
/* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */
|
||
socket.on('set-ready', ({ ready }) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id)) {
|
||
const p = space.peers.get(socket.id);
|
||
if (p) p.ready = !!ready;
|
||
io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('start-game', (data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id) && space.hostId === socket.id) {
|
||
let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : '';
|
||
let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : '';
|
||
if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = '';
|
||
/* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */
|
||
if (caseId && !/^(?:[1-9]|1[0-5])$/.test(caseId)) caseId = '';
|
||
ensurePostCaseLobbyMapLoaded();
|
||
const mdBefore = getLobbyLayoutMapForSpace(space);
|
||
if (mdBefore && mdBefore.gameType === 'quiz') {
|
||
const qErr = validateQuizMap(mdBefore);
|
||
if (qErr) return reply({ ok: false, error: qErr });
|
||
clearSpaceQuizTimers(space);
|
||
startQuizGame(sid, space, mdBefore);
|
||
io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true });
|
||
return reply({ ok: true });
|
||
}
|
||
const detectiveB = !!(data && data.detectiveLobbyBStart);
|
||
if (detectiveB && (!lobbyLevel || !caseId)) {
|
||
return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' });
|
||
}
|
||
const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : '';
|
||
const isNamedLobbyA = mapNameNorm === 'lobbya';
|
||
const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet');
|
||
/* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */
|
||
const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore)
|
||
&& lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID)
|
||
&& (
|
||
mdBefore.gameType === 'lobby'
|
||
|| isNamedLobbyA
|
||
|| (detectiveB && notZepOrFrogger)
|
||
));
|
||
if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) {
|
||
return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' });
|
||
}
|
||
/* detective LobbyA→LobbyB: ไม่บังคับยืนโซนส้ม (หน้าจอเลือกคดีบังแผนที่ ขยับตัวไม่ได้) */
|
||
if (!isLobbyADetectiveStart && !lobbyHostStandingInStartArea(space, socket.id)) {
|
||
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
|
||
}
|
||
if (isLobbyADetectiveStart) {
|
||
assignDetectiveSuspectCardMinigames(space);
|
||
space.mapId = POST_CASE_LOBBY_SPACE_ID;
|
||
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
|
||
const caseNickWl = new Set();
|
||
space.peers.forEach((p) => {
|
||
caseNickWl.add(normalizeLobbyNickname(p.nickname));
|
||
});
|
||
space.caseParticipantNicknames = caseNickWl;
|
||
space.joinLocked = true;
|
||
space.troublesomeOfferSent = false;
|
||
space.troublesomeTargetId = null;
|
||
space.troublesomeResponseReceived = false;
|
||
space.troublesomeAccept = false;
|
||
space.disruptorPlayerKey = '';
|
||
space.disruptorNickname = '';
|
||
space.suspectPhaseActive = false;
|
||
space.suspectPickIndex = 0;
|
||
space.detectiveLobbyLevel = Number(lobbyLevel) || 1;
|
||
space.detectiveLobbyCaseId = Number(caseId) || 1;
|
||
space.caseId = Number(caseId) || 1;
|
||
if (space.troublesomeEligible) space.troublesomeEligible.clear();
|
||
else space.troublesomeEligible = new Set();
|
||
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
|
||
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
|
||
space.troublesomeDebTimer = null;
|
||
space.troublesomeMaxTimer = null;
|
||
initTroublesomeState(space);
|
||
space.peers.forEach((p) => {
|
||
const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
|
||
p.x = sp.x;
|
||
p.y = sp.y;
|
||
p.direction = 'down';
|
||
});
|
||
const peersSnap = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
nickname: p.nickname,
|
||
ready: !!p.ready,
|
||
characterId: p.characterId ?? null
|
||
}));
|
||
space.peers.forEach((_p, pid) => {
|
||
const sock = io.sockets.sockets.get(pid);
|
||
if (!sock) return;
|
||
sock.emit('game-start', {
|
||
stayInRoomLobby: true,
|
||
mapId: POST_CASE_LOBBY_SPACE_ID,
|
||
lobbyLevel,
|
||
caseId,
|
||
peersSnap,
|
||
cardMinigames: space.suspectCardMinigames || [],
|
||
myPlayerEvidence: clonePlayerEvidence(space, pid),
|
||
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
|
||
});
|
||
});
|
||
return reply({ ok: true });
|
||
}
|
||
const mapId = (data && data.mapId) ? String(data.mapId).trim() : null;
|
||
let peersSnap = null;
|
||
let gauntletEndsAtEmit = undefined;
|
||
if (mapId && maps.has(mapId)) {
|
||
const prevMapIdForReturn = space.mapId;
|
||
space.mapId = mapId;
|
||
space.mapData = maps.get(mapId);
|
||
const md = space.mapData;
|
||
if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
|
||
space.peers.forEach((p) => {
|
||
const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y));
|
||
p.x = sn.x;
|
||
p.y = sn.y;
|
||
});
|
||
}
|
||
if (!md || md.gameType !== 'gauntlet') {
|
||
stopGauntletTicker(space);
|
||
space.gauntletReturnMapId = null;
|
||
space.gauntletRun = null;
|
||
}
|
||
if (md && md.gameType === 'gauntlet') {
|
||
stopGauntletTicker(space);
|
||
space.gauntletReturnMapId = prevMapIdForReturn;
|
||
initGauntletPeersAll(space, md);
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
startGauntletTicker(sid, space);
|
||
gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null
|
||
? space.gauntletRun.endsAt
|
||
: null;
|
||
peersSnap = [...space.peers.values()].map((p) => ({
|
||
id: p.id,
|
||
x: p.x,
|
||
y: p.y,
|
||
direction: p.direction || 'down',
|
||
nickname: p.nickname,
|
||
ready: !!p.ready,
|
||
characterId: p.characterId ?? null,
|
||
gauntletJumpTicks: p.gauntletJumpTicks || 0,
|
||
gauntletScore: p.gauntletScore || 0,
|
||
gauntletEliminated: !!p.gauntletEliminated,
|
||
}));
|
||
}
|
||
if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) {
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
|
||
space.peers.forEach((_p, id) => {
|
||
space.gauntletCrownLobbyReady[id] = false;
|
||
});
|
||
space.gauntletRun = newBalloonBossShellGauntletRunState();
|
||
}
|
||
}
|
||
const gameStartPayload = {
|
||
mapId: mapId || undefined,
|
||
lobbyLevel: lobbyLevel || undefined,
|
||
caseId: caseId || undefined,
|
||
peersSnap: peersSnap || undefined,
|
||
};
|
||
if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null;
|
||
if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) {
|
||
const grs = space.gauntletRun;
|
||
gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld);
|
||
}
|
||
io.to(sid).emit('game-start', gameStartPayload);
|
||
return reply({ ok: true });
|
||
}
|
||
}
|
||
reply({ ok: false, error: 'ไม่ใช่ host' });
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space)
|
||
let sid = socket.data.spaceId;
|
||
let space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) {
|
||
for (const [s, sp] of spaces) {
|
||
if (sp.peers.has(socket.id)) { sid = s; space = sp; break; }
|
||
}
|
||
}
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const wasHost = space.hostId === socket.id;
|
||
if (space.voiceInits) delete space.voiceInits[socket.id];
|
||
if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id);
|
||
if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id];
|
||
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id);
|
||
if (space.quizCarryLobbyReady && typeof space.quizCarryLobbyReady === 'object') {
|
||
delete space.quizCarryLobbyReady[socket.id];
|
||
if (spaceAllowsQuizCarryLobbyRelaxed(space)) {
|
||
io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space));
|
||
}
|
||
}
|
||
if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') {
|
||
delete space.gauntletCrownLobbyReady[socket.id];
|
||
if (isCrownLobbyShellSpace(space)) {
|
||
io.to(sid).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space));
|
||
}
|
||
}
|
||
if (space.qbwSolved) space.qbwSolved.delete(socket.id); // Quiz Battle (ฉากเดิน) — เอาคะแนนคนออกแล้ว sync ใหม่ด้านล่าง
|
||
space.peers.delete(socket.id);
|
||
if (space.peers.size === 0) {
|
||
stopGauntletTicker(space);
|
||
clearSpaceQuizTimers(space);
|
||
space.quizSession = null;
|
||
space.emptySince = Date.now();
|
||
/* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */
|
||
const keepCaseSpace = !!(space.detectiveMinigameActive
|
||
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0));
|
||
/* ห้อง Quiz Battle — เก็บไว้เมื่อว่าง ให้ refresh กลับเข้าห้องเดิมได้ (TTL prune จะเก็บกวาดเมื่อว่างเกิน 45 วิ) */
|
||
const spaceMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
const keepQuizBattleRoom = !!(spaceMd && spaceMd.gameType === 'quiz_battle');
|
||
if (keepCaseSpace || keepQuizBattleRoom) {
|
||
space.hostId = null;
|
||
return;
|
||
}
|
||
spaces.delete(sid);
|
||
} else {
|
||
if (wasHost) {
|
||
const firstPeer = space.peers.values().next().value;
|
||
if (firstPeer) {
|
||
space.hostId = firstPeer.id; /* ย้าย pointer host ชั่วคราวให้คนที่เหลือ (liveness) */
|
||
/* อย่าเขียนทับ hostPlayerKey ถ้ามีอยู่แล้ว — host ตัวจริงต้อง reclaim ได้เมื่อ rejoin จาก play.html
|
||
(เปลี่ยนหน้า lobby→play→lobbyB ทำให้ socket disconnect ชั่วคราว ไม่ใช่ออกจากห้องจริง
|
||
ถ้าเขียนทับ key → host/client สลับกันถาวรหลังเข้า minigame) */
|
||
if (!space.hostPlayerKey && firstPeer.playerKey) space.hostPlayerKey = firstPeer.playerKey;
|
||
io.to(sid).emit('host-changed', { hostId: firstPeer.id });
|
||
}
|
||
}
|
||
io.to(sid).emit('user-left', { id: socket.id });
|
||
if (space.qbwSolved) qbwBroadcastScores(sid, space); // Quiz Battle ฉากเดิน — อัปเดตอันดับหลังมีคนออก
|
||
emitDetectiveLobbyBPresence(sid, space); /* คนออกจาก LobbyB → อัปเดตจำนวนที่พร้อมให้ host */
|
||
/* กำลังรอกด «เล่นต่อ» (special-quiz) แล้วมีคนออก — เอา ack คนนั้นออกแล้วเช็คใหม่ (กันค้างรอคนที่ไม่อยู่แล้ว) */
|
||
if (space.specialQuizAwaitContinue) {
|
||
if (space.specialQuizContinueAcks) space.specialQuizContinueAcks.delete(socket.id);
|
||
maybeFinishSpecialQuizContinue(sid, space);
|
||
}
|
||
}
|
||
});
|
||
|
||
/* client แจ้งว่าเส้นชัยมาถึงแล้ว (BG ถึง finish) → เข้าโหมดจบ: หยุด obstacle + collision */
|
||
socket.on('gauntlet-finish-phase', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if (space.gauntletRun) {
|
||
space.gauntletRun.finishPhase = true;
|
||
space.gauntletRun.obstacles = [];
|
||
emitGauntletSync(sid, space);
|
||
}
|
||
});
|
||
|
||
socket.on('gauntlet-jump', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const p = space.peers.get(socket.id);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if (space.gauntletRun && space.gauntletRun.crownRunHeld) return;
|
||
if (isGauntletCrownHeistMapBySpace(space) && p.gauntletEliminated) return;
|
||
if ((p.gauntletJumpTicks || 0) > 0) return;
|
||
p.gauntletJumpPending = true;
|
||
});
|
||
|
||
socket.on('gauntlet-crown-begin-run', (cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
|
||
const gr = space.gauntletRun;
|
||
if (!gr) return reply({ ok: false });
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space);
|
||
const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space);
|
||
if (!okGauntletCrown && !okBalloonShell) return reply({ ok: false });
|
||
const previewRoom = !!(space.previewEditorTest
|
||
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
|
||
if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' });
|
||
if (!gr.crownRunHeld) return reply({ ok: true, already: true });
|
||
gr.crownRunHeld = false;
|
||
if (okGauntletCrown) {
|
||
ensureGauntletEndsAtIfNeeded(space);
|
||
emitGauntletSync(sid, space);
|
||
} else {
|
||
emitBalloonBossMissionShellGauntletSync(sid, space);
|
||
}
|
||
return reply({ ok: true });
|
||
});
|
||
|
||
/** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */
|
||
socket.on('gauntlet-preview-rows', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md || md.gameType !== 'gauntlet') return;
|
||
if (space.gauntletRun && space.gauntletRun.crownRunHeld) return;
|
||
const hh = md.height || 15;
|
||
if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map();
|
||
const raw = data && Array.isArray(data.ys) ? data.ys : [];
|
||
const set = new Set();
|
||
const cap = Math.min(raw.length, 48);
|
||
for (let i = 0; i < cap; i++) {
|
||
const fy = Math.floor(Number(raw[i]));
|
||
if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy);
|
||
}
|
||
space.gauntletPreviewRowsBySocket.set(socket.id, set);
|
||
});
|
||
|
||
socket.on('quiz-carry-lobby-sync-request', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return;
|
||
if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') {
|
||
space.quizCarryLobbyReady = {};
|
||
}
|
||
for (const id of space.peers.keys()) {
|
||
if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false;
|
||
}
|
||
socket.emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space));
|
||
});
|
||
|
||
socket.on('quiz-carry-lobby-ready', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return;
|
||
if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {};
|
||
for (const id of space.peers.keys()) {
|
||
if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false;
|
||
}
|
||
space.quizCarryLobbyReady[socket.id] = !!(data && data.ready);
|
||
io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space));
|
||
});
|
||
|
||
socket.on('quiz-carry-lobby-start', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return reply({ ok: false, error: 'ห้องนี้ไม่ใช่โหมด lobby หยิบมาวาง / พรีวิว' });
|
||
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' });
|
||
const rm = space.quizCarryLobbyReady || {};
|
||
const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id));
|
||
/* ต้องเข้าเกมครบก่อน (กัน host เริ่มก่อน client โหลดเสร็จ) */
|
||
const expectedActive = space.minigameExpectedActiveCount || 0;
|
||
if (expectedActive > 0 && humanIds.length < expectedActive) {
|
||
return reply({ ok: false, waitingPlayers: true, present: humanIds.length, total: expectedActive, error: 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + humanIds.length + '/' + expectedActive + ')' });
|
||
}
|
||
const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]);
|
||
if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' });
|
||
const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS;
|
||
space.missionLiveBeginsAt = liveBeginsAt;
|
||
io.to(sid).emit('quiz-carry-lobby-started', { liveBeginsAt });
|
||
reply({ ok: true });
|
||
});
|
||
|
||
/* host เลือกคำถาม quiz_carry → relay index ให้คนอื่นในห้องใช้ข้อเดียวกัน (กันสุ่มไม่ตรงกัน) */
|
||
socket.on('quiz-carry-question', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) return; /* เฉพาะ host */
|
||
const idx = Number(data && data.idx);
|
||
if (!Number.isInteger(idx) || idx < 0) return;
|
||
socket.to(sid).emit('quiz-carry-question', { idx });
|
||
});
|
||
|
||
/* host จบเซสชัน quiz_carry → ให้คนอื่นโชว์สรุปพร้อมกัน */
|
||
socket.on('quiz-carry-session-complete', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || space.hostId !== socket.id) return;
|
||
socket.to(sid).emit('quiz-carry-session-complete', {});
|
||
});
|
||
|
||
/* ตอบเวลา server ปัจจุบัน → client ใช้คำนวณ clock offset (ชดเชย latency) ให้เวลาในเกมตรงกันทุกเครื่อง */
|
||
socket.on('time-sync', (cb) => {
|
||
if (typeof cb === 'function') cb({ t: Date.now() });
|
||
});
|
||
|
||
socket.on('gauntlet-crown-lobby-sync-request', () => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
if (!isCrownLobbyShellSpace(space)) return;
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') {
|
||
space.gauntletCrownLobbyReady = {};
|
||
}
|
||
for (const id of space.peers.keys()) {
|
||
if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false;
|
||
}
|
||
socket.emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space));
|
||
});
|
||
|
||
socket.on('gauntlet-crown-lobby-ready', (data) => {
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return;
|
||
if (!isCrownLobbyShellSpace(space)) return;
|
||
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
|
||
for (const id of space.peers.keys()) {
|
||
if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false;
|
||
}
|
||
space.gauntletCrownLobbyReady[socket.id] = !!(data && data.ready);
|
||
io.to(sid).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space));
|
||
});
|
||
|
||
socket.on('gauntlet-crown-lobby-start', (_data, cb) => {
|
||
const reply = typeof cb === 'function' ? cb : () => {};
|
||
const sid = socket.data.spaceId;
|
||
const space = sid ? spaces.get(sid) : null;
|
||
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
|
||
if (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' });
|
||
const soloRoom = space.peers.size === 1;
|
||
if (!soloRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' });
|
||
const gr = space.gauntletRun;
|
||
if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' });
|
||
const rm = space.gauntletCrownLobbyReady || {};
|
||
const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id));
|
||
/* ต้องเข้าเกมครบก่อน (กัน host เริ่มก่อน client โหลดเสร็จ → เกมไม่ sync/จบไม่พร้อมกัน) */
|
||
const expectedActive = space.minigameExpectedActiveCount || 0;
|
||
if (expectedActive > 0 && humanIds.length < expectedActive) {
|
||
return reply({ ok: false, waitingPlayers: true, present: humanIds.length, total: expectedActive, error: 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + humanIds.length + '/' + expectedActive + ')' });
|
||
}
|
||
const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]);
|
||
if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' });
|
||
/* เวลาเริ่มเล่นจริงร่วมกัน (server epoch) → ทุก client นับ 3-2-1 ไปหาจุดนี้ → GO พร้อมกัน */
|
||
const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS;
|
||
space.missionLiveBeginsAt = liveBeginsAt;
|
||
if (isQuizQuestionMissionShellSpace(space)) {
|
||
/* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ไม่ใช่ทันที) ให้ตรงกับตอน client จบ 3-2-1 */
|
||
setTimeout(() => {
|
||
const sp = spaces.get(sid);
|
||
if (!sp) return;
|
||
const md2 = maps.get(sp.mapId) || sp.mapData;
|
||
if (md2 && md2.gameType === 'quiz') {
|
||
clearSpaceQuizTimers(sp);
|
||
startQuizGame(sid, sp, md2);
|
||
}
|
||
}, LIVE_COUNTDOWN_MS);
|
||
}
|
||
io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt });
|
||
reply({ ok: true });
|
||
});
|
||
|
||
socket.on('move', (data) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id)) {
|
||
const p = space.peers.get(socket.id);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (md && md.gameType === 'gauntlet') {
|
||
const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId };
|
||
socket.emit('user-move', out);
|
||
break;
|
||
}
|
||
let nx = data.x;
|
||
let ny = data.y;
|
||
const sess = space.quizSession;
|
||
const mdQuiz = (sess && sess.quizMapMd) || md;
|
||
if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') {
|
||
const st = sess.players && sess.players[socket.id];
|
||
if (st && !st.eliminated) {
|
||
const tx = Math.floor(Number(nx));
|
||
const ty = Math.floor(Number(ny));
|
||
const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty);
|
||
const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty);
|
||
if (blockTrue || blockFalse) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
}
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) {
|
||
const txN = Number(nx);
|
||
const tyN = Number(ny);
|
||
if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'quiz_battle') {
|
||
const txN = Number(nx);
|
||
const tyN = Number(ny);
|
||
if (!Number.isFinite(txN) || !Number.isFinite(tyN) || !serverFootprintClearOfWalls(md, txN, tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
} else if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, txN, tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
} else if (quizBattlePathModeActiveServer(md) && !quizBattlePathGateAllowsServer(md, space, socket.id, txN, tyN)) {
|
||
nx = p.x;
|
||
ny = p.y;
|
||
}
|
||
if (quizBattlePathModeActiveServer(md)) {
|
||
const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny));
|
||
nx = sn.x;
|
||
ny = sn.y;
|
||
}
|
||
}
|
||
let moveForceEmit = false;
|
||
if (p && md && md.gameType === 'space_shooter' && data) {
|
||
if (data.spaceShooterScore != null) {
|
||
const ns = Math.floor(Number(data.spaceShooterScore));
|
||
if (Number.isFinite(ns) && ns >= 0) {
|
||
const prev = Math.max(0, p.spaceShooterScore | 0);
|
||
if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns);
|
||
}
|
||
}
|
||
if (data.spaceShooterHits != null) {
|
||
const nh = Math.floor(Number(data.spaceShooterHits));
|
||
if (Number.isFinite(nh) && nh >= 0) {
|
||
const prevH = Math.max(0, p.spaceShooterHits | 0);
|
||
if (nh > prevH) { p.spaceShooterHits = nh; moveForceEmit = true; }
|
||
}
|
||
}
|
||
/* ตาย/รอด มาจากเจ้าของยาน → ต้อง relay แม้ตำแหน่งไม่เปลี่ยน (ยานหยุดตอนตาย)
|
||
ไม่งั้นยานคนตายจะค้างนิ่งบนจออีกฝั่ง (ดูเหมือนขยับไม่ได้) + ตายไม่ตรงกัน */
|
||
if (data.spaceShooterEliminated && !p.spaceShooterEliminated) {
|
||
p.spaceShooterEliminated = true;
|
||
moveForceEmit = true;
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'jump_survive' && data) {
|
||
/* MG5: การตายมาจากเจ้าตัว → relay แม้ตำแหน่งไม่เปลี่ยน (ตอนตายไม่ขยับ) ไม่งั้นคนอื่นไม่รู้ = "ไม่ยอมตาย" */
|
||
if (data.jumpSurviveEliminated && !p.jumpSurviveEliminated) {
|
||
p.jumpSurviveEliminated = true;
|
||
moveForceEmit = true;
|
||
}
|
||
}
|
||
if (p && md && md.gameType === 'balloon_boss' && data) {
|
||
const bbDefaultBalloons = balloonBossBalloonsForMap(md);
|
||
if (data.balloonBossScore != null) {
|
||
const ns = Math.floor(Number(data.balloonBossScore));
|
||
if (Number.isFinite(ns) && ns >= 0) {
|
||
const prev = Math.max(0, p.balloonBossScore | 0);
|
||
if (ns <= prev + 35) p.balloonBossScore = Math.max(prev, ns);
|
||
}
|
||
}
|
||
if (data.balloonBossBossDmg != null) {
|
||
const nd = Math.floor(Number(data.balloonBossBossDmg));
|
||
if (Number.isFinite(nd) && nd >= 0) {
|
||
const prevD = Math.max(0, p.balloonBossBossDmg | 0);
|
||
if (nd <= prevD + 8) p.balloonBossBossDmg = Math.max(prevD, nd);
|
||
}
|
||
}
|
||
if (data.balloonBossBalloons != null) {
|
||
const nb = Math.floor(Number(data.balloonBossBalloons));
|
||
if (Number.isFinite(nb) && nb >= 0) {
|
||
const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
|
||
? Math.floor(p.balloonBossBalloons)
|
||
: bbDefaultBalloons;
|
||
if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb;
|
||
}
|
||
}
|
||
if (data.balloonBossEliminated && !p.balloonBossEliminated) { p.balloonBossEliminated = true; moveForceEmit = true; }
|
||
else if (data.balloonBossEliminated) p.balloonBossEliminated = true;
|
||
}
|
||
if (p && md && md.gameType === 'quiz_carry' && data && data.quizCarryHeld !== undefined) {
|
||
/* relay ของที่ผู้เล่น (มนุษย์) ถืออยู่ → client อื่นเห็น "ใครถืออะไร"; force emit แม้ยืนนิ่ง (หยิบ/วางขณะไม่ขยับ) */
|
||
const prevHeld = (p.quizCarryHeld === undefined ? null : p.quizCarryHeld);
|
||
const rawHeld = data.quizCarryHeld;
|
||
const nh = (rawHeld == null) ? null : Math.floor(Number(rawHeld));
|
||
const newHeld = (nh == null || !Number.isFinite(nh)) ? null : nh;
|
||
if (newHeld !== prevHeld) moveForceEmit = true;
|
||
p.quizCarryHeld = newHeld;
|
||
}
|
||
if (p) {
|
||
const prevX = Number(p.x);
|
||
const prevY = Number(p.y);
|
||
const prevDir = p.direction || 'down';
|
||
p.x = nx;
|
||
p.y = ny;
|
||
p.direction = data.direction || p.direction;
|
||
const posUnchanged = Math.abs(nx - prevX) < 1e-5 && Math.abs(ny - prevY) < 1e-5 && p.direction === prevDir;
|
||
if (!posUnchanged || moveForceEmit) {
|
||
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
|
||
if (md && md.gameType === 'space_shooter') {
|
||
out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
|
||
out.spaceShooterHits = Math.max(0, p.spaceShooterHits | 0);
|
||
out.spaceShooterEliminated = !!p.spaceShooterEliminated;
|
||
}
|
||
if (md && md.gameType === 'balloon_boss') {
|
||
const bbDef = balloonBossBalloonsForMap(md);
|
||
out.balloonBossScore = Math.max(0, p.balloonBossScore | 0);
|
||
out.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 0);
|
||
out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
|
||
? Math.max(0, Math.floor(p.balloonBossBalloons))
|
||
: bbDef;
|
||
out.balloonBossEliminated = !!p.balloonBossEliminated;
|
||
}
|
||
if (md && md.gameType === 'quiz_carry') {
|
||
out.quizCarryHeld = (p.quizCarryHeld === undefined ? null : p.quizCarryHeld);
|
||
}
|
||
if (md && md.gameType === 'jump_survive') {
|
||
out.jumpSurviveEliminated = !!p.jumpSurviveEliminated;
|
||
}
|
||
if (nx !== data.x || ny !== data.y) socket.emit('user-move', out);
|
||
socket.to(sid).emit('user-move', out);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('lobby-interact', (data, ack) => {
|
||
const reply = typeof ack === 'function' ? ack : () => {};
|
||
const tx = Math.floor(Number(data && data.x));
|
||
const ty = Math.floor(Number(data && data.y));
|
||
for (const [sid, space] of spaces) {
|
||
if (!space.peers.has(socket.id)) continue;
|
||
const p = space.peers.get(socket.id);
|
||
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
|
||
if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' });
|
||
const w = md.width || 20;
|
||
const h = md.height || 15;
|
||
if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) {
|
||
return reply({ ok: false, error: 'จุดไม่ถูกต้อง' });
|
||
}
|
||
if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) {
|
||
return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' });
|
||
}
|
||
const px = Math.floor(Number(p.x));
|
||
const py = Math.floor(Number(p.y));
|
||
const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]];
|
||
let near = false;
|
||
for (const [dx, dy] of neighbors) {
|
||
if (px + dx === tx && py + dy === ty) { near = true; break; }
|
||
}
|
||
if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' });
|
||
if (!p.ready) return reply({ ok: false, error: 'กดพร้อมก่อน แล้วค่อยกด F ที่ช่องสีเขียว' });
|
||
const nick = (p.nickname || '').trim() || 'ผู้เล่น';
|
||
io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty });
|
||
return reply({ ok: true });
|
||
}
|
||
reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' });
|
||
});
|
||
|
||
socket.on('chat', (text) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid)) {
|
||
const nick = space.peers.get(socket.id)?.nickname || '';
|
||
io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('voice-state', ({ micOn }) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (space.peers.has(socket.id)) {
|
||
const p = space.peers.get(socket.id);
|
||
if (p) p.voiceMicOn = micOn !== false;
|
||
if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id];
|
||
io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('voice-activity', ({ level }) => {
|
||
const sid = socket.data.spaceId;
|
||
if (!sid || !spaces.get(sid)) return;
|
||
const until = Date.now() + 400;
|
||
io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until });
|
||
});
|
||
|
||
socket.on('voice-init', (data) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid)) {
|
||
if (!space.voiceInits) space.voiceInits = {};
|
||
space.voiceInits[socket.id] = data;
|
||
io.to(sid).emit('voice-init', { from: socket.id, data });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('voice-chunk', (data) => {
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid)) {
|
||
socket.to(sid).emit('voice-chunk', { from: socket.id, data });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
socket.on('webrtc-signal', (data) => {
|
||
const { to, type, sdp, candidate } = data;
|
||
if (!to) return;
|
||
for (const [sid, space] of spaces) {
|
||
if (socket.rooms.has(sid) && space.peers.has(to)) {
|
||
io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
loadMaps();
|
||
server.on('error', (err) => {
|
||
if (err && (err.code === 'EACCES' || err.code === 'EADDRINUSE')) {
|
||
console.error('');
|
||
console.error('[Game] Cannot listen on', HOST + ':' + PORT, '(' + err.code + ').');
|
||
console.error(' Windows AppServ:');
|
||
console.error(' set HOST=127.0.0.1');
|
||
console.error(' set PORT=13010');
|
||
console.error(' node server.js');
|
||
console.error(' Or double-click start-dev.cmd');
|
||
console.error('');
|
||
process.exit(1);
|
||
}
|
||
throw err;
|
||
});
|
||
server.listen(PORT, HOST, () => console.log('Game server listening on http://' + HOST + ':' + PORT + BASE_PATH)); |