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

12241 lines
609 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const http = require('http');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { Server } = require('socket.io');
const PORT = parseInt(process.env.PORT, 10) || 3001;
/** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */
const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0';
const BASE_PATH = '/Game';
const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
const adminTokens = new Set();
/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */
const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */
/** ตรงแท็บ Admin Minigame 17 (ไม่รวม Quiz Battle / กบ frogger เก่า) */
const DETECTIVE_MINIGAME_POOL = [
{ key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' },
{ key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' },
{ key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' },
{ key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' },
{ key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' },
{ key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' },
{ key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' },
];
/** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */
function normalizeForcedMinigameKeys(v) {
let arr;
if (Array.isArray(v)) arr = v;
else if (v != null && String(v).trim()) arr = [v, v, v];
else arr = [];
const one = (k) => {
const s = (k == null ? '' : String(k)).trim();
return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : '';
};
return [one(arr[0]), one(arr[1]), one(arr[2])];
}
const LOBBY_SLOT_TOTAL = 6;
/* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้
ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */
const LIVE_COUNTDOWN_MS = 3500;
const LOBBY_THEME_COUNT = 8;
const LOBBY_SKIN_COUNT = 3;
/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
function getTakenLobbyThemeIndices(space, excludePeerId) {
const taken = new Set();
if (!space) return taken;
space.peers.forEach((p, id) => {
if (excludePeerId && id === excludePeerId) return;
const ti = parseInt(p.lobbyColorThemeIndex, 10);
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
});
const bots = space.lobbyBotThemeBySlot;
if (bots && typeof bots === 'object') {
Object.keys(bots).forEach((k) => {
const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
});
}
return taken;
}
function pickFreeLobbyThemeIndex(space, excludePeerId) {
const taken = getTakenLobbyThemeIndices(space, excludePeerId);
for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
if (!taken.has(i)) return i;
}
const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
return ((n - 1) % LOBBY_THEME_COUNT) + 1;
}
function pickLobbySkinIndexForSlot(slotIndex) {
const s = Number(slotIndex);
if (!Number.isFinite(s)) return 1;
return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
}
/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น
taken = สีของ "คนจริง" เท่านั้น แล้วสะสมสีบอทที่จัดไปแล้ว (ไม่รวม pin ของ slot ตัวเอง)
→ pin เดิมที่ valid + ไม่ชนคน/บอทอื่น = คงเดิม (ไม่เปลี่ยนสีมั่วทุก sync); ชนเมื่อไหร่ค่อยเลือกใหม่
แก้ 2 อย่าง: (ก) เดิม getTakenLobbyThemeIndices รวม pin ของ slot ตัวเอง → re-pick เสมอ = สีบอท churn ทุก sync
(ข) "เงาสะท้อนของ user" = บอทสีตรงคน (เช่น bot ทดแทนคนที่ออก pin สีคนนั้นไว้) พอคนนั้นกลับมา → บอท re-pick พ้นสีคน */
function syncLobbyBotThemeSlots(space) {
if (!space) return [];
const botCount = effectiveBotSlotCount(space);
if (!space.lobbyBotThemeBySlot || typeof space.lobbyBotThemeBySlot !== 'object') {
space.lobbyBotThemeBySlot = {};
}
const assigned = new Set();
space.peers.forEach((p) => {
const ti = parseInt(p.lobbyColorThemeIndex, 10);
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) assigned.add(ti);
});
const pickFree = () => {
for (let t = 1; t <= LOBBY_THEME_COUNT; t++) { if (!assigned.has(t)) return t; }
return (assigned.size % LOBBY_THEME_COUNT) + 1;
};
const out = [];
for (let i = 0; i < botCount; i++) {
const slot = space.lobbyBotThemeBySlot[i];
let themeIndex = parseInt(slot && slot.themeIndex, 10);
if (!(themeIndex >= 1 && themeIndex <= LOBBY_THEME_COUNT) || assigned.has(themeIndex)) {
themeIndex = pickFree();
}
assigned.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),
/* ส่งจำนวนบอทมาด้วย — ให้ client ที่นั่งอยู่ใน LobbyB อยู่แล้ว (ไม่ได้ re-join) เห็นบอทที่มาแทนคนออกทันที */
botSlotCount: effectiveBotSlotCount(space),
});
}
/* ===== Game Log (รวม 7 มินิเกม + LobbyA/B) → /var/www/html/gamelog/data/log.jsonl อ่านที่ /gamelog ===== */
const GAMELOG_DIR = path.join(__dirname, '..', 'gamelog', 'data');
const GAMELOG_FILE = path.join(GAMELOG_DIR, 'log.jsonl');
try { fs.mkdirSync(GAMELOG_DIR, { recursive: true }); } catch (eMk) { /* ignore */ }
function gameLogCategoryOf(space) {
if (!space) return 'อื่นๆ';
try {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
const gt = md && md.gameType;
const m = { quiz: 'MG1', gauntlet: 'MG2', stack: 'MG3', quiz_carry: 'MG4', jump_survive: 'MG5', space_shooter: 'MG6', balloon_boss: 'MG7' };
if (gt && m[gt]) return m[gt];
if (serverMapIsPostCaseLobbyB(space)) return 'LobbyB';
return 'LobbyA';
} catch (e) { return 'อื่นๆ'; }
}
function glog(spaceId, space, ev, detail, who) {
try {
const rec = {
t: Date.now(),
cat: gameLogCategoryOf(space),
room: String((space && space.spaceName) || spaceId || '').slice(0, 64),
who: String(who || '').slice(0, 48),
ev: String(ev || '').slice(0, 48),
detail: detail == null ? '' : String(detail).slice(0, 500),
src: 'server',
};
fs.appendFile(GAMELOG_FILE, JSON.stringify(rec) + '\n', () => {});
} catch (e) { /* ignore */ }
}
/** เก็บกวาด peer ซ้ำ (ghost / "เงาสะท้อนของ user") ออกจาก space.peers ทั้งก้อน — กันกรณี dedup ต่อ-join พลาด/race
จน peer คนเดิมค้าง 2 ตัว → snapshot ส่งไปทุกจอ = เห็นตัวซ้ำ + MG3 คิวตามี seat ผี = เกมค้าง.
เทียบ playerKey ก่อน (เชื่อถือสุด ข้าม reconnect) แล้วชื่อ custom; เก็บตัวที่อยู่ท้าย Map (เพิ่ง join/active ล่าสุด) */
function pruneDuplicateSpacePeers(spaceId, space) {
if (!space || !space.peers || space.peers.size < 2) return false;
const validKey = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : '';
const isCustom = (nn) => nn && nn !== 'ผู้เล่น' && nn.indexOf('ผู้เล่น') !== 0 && nn.indexOf('บอท') !== 0;
const lastByKey = new Map();
const lastByNickNoKey = new Map(); /* ชื่อ → pid เฉพาะ peer ที่ "ไม่มี playerKey" (ก้ำกึ่งว่าเป็น ghost) */
space.peers.forEach((p, pid) => {
if (!p) return;
const pk = validKey(p);
if (pk) { lastByKey.set(pk, pid); return; } /* มี key → ไม่เข้า nickname dedup */
const nn = normalizeLobbyNickname(p.nickname);
if (isCustom(nn)) lastByNickNoKey.set(nn, pid);
});
const remove = [];
space.peers.forEach((p, pid) => {
if (!p) return;
const pk = validKey(p);
if (pk) {
/* มี playerKey → ลบเฉพาะตัวที่ key เดียวกันแต่ไม่ใช่ตัวล่าสุด (= ghost reconnect จริง)
⚠️ ไม่ลบด้วยชื่อ: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละ playerKey = คนละคน ต้องเก็บทั้งคู่ */
if (lastByKey.get(pk) !== pid) remove.push(pid);
return;
}
/* ไม่มี playerKey → dedup ชื่อ custom ได้ (เทียบเฉพาะกลุ่มไม่มี key ด้วยกัน) */
const nn = normalizeLobbyNickname(p.nickname);
if (isCustom(nn) && lastByNickNoKey.get(nn) !== pid) remove.push(pid);
});
if (!remove.length) return false;
remove.forEach((pid) => {
const gp = space.peers.get(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 });
glog(spaceId, space, 'ghost-prune', 'ลบตัวซ้ำ id=' + String(pid).slice(0, 6), (gp && gp.nickname) || '');
});
return true;
}
/** จำนวน 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; }
}
/* จำนวนผู้เล่น/ห้อง/socket ออนไลน์ตอนนี้ — โชว์ในหน้า Admin เทียบกับเพดานความจุ */
function getOnlineStats() {
let players = 0, rooms = 0;
try {
for (const [, sp] of spaces) {
if (sp && sp.peers) { players += sp.peers.size; rooms++; }
}
} catch (e) { /* ignore */ }
let sockets = 0;
try { sockets = (io && io.engine && io.engine.clientsCount) || 0; } catch (e) { /* ignore */ }
return { sockets, players, rooms };
}
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()) },
/* ผู้ใช้ออนไลน์ + เพดานความจุ (ประเมินจาก 2 vCPU / socket fan-out): สบาย ≤300 · เริ่มหนัก ≥500 */
online: getOnlineStats(),
capacity: { comfortable: 300, strain: 500 },
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;
/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน (roster คดี/LobbyB)
⚠️ ห้ามตั้ง 0: เปลี่ยนหน้า lobbyB→play→lobbyB (socket หลุดชั่วคราว) จะถูกถอดจาก roster → rejoin ไม่ได้ ("ห้องนี้เริ่มคดีแล้ว")
หมายเหตุ: bot takeover "ในเกม MG1" (sess.bots) เป็นทันทีอยู่แล้ว แยกจาก grace นี้ */
const DISCONNECT_GRACE_SEC = 10;
/** โหมดเทสต์ (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 };
}
}
/* jump_survive (กระโดด mnptfts2): ผู้เล่นยืนบน "แพลตฟอร์ม" — พื้นที่ว่างนับ walkable ผิด (ลอยกลางอากาศเก็บไม่ได้)
ไอคอนคลิก/แตะเก็บได้ทุกมินิเกม (trySpecialQuizClickAt) → วางบนแพลตฟอร์มใกล้จุดเกิด (อยู่ในจอตั้งแต่ต้น คลิกได้)
= แก้ #44 อีเวนต์พิเศษ (ทนาย/เทพ) ไม่ขึ้นเพราะ BFS หา walkable ไม่เจอ → return null */
if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) {
const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) };
const bx = Math.floor(Number(sp.x));
const by = Math.floor(Number(sp.y));
const plats = [];
for (let y = 0; y < h; y++) {
const row = md.jumpSurvivePlatformArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x]) plats.push({ x, y });
}
}
if (plats.length) {
plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by)));
const near = plats.slice(0, Math.min(8, plats.length));
const p = near[Math.floor(Math.random() * near.length)];
const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */
return { x: p.x + 0.5, y: iy + 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 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง (1 ใบต่อครั้งที่ติด — ดีไซน์ "1 ใน 3")
Test Mode (testSpecialCardByMap / forceSpecialQuiz) → ข้าม roll ขึ้นเสมอ */
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);
}
/* เกมหยุดชั่วคราวระหว่างคำถามพิเศษ (กำลังตอบ หรือรอกด «เล่นต่อ»)
ใช้ร่วมกันทุก minigame ฝั่ง server — เดิมมีแต่ gauntlet เช็ค ทำให้ MG6/MG7 บอทยังเดิน/ยิงตอน pause */
function isSpecialQuizPausedServer(space) {
if (!space) return false;
return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done));
}
function startSpecialQuizSession(sid, space) {
const sq = space.specialQuiz;
if (!sq || sq.triggered || sq.done) return false;
const settings = loadQuizSettings();
const set = getSpecialQuizSet(settings, sq.level, sq.caseId);
const all = (set && Array.isArray(set.questions) ? set.questions : [])
.map(specialQuizNormalizeQuestion)
.filter(Boolean);
if (!all.length) return false;
/* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */
const picked = specialQuizShuffle(all).slice(0, 1);
sq.triggered = true;
sq.session = {
questions: picked,
qIndex: 0,
answers: {},
phaseEndsAt: 0,
resolving: false,
roster: buildSpecialQuizRoster(space),
};
clearSpecialQuizTimers(space);
pauseQuizForSpecialQuiz(sid, space);
io.to(sid).emit('special-quiz-icon-clear', {});
sendSpecialQuizQuestion(sid, space);
return true;
}
function defaultQuizSettings() {
return {
readMs: 10000,
answerMs: 5000,
betweenMs: 3500,
/** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */
carryReadMs: 3000,
/** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */
carryAnswerMs: 5000,
/** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */
carrySessionLength: 0,
/** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */
carryMapPanelTheme: defaultCarryMapPanelTheme(),
/** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */
quizMapPanelTheme: defaultCarryMapPanelTheme(),
/** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */
carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(),
/** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 116 */
carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
/** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
/** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
carryChoicePlaqueMapScale: 1.25,
/** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */
carryWalkSpeedMultForMapId: '',
carryWalkSpeedMult: null,
/** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */
quizRoundQuestionCount: 10,
questions: [],
carryQuestions: [],
battleQuizMcq: [],
/** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */
specialQuizSets: sanitizeSpecialQuizSets({}),
};
}
function defaultCarryMapPanelTheme() {
return {
panelBg: 'rgba(12, 14, 28, 0.88)',
panelBorder: 'rgba(255, 214, 102, 0.7)',
borderWidthPx: 2,
textColor: '#f1f5f9',
questionFontMinPx: 10,
questionFontMaxPx: 24,
};
}
function sanitizeCssColorToken(input, fallback) {
const t = String(input == null ? '' : input).trim().slice(0, 120);
if (!t) return fallback;
if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback;
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
if (/^rgba?\(\s*[^)]+\)$/i.test(t)) {
const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, '');
if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim();
}
return fallback;
}
function sanitizeCarryMapPanelTheme(raw) {
const d = defaultCarryMapPanelTheme();
if (!raw || typeof raw !== 'object') return d;
let qMin = Number(raw.questionFontMinPx);
let qMax = Number(raw.questionFontMaxPx);
if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx;
if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx;
qMin = Math.round(Math.max(10, Math.min(40, qMin)));
qMax = Math.round(Math.max(14, Math.min(56, qMax)));
if (qMax < qMin) {
const t = qMin;
qMin = qMax;
qMax = t;
}
return {
panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg),
panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder),
borderWidthPx: (() => {
let w = Number(raw.borderWidthPx);
if (!Number.isFinite(w)) w = d.borderWidthPx;
return Math.max(0, Math.min(12, Math.round(w)));
})(),
textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
questionFontMinPx: qMin,
questionFontMaxPx: qMax,
};
}
/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
function defaultCarryChoicePlaqueTheme() {
return {
borderMode: 'neon',
fixedBorder: 'rgba(122, 200, 255, 0.9)',
fillBg: 'rgba(12, 10, 20, 0.88)',
textColor: 'rgba(248, 249, 255, 1)',
borderWidthPx: 2.5,
};
}
function sanitizeCarryChoicePlaqueTheme(raw) {
const d = defaultCarryChoicePlaqueTheme();
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
let bw = Number(raw.borderWidthPx);
if (!Number.isFinite(bw)) bw = d.borderWidthPx;
bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
return {
borderMode: mode,
fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
borderWidthPx: bw,
plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
};
}
const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
function defaultCarryChoicePlaqueThemes() {
const out = [];
for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
out.push(sanitizeCarryChoicePlaqueTheme({}));
}
return out;
}
/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
function sanitizeCarryChoicePlaqueThemes(raw) {
if (Array.isArray(raw) && raw.length > 0) {
const out = [];
for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
}
return out;
}
if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
const one = sanitizeCarryChoicePlaqueTheme(raw);
return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
}
return defaultCarryChoicePlaqueThemes();
}
function sanitizeCarryChoiceImageUrl(input) {
const s = String(input == null ? '' : input).trim().slice(0, 512);
if (!s) return '';
if (/[\s<>'"`]/.test(s)) return '';
if (s.startsWith('/')) {
if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
return s;
}
const low = s.toLowerCase();
if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
try {
const u = new URL(s);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
return s;
} catch (e) {
return '';
}
}
function defaultCarryEmbedCountdownTheme() {
return {
overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
innerBg: 'rgba(0, 0, 0, 0.78)',
innerBorder: 'rgba(94, 234, 212, 0.82)',
innerBorderWpx: 1,
innerRadiusPx: 14,
digitColor: '#ffe066',
mapDigitCqmin: 78,
mapDigitCqh: 82,
mapDigitMaxPx: 220,
screenDigitVw: 32,
screenDigitMaxPx: 200,
};
}
function sanitizeCarryEmbedCountdownTheme(raw) {
const d = defaultCarryEmbedCountdownTheme();
if (!raw || typeof raw !== 'object') return d;
const clampN = (v, lo, hi, def) => {
const n = Number(v);
if (!Number.isFinite(n)) return def;
return Math.max(lo, Math.min(hi, Math.round(n)));
};
return {
overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
};
}
/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
const QUIZ_BATTLE_CATEGORY_IDS = new Set([
'topic1', 'topic2', 'topic3', 'topic4', 'topic5',
'topic6', 'topic7', 'topic8', 'topic9', 'topic10',
]);
/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
function sanitizeBattleQuizMcq(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const q of arr) {
if (!q || typeof q !== 'object') continue;
let categoryId = String(q.categoryId || '').trim();
if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
categoryId = 'topic1';
}
const text = String(q.text || '').trim().slice(0, 500);
if (!text) continue;
const rawChoices = Array.isArray(q.choices) ? q.choices : [];
const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
if (choices.length !== 3 || !choices.every(Boolean)) continue;
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(2, Math.floor(ci)));
out.push({ categoryId, text, choices, correctIndex: ci });
if (out.length >= 200) break;
}
return out;
}
/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
function sanitizeCarryQuestions(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const q of arr) {
if (!q || typeof q !== 'object') continue;
const text = String(q.text || '').trim().slice(0, 500);
if (!text) continue;
let choices = Array.isArray(q.choices)
? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
: [];
if (choices.length < 2) continue;
if (choices.length > 16) choices = choices.slice(0, 16);
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
const row = { text, choices, correctIndex: ci };
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
if (urls.some((u) => u)) row.choiceImageUrls = urls;
}
out.push(row);
if (out.length >= 80) break;
}
return out;
}
function clampQuizMs(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(1000, Math.min(300000, Math.floor(v)));
}
function clampQuizBetweenMs(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(300000, Math.floor(v)));
}
function clampCarrySessionLength(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(500, Math.floor(v)));
}
/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
function clampQuizRoundQuestionCount(n, def) {
const v = Number(n);
if (!Number.isFinite(v)) {
const d = Number(def);
if (!Number.isFinite(d)) return 10;
return Math.max(1, Math.min(50, Math.floor(d)));
}
return Math.max(1, Math.min(50, Math.floor(v)));
}
function clampCarryChoicePlaqueMapScale(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
}
/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
function sanitizeCarryWalkSpeedMultForMapId(raw) {
const t = String(raw == null ? '' : raw).trim().slice(0, 64);
if (!t) return '';
if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
return t;
}
/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
function clampCarryWalkSpeedMultSetting(n) {
if (n === null || n === undefined || n === '') return null;
const v = Number(n);
if (Number.isNaN(v)) return null;
return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
}
function mergeQuizCarryFromMirrorIfSet(base) {
if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
try {
if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
const j = JSON.parse(raw);
if (!j || typeof j !== 'object') return base;
if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
}
if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
}
if (j.carryReadMs != null) {
base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
}
if (j.carryAnswerMs != null) {
base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
}
if (j.carrySessionLength != null) {
base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
}
if (j.carryWalkSpeedMultForMapId != null) {
base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
}
if (j.carryWalkSpeedMult != null) {
const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
if (wm != null) base.carryWalkSpeedMult = wm;
}
if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
}
if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
} else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
}
if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
}
if (j.quizRoundQuestionCount != null) {
base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
}
return base;
} catch (e) {
console.error('mergeQuizCarryFromMirrorIfSet', e.message);
return base;
}
}
/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
const EVIDENCE_CASE_COUNT = 15;
/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
const CASE_MEDIA_COUNT = 15;
/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
function caseMediaDefault(n) {
const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png');
const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png');
return {
name: 'คดีที่ ' + n,
art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
story: [
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
],
suspects,
culprits,
};
}
function sanitizeCaseMediaUrlList(arr, fallbackArr) {
const fb = Array.isArray(fallbackArr) ? fallbackArr : [];
const src = Array.isArray(arr) ? arr : [];
return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || ''));
}
/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
function sanitizeCaseMediaUrl(v, fallback) {
if (typeof v !== 'string') return fallback;
const s = v.trim();
if (!s) return fallback;
if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
return s;
}
function sanitizeCaseMedia(obj) {
const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
const cases = {};
for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
const d = caseMediaDefault(n);
const c = src[n] || src[String(n)] || {};
const story = Array.isArray(c.story) ? c.story : [];
cases[n] = {
name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
art: sanitizeCaseMediaUrl(c.art, d.art),
detail: sanitizeCaseMediaUrl(c.detail, d.detail),
story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
};
}
return { cases };
}
function loadCaseMedia() {
try {
if (fs.existsSync(CASE_MEDIA_PATH)) {
return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
}
} catch (e) { /* ignore — คืนค่า default */ }
return sanitizeCaseMedia({});
}
function saveCaseMedia(d) {
try {
const clean = sanitizeCaseMedia(d);
fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
return { ok: true, cases: clean.cases };
} catch (e) {
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
function listCaseMediaImages() {
const out = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] };
['case', 'cutscene', 'suspect-pick', 'culprit-prison'].forEach((sub) => {
try {
const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
out[sub] = fs.readdirSync(p)
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
});
return out;
}
const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
const EVIDENCE_RARITY_RANGES = [
{ rarity: 'common', min: 1, max: 22, stars: 1 },
{ rarity: 'rare', min: 23, max: 44, stars: 2 },
{ rarity: 'legendary', min: 45, max: 54, stars: 3 },
];
function evidenceRarityForNum(n) {
for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
return EVIDENCE_RARITY_RANGES[0];
}
function evidenceCardImageDefault(suspectIdx, cardIdx) {
return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
}
/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
let _evidencePoolCache = null;
let _evidencePoolCacheAt = 0;
function evidenceImagePools() {
const now = Date.now();
if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
const pools = {};
EVIDENCE_IMAGE_DIRS.forEach((dir) => {
const buckets = { common: [], rare: [], legendary: [] };
try {
const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
fs.readdirSync(p).forEach((f) => {
const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
if (!m) return;
const num = parseInt(m[1], 10);
const rr = evidenceRarityForNum(num);
buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
});
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
pools[dir] = buckets;
});
_evidencePoolCache = pools;
_evidencePoolCacheAt = now;
return pools;
}
function evidencePoolCounts() {
const pools = evidenceImagePools();
const out = {};
EVIDENCE_IMAGE_DIRS.forEach((dir) => {
const b = pools[dir] || { common: [], rare: [], legendary: [] };
out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
});
return out;
}
/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
function rollEvidenceRarityKey(grade) {
const r = Math.random();
if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
}
/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
function evidenceImageDirForSuspect(space, suspectIndex) {
const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
try {
const cid = String((space && space.caseId) || '1');
const cfg = loadEvidenceCards();
const cse = cfg.cases && cfg.cases[cid];
const s = cse && cse.suspects && cse.suspects[suspectIndex];
if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
} catch (e) { /* ใช้ default */ }
return def;
}
/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
const pools = evidenceImagePools();
const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
let want = rollEvidenceRarityKey(grade || 'C');
let pool = buckets[want];
if (!pool || !pool.length) {
const tryOrder = ['common', 'rare', 'legendary'];
for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
}
if (!pool || !pool.length) return null;
const pick = pool[Math.floor(Math.random() * pool.length)];
return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
}
function evidenceGradeFromPct(avgPct) {
return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
}
/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
function evidencePlayerRawScore(space, p, gameType) {
switch (gameType) {
case 'gauntlet':
case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
default:
return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
}
}
/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
function computeRunGradeForEvidence(space) {
try {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
const gameType = md && md.gameType;
/* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C
quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */
if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
const ids = Object.keys(space.quizSession.players);
if (total > 0 && ids.length) {
const avgPct = ids.reduce((s, id) => {
const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
return s + Math.min(100, (sc / total) * 100);
}, 0) / ids.length;
return evidenceGradeFromPct(avgPct);
}
}
/* Last Light (crown gauntlet): ใช้เกรดเดียวกับ mission payload (รวมบอท) — แหล่งเดียวกับที่แสดง
F (ไม่มีผู้รอด) ไม่มี tier ในหลักฐาน → map เป็น C */
if (gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space) && space.lastCrownMissionGrade) {
const g = space.lastCrownMissionGrade;
return (g === 'A' || g === 'B') ? g : 'C';
}
const peers = [...space.peers.values()];
if (!peers.length) return 'C';
const raw = peers.map((p) => evidencePlayerRawScore(space, p, gameType));
let avgPct;
if (gameType === 'balloon_boss') {
/* MG7: เอาคะแนนทุกคนมาเฉลี่ย = ระดับความสำเร็จ (0-100) โดยตรง — ไม่ normalize เทียบคนคะแนนสูงสุด
(เดิม normalize ทำให้คะแนนต่ำ เช่น 15 เฟ้อเป็นเกรดสูง → ได้การ์ดชี้คนร้ายผิด) */
const avgRaw = raw.reduce((s, v) => s + v, 0) / raw.length;
avgPct = Math.min(100, Math.max(0, avgRaw));
} else {
const max = Math.max(1, ...raw);
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 'quiz_carry': {
/* MG4 server-auth: คะแนนจาก session (server เป็นเจ้าของ) — ทุกคนถือว่ารอด (ไม่มีตกรอบ) */
const sc = space.quizCarrySession && space.quizCarrySession.players ? space.quizCarrySession.players[p.id] : null;
if (sc) return { score: Math.max(0, Number(sc.score) | 0), survived: true };
return { score: Math.max(0, p.reportedMiniScore | 0), survived: true };
}
case 'gauntlet':
return { score: Math.max(0, p.gauntletScore | 0), survived: !p.gauntletEliminated };
case 'jump_survive': {
/* MG5 server-auth: ใช้สถานะตายจาก session (server เป็นเจ้าของ) ถ้ามี — ไม่งั้น fallback ค่าที่ client รายงานตอนจบ */
const sj = space.jumpSurviveSession;
if (sj && sj.players && Object.prototype.hasOwnProperty.call(sj.players, p.id)) {
const dead = !!(sj.players[p.id] && sj.players[p.id].eliminated);
return { score: dead ? 0 : 100, survived: !dead };
}
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 = 6;
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 < 8; i++) { /* 8 slot ตาม lobby theme (#4) — เดิม 6 */
out.push(sanitizeGauntletAssetUrl(arr[i]));
}
return out;
}
function clampStackHeavyBlockPercent(n) {
const v = Number(n);
if (Number.isNaN(v)) return 35;
return Math.max(0, Math.min(100, Math.floor(v)));
}
function clampJumpSurviveJumpHeightMult(n) {
const v = Number(n);
if (Number.isNaN(v)) return 1.5;
return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100;
}
/** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 107200 */
function clampJumpSurviveMissionTimeSec(n) {
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return 0;
return Math.max(10, Math.min(7200, Math.floor(v)));
}
/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */
function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) {
let arr = val;
if (typeof arr === 'string') {
try {
const p = JSON.parse(arr);
if (Array.isArray(p)) arr = p;
} catch (e) {
arr = [];
}
}
if (!Array.isArray(arr)) arr = [];
const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : '';
const out = [];
for (let i = 0; i < 3; i++) {
const o = arr[i];
const obj = o && typeof o === 'object' ? o : {};
let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : '');
if (i === 0 && !url && legacy) url = legacy;
let ww = Number(obj.w);
let hh = Number(obj.h);
if (!Number.isFinite(ww) || ww <= 0) ww = 0;
else ww = Math.max(8, Math.min(4096, Math.round(ww)));
if (!Number.isFinite(hh) || hh <= 0) hh = 0;
else hh = Math.max(8, Math.min(4096, Math.round(hh)));
out.push({ url, w: ww, h: hh });
}
return out;
}
function clampSpaceShooterMissionTimeSec(n) {
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return 0;
return Math.max(10, Math.min(7200, Math.floor(v)));
}
/** รูปยาน space_shooter ช่อง 16 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */
function normalizeSpaceShooterShipImageUrls(val) {
let arr = val;
if (typeof arr === 'string') {
try {
const p = JSON.parse(arr);
if (Array.isArray(p)) arr = p;
} catch (e) {
arr = arr.split(/[\n\r]+/);
}
}
if (!Array.isArray(arr)) arr = [];
const out = [];
for (let i = 0; i < 6; i++) {
out.push(sanitizeGauntletAssetUrl(arr[i]));
}
return out;
}
/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */
function normalizeSpaceShooterAsteroidSpriteUrls(val) {
let arr = val;
if (typeof arr === 'string') {
try {
const p = JSON.parse(arr);
if (Array.isArray(p)) arr = p;
} catch (e) {
arr = arr.split(/[\n\r]+/);
}
}
if (!Array.isArray(arr)) arr = [];
const out = [];
for (let i = 0; i < arr.length && out.length < 32; i++) {
const u = sanitizeGauntletAssetUrl(arr[i]);
if (u) out.push(u);
}
return out;
}
function clampSpaceShooterAsteroidExplodeFrameMs(n) {
const v = Number(n);
if (Number.isNaN(v)) return 70;
return Math.max(30, Math.min(500, Math.round(v)));
}
const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040;
function clampSpaceShooterAsteroidIntervalMs(n) {
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
return Math.max(200, Math.min(10000, Math.floor(v)));
}
/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */
function normalizeSpaceShooterShipDamageOverlayUrls(val) {
let arr = val;
if (typeof arr === 'string') {
try {
const p = JSON.parse(arr);
if (Array.isArray(p)) arr = p;
} catch (e) {
arr = arr.split(/[\n\r]+/);
}
}
if (!Array.isArray(arr)) arr = [];
const out = ['', '', ''];
for (let i = 0; i < 3; i++) {
out[i] = sanitizeGauntletAssetUrl(arr[i]);
}
return out;
}
function clampGauntletTickMs(n) {
const v = Number(n);
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
return Math.max(80, Math.min(800, Math.floor(v)));
}
function clampGauntletJumpTicks(n) {
const v = Number(n);
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
return Math.max(4, Math.min(40, Math.floor(v)));
}
/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */
function clampGauntletTimeLimitSec(n) {
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return 0;
return Math.max(10, Math.min(7200, Math.floor(v)));
}
function defaultGauntletVisuals() {
return {
gauntletLaneImageUrls: [],
gauntletLaserTopUrl: '',
gauntletLaserBottomUrl: '',
gauntletLaserLineUrl: '',
gauntletLaserFillColor: 'rgba(140,230,255,0.42)',
gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)',
gauntletLaserLineWidthPx: 2,
};
}
/** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */
function encodeSpacesInAssetUrlPath(t) {
const s = String(t || '');
const q = s.indexOf('?');
const pathPart = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
return pathPart.replace(/ /g, '%20') + query;
}
/** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */
function normalizeGameAssetUrlForWeb(t) {
let s = String(t || '');
s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
return s;
}
/** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */
function decodeAssetUrlPercentRuns(t) {
let s = String(t || '');
const q = s.indexOf('?');
let base = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
for (let i = 0; i < 2; i += 1) {
try {
const d = decodeURIComponent(base);
if (d === base) break;
base = d;
} catch (e) {
break;
}
}
return base + query;
}
/** ช่อง Admin มักใส่แค่ …/Artboard ลืม 9.png — ชี้ไป Artboard%209.png */
function fixBareMegaVirusArtboardUrl(t) {
const s = String(t || '').trim().replace(/\/+$/, '');
if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png';
if (/^Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png';
return t;
}
function sanitizeGauntletAssetUrl(s) {
const t = fixBareMegaVirusArtboardUrl(
normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, '')
);
if (!t || t.length > 500) return '';
/** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */
if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return '';
/** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */
if (/\.{4,}/.test(t)) return '';
if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t);
if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t);
/** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */
if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`);
return '';
}
function clampGauntletLaserColor(s, def) {
const t = String(s || '').trim().slice(0, 100);
if (!t) return def;
if (/[<>"'`]/.test(t)) return def;
return t;
}
function clampGauntletLaserLineWidthPx(n) {
const v = Number(n);
if (Number.isNaN(v)) return 2;
return Math.max(0, Math.min(24, Math.round(v)));
}
function normalizeGauntletVisualsFromRequest(d) {
const def = defaultGauntletVisuals();
if (!d || typeof d !== 'object') return { ...def };
let laneArr = d.gauntletLaneImageUrls;
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
if (!Array.isArray(laneArr)) laneArr = [];
const gauntletLaneImageUrls = [];
for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) {
const u = sanitizeGauntletAssetUrl(laneArr[i]);
if (u) gauntletLaneImageUrls.push(u);
}
return {
gauntletLaneImageUrls,
gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl),
gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl),
gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl),
gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor),
gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor),
gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx),
};
}
function getGauntletVisualsForClient() {
const r = runtimeGameTiming;
const d = defaultGauntletVisuals();
return {
gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls,
gauntletLaserTopUrl: r.gauntletLaserTopUrl || '',
gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '',
gauntletLaserLineUrl: r.gauntletLaserLineUrl || '',
gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor,
gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor,
gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx,
};
}
function loadGameTiming() {
try {
if (fs.existsSync(GAME_TIMING_PATH)) {
const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8'));
const vis = normalizeGauntletVisualsFromRequest(j);
return {
gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
...vis,
stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz')
? clampStackSwingHz(j.stackSwingHz)
: STACK_SWING_HZ_DEFAULT,
stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles')
? clampStackBlockWidthTiles(j.stackBlockWidthTiles)
: null,
stackTowerMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'stackTowerMissionTimeSec')
? clampStackTowerMissionTimeSec(j.stackTowerMissionTimeSec)
: 90,
stackTeamMissesMax: Object.prototype.hasOwnProperty.call(j, 'stackTeamMissesMax')
? clampStackTeamMissesMax(j.stackTeamMissesMax)
: 3,
stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks')
? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks)
: 50,
stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls),
stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls),
stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent')
? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent)
: 35,
jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult')
? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult)
: 1.5,
jumpSurviveMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveMissionTimeSec')
? clampJumpSurviveMissionTimeSec(j.jumpSurviveMissionTimeSec)
: 0,
jumpSurvivePlatformTiles: normalizeJumpSurvivePlatformTilesFromTiming(
j.jumpSurvivePlatformTiles,
j.jumpSurvivePlatformTileUrl,
),
spaceShooterMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'spaceShooterMissionTimeSec')
? clampSpaceShooterMissionTimeSec(j.spaceShooterMissionTimeSec)
: 0,
spaceShooterShipImageUrls: normalizeSpaceShooterShipImageUrls(j.spaceShooterShipImageUrls),
spaceShooterAsteroidSpriteUrls: normalizeSpaceShooterAsteroidSpriteUrls(j.spaceShooterAsteroidSpriteUrls),
spaceShooterAsteroidExplodeFrameMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidExplodeFrameMs')
? clampSpaceShooterAsteroidExplodeFrameMs(j.spaceShooterAsteroidExplodeFrameMs)
: 70,
spaceShooterAsteroidIntervalMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidIntervalMs')
? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs)
: SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT,
spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls),
balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec')
? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec)
: 0,
balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl)
: '',
balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls),
balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl)
: '',
balloonBossBalloonsPerPlayer: Object.prototype.hasOwnProperty.call(j, 'balloonBossBalloonsPerPlayer')
? Math.max(0, Math.min(12, Math.floor(Number(j.balloonBossBalloonsPerPlayer)) || 0))
: 0,
forcedMinigameKeys: normalizeForcedMinigameKeys(
j.forcedMinigameKeys != null ? j.forcedMinigameKeys : j.forcedMinigameKey,
),
testSpecialCardByMap: normalizeTestSpecialCardByMap(j.testSpecialCardByMap),
troublesomeForceOffer: !!j.troublesomeForceOffer,
specialQuizIconExpireSec: Object.prototype.hasOwnProperty.call(j, 'specialQuizIconExpireSec')
? Math.max(0, Math.min(120, Math.floor(Number(j.specialQuizIconExpireSec)) || 0))
: 10,
pickVoteSec: Object.prototype.hasOwnProperty.call(j, 'pickVoteSec')
? Math.max(5, Math.min(120, Math.floor(Number(j.pickVoteSec)) || 25))
: 25,
trialVoteSec: Object.prototype.hasOwnProperty.call(j, 'trialVoteSec')
? Math.max(5, Math.min(180, Math.floor(Number(j.trialVoteSec)) || 30))
: 30,
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
return defaultGameTiming();
}
let runtimeGameTiming = loadGameTiming();
/** จำนวนลูกโป่งต่อคน Mega Virus — แมปกำหนดก่อน, ไม่งั้นใช้ค่ากลางจาก admin, สุดท้าย default 3 (clamp 1-12) */
function balloonBossBalloonsForMap(md) {
const fromMap = Math.floor(Number(md && md.balloonBossBalloonsPerPlayer)) || 0;
const fromCfg = Math.floor(Number(runtimeGameTiming && runtimeGameTiming.balloonBossBalloonsPerPlayer)) || 0;
return Math.max(1, Math.min(12, fromMap || fromCfg || 3));
}
function getGauntletTickMs() {
return runtimeGameTiming.gauntletTickMs;
}
function getGauntletJumpTicks() {
return runtimeGameTiming.gauntletJumpTicks;
}
function getGauntletTimeLimitSec() {
return runtimeGameTiming.gauntletTimeLimitSec || 0;
}
function saveGameTimingToFile(d) {
try {
const dir = path.dirname(GAME_TIMING_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const prev = { ...runtimeGameTiming };
const vis = normalizeGauntletVisualsFromRequest(d);
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
? clampStackSwingHz(d.stackSwingHz)
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles')
? clampStackBlockWidthTiles(d.stackBlockWidthTiles)
: (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles')
? prev.stackBlockWidthTiles
: null);
const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult')
? prev.jumpSurviveJumpHeightMult
: 1.5;
const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult')
? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult)
: clampJumpSurviveJumpHeightMult(prevMult);
const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec')
? prev.jumpSurviveMissionTimeSec
: 0;
const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec')
? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec)
: clampJumpSurviveMissionTimeSec(prevJumpMissionSec);
const prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming(
prev.jumpSurvivePlatformTiles,
prev.jumpSurvivePlatformTileUrl,
);
let jumpSurvivePlatformTiles;
if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) {
jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming(
d.jumpSurvivePlatformTiles,
d.jumpSurvivePlatformTileUrl,
);
} else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) {
jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming(
[
{ url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 },
prevJumpTiles[1],
prevJumpTiles[2],
],
'',
);
} else {
jumpSurvivePlatformTiles = prevJumpTiles;
}
const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec')
? prev.spaceShooterMissionTimeSec
: 0;
const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec')
? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec)
: clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec);
const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls)
? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls)
: normalizeSpaceShooterShipImageUrls([]);
const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls')
? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls)
: prevShipUrls;
const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls)
? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls)
: [];
const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls')
? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls)
: prevAstUrls;
const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs')
? prev.spaceShooterAsteroidExplodeFrameMs
: 70;
const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs')
? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs)
: clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms);
const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs')
? prev.spaceShooterAsteroidIntervalMs
: SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs')
? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs)
: clampSpaceShooterAsteroidIntervalMs(prevAstIv);
const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls)
? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls)
: normalizeSpaceShooterShipDamageOverlayUrls([]);
const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls')
? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls)
: prevDmg;
const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec')
? prev.balloonBossMissionTimeSec
: 0;
const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec')
? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec)
: clampSpaceShooterMissionTimeSec(prevBbMission);
const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl)
: '';
const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl)
: prevBbBoss;
const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls)
? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls)
: normalizeStackBlockSixImageUrls([]);
const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls')
? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls)
: prevBbBalloons;
const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl)
: '';
const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl)
: prevBbFb;
/* จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */
const prevBbCount = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBalloonsPerPlayer')
? Math.max(0, Math.min(12, Math.floor(Number(prev.balloonBossBalloonsPerPlayer)) || 0))
: 0;
const balloonBossBalloonsPerPlayer = Object.prototype.hasOwnProperty.call(d, 'balloonBossBalloonsPerPlayer')
? Math.max(0, Math.min(12, Math.floor(Number(d.balloonBossBalloonsPerPlayer)) || 0))
: prevBbCount;
const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec')
? prev.stackTowerMissionTimeSec
: 90;
const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec')
? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec)
: clampStackTowerMissionTimeSec(prevStackTowerSec);
const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax')
? prev.stackTeamMissesMax
: 3;
const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax')
? clampStackTeamMissesMax(d.stackTeamMissesMax)
: clampStackTeamMissesMax(prevStackMisses);
const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks')
? prev.stackTowerProgressBlocks
: 50;
const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks')
? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks)
: clampStackTowerProgressBlocks(prevStackProgBlocks);
const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls)
? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls)
: normalizeStackBlockSixImageUrls([]);
const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls)
? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls)
: normalizeStackBlockSixImageUrls([]);
const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls')
? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls)
: prevStackNorm;
const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls')
? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls)
: prevStackHeavy;
const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent')
? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent)
: 35;
const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent')
? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent)
: prevHeavyPct;
const out = {
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
...vis,
stackSwingHz: stackHz,
stackBlockWidthTiles: stackBlockW,
stackTowerMissionTimeSec,
stackTeamMissesMax,
stackTowerProgressBlocks,
stackBlockNormalImageUrls,
stackBlockHeavyImageUrls,
stackHeavyBlockPercent,
jumpSurviveJumpHeightMult: jumpMult,
jumpSurviveMissionTimeSec: jumpMissionSec,
jumpSurvivePlatformTiles,
spaceShooterMissionTimeSec: spaceShooterMissionSec,
spaceShooterShipImageUrls,
spaceShooterAsteroidSpriteUrls,
spaceShooterAsteroidExplodeFrameMs,
spaceShooterAsteroidIntervalMs,
spaceShooterShipDamageOverlayUrls,
balloonBossMissionTimeSec,
balloonBossBossImageUrl,
balloonBossPlayerBalloonImageUrls,
balloonBossPlayerBalloonFallbackUrl,
balloonBossBalloonsPerPlayer,
forcedMinigameKeys: Object.prototype.hasOwnProperty.call(d, 'forcedMinigameKeys')
? normalizeForcedMinigameKeys(d.forcedMinigameKeys)
: (Object.prototype.hasOwnProperty.call(d, 'forcedMinigameKey')
? normalizeForcedMinigameKeys(d.forcedMinigameKey)
: normalizeForcedMinigameKeys(prev.forcedMinigameKeys)),
testSpecialCardByMap: Object.prototype.hasOwnProperty.call(d, 'testSpecialCardByMap')
? normalizeTestSpecialCardByMap(d.testSpecialCardByMap)
: normalizeTestSpecialCardByMap(prev.testSpecialCardByMap),
troublesomeForceOffer: Object.prototype.hasOwnProperty.call(d, 'troublesomeForceOffer')
? !!d.troublesomeForceOffer
: !!prev.troublesomeForceOffer,
specialQuizIconExpireSec: Object.prototype.hasOwnProperty.call(d, 'specialQuizIconExpireSec')
? Math.max(0, Math.min(120, Math.floor(Number(d.specialQuizIconExpireSec)) || 0))
: (prev.specialQuizIconExpireSec != null ? Math.max(0, Math.min(120, Math.floor(Number(prev.specialQuizIconExpireSec)) || 0)) : 10),
pickVoteSec: Object.prototype.hasOwnProperty.call(d, 'pickVoteSec')
? Math.max(5, Math.min(120, Math.floor(Number(d.pickVoteSec)) || 25))
: (prev.pickVoteSec != null ? Math.max(5, Math.min(120, Math.floor(Number(prev.pickVoteSec)) || 25)) : 25),
trialVoteSec: Object.prototype.hasOwnProperty.call(d, 'trialVoteSec')
? Math.max(5, Math.min(180, Math.floor(Number(d.trialVoteSec)) || 30))
: (prev.trialVoteSec != null ? Math.max(5, Math.min(180, Math.floor(Number(prev.trialVoteSec)) || 30)) : 30),
/* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */
lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud')
? !!d.lobbyDebugHud
: !!prev.lobbyDebugHud,
lobbyDebugHudNames: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHudNames')
? (Array.isArray(d.lobbyDebugHudNames) ? d.lobbyDebugHudNames.slice(0, 30).map((s) => String(s).slice(0, 40)) : [])
: (Array.isArray(prev.lobbyDebugHudNames) ? prev.lobbyDebugHudNames : []),
};
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),
};
}
/* เช็คว่าพร้อมเริ่มมินิเกม crown-lobby ไหม: ยังไม่เริ่ม + เข้าครบ + ทุกคน (ไม่นับ banned) กด Ready */
function crownLobbyAllReadyToStart(space) {
const gr = space && space.gauntletRun;
if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false;
const rm = space.gauntletCrownLobbyReady || {};
const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id));
if (humanIds.length === 0) return false;
const expectedActive = space.minigameExpectedActiveCount || 0;
if (expectedActive > 0 && humanIds.length < expectedActive) return false;
return humanIds.every((id) => rm[id]);
}
/* เริ่มมินิเกม crown-lobby (server เป็นคนสั่ง) — idempotent ด้วย gr.lobbyStarted (รีเซ็ตเองทุกมินิเกมเพราะ gr สร้างใหม่)
ใช้ทั้ง host กด START และ auto-start เมื่อทุกคน ready (Track B: MG1 ไม่ต้องพึ่ง host) */
function performCrownLobbyStart(sid, space) {
const gr = space && space.gauntletRun;
if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false;
gr.lobbyStarted = true;
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);
}
if (isStackTowerMissionShellSpace(space)) {
/* MG3 stack: เริ่ม server engine ตอน client จบ countdown (ตรง liveBeginsAt) */
setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
const md2 = maps.get(sp.mapId) || sp.mapData;
if (md2 && md2.gameType === 'stack') startStackTicker(sid, sp);
}, LIVE_COUNTDOWN_MS);
}
if (isJumpSurviveMissionShellSpace(space)) {
/* MG5 jump_survive: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */
setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
const md2 = maps.get(sp.mapId) || sp.mapData;
if (md2 && md2.gameType === 'jump_survive') startJumpSurviveGame(sid, sp);
}, LIVE_COUNTDOWN_MS);
}
if (isSpaceShooterMissionShellSpace(space)) {
/* MG6 space_shooter: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */
setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
const md2 = maps.get(sp.mapId) || sp.mapData;
if (md2 && md2.gameType === 'space_shooter') startSpaceShooterGame(sid, sp);
}, LIVE_COUNTDOWN_MS);
}
if (isBalloonBossMissionShellSpace(space)) {
/* MG7 balloon_boss: เริ่ม server engine (บอท+รอบ+HP บอส) ตอน client จบ countdown */
setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
const md2 = maps.get(sp.mapId) || sp.mapData;
if (md2 && md2.gameType === 'balloon_boss') startBalloonBossGame(sid, sp);
}, LIVE_COUNTDOWN_MS);
}
io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt });
return true;
}
/** สุ่ม 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]);
/* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */
if (list.length >= 3) {
space.playerEvidence[playerId][suspectIndex] = list;
syncEvidenceByNick(space, playerId);
return null;
}
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;
space.lastCrownMissionGrade = null;
/* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */
space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; });
/* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */
space.lastMinigameOver = null;
/* 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;
glog(sid, space, 'game-start', 'เริ่มมินิเกม: ' + ((md && md.name) || mapId) + ' (การ์ด ' + ((selectedIndex != null ? selectedIndex + 1 : '?')) + ')');
/* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (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;
/* Last Light (MG2): เก็บเกรด mission (รวมบอท server-authoritative) ก่อนล้าง gauntletRun
→ ใช้เป็น "แหล่งเดียว" ของเกรดหลักฐาน ให้ตรงกับสรุปภารกิจที่ผู้เล่นเห็น */
if (space.gauntletRun && isGauntletCrownHeistMapBySpace(space)) {
try { space.lastCrownMissionGrade = buildGauntletCrownMissionPayload(space).grade; }
catch (_e) { space.lastCrownMissionGrade = null; }
}
stopGauntletTicker(space);
space.gauntletRun = null;
/* MG3 stack (server-auth): หยุด engine + ล้าง session (เกรดถูกเก็บไว้ใน lastStackMissionGrade ตอนจบแล้ว) */
stopStackTicker(space);
if (space.stackSession) space.stackSession.active = false;
space.stackSession = null;
/* MG4 quiz_carry (server-auth): ล้าง session (คะแนนถูกอ่านไปแจกเหรียญใน awardMinigameRankCoins ด้านบนแล้ว) */
if (space.quizCarrySession) space.quizCarrySession.active = false;
space.quizCarrySession = null;
/* MG5 jump_survive (server-auth): ล้าง session */
if (space.jumpSurviveSession) space.jumpSurviveSession.active = false;
space.jumpSurviveSession = null;
/* MG6 space_shooter (server-auth): ล้าง session */
if (space.spaceShooterSession) space.spaceShooterSession.active = false;
space.spaceShooterSession = null;
/* MG7 balloon_boss (server-auth): ล้าง session */
if (space.balloonBossSession) space.balloonBossSession.active = false;
space.balloonBossSession = 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);
/* คะแนนสุดท้าย authoritative (ผู้เล่นจริง) — ส่งไปกับ quiz-ended ให้ทุก client ตัดสินผล complete/gameover ตรงกัน
(เดิมไม่ส่ง → client อ่าน playLiveQuizScores local ที่ quiz-result ข้อสุดท้ายอาจมาไม่ทัน → ผล 2 จอไม่ตรง) */
const quizFinalScores = {};
const sessF = space.quizSession;
if (sessF && sessF.players) {
Object.keys(sessF.players).forEach((pid) => {
const st = sessF.players[pid];
quizFinalScores[pid] = (st && typeof st.score === 'number') ? Math.max(0, st.score) : 0;
});
}
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, scores: quizFinalScores });
/* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้
(ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */
return;
}
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', scores: quizFinalScores });
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);
/* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */
const qCur = sess.questions[sess.qIndex];
if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue);
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;
});
/* บอท: server เป็นเจ้าของคะแนน (จากคำตอบที่ตัดสินไว้) + คัดออกถ้าผิด (กฎเดียวกับคน "ตอบผิดถูกคัดออก") */
if (sess.bots) {
sess.bots.forEach((b, botId) => {
if (!b.eliminated) {
if (b.answerRight) {
b.score += QUIZ_TF_POINTS_PER_CORRECT;
} else {
b.eliminated = true;
/* คัดออก → วาร์ปเข้าโซนผีทันที (เหมือนคนจริง) */
const gc = quizRandomCellInZone(mdLive.quizGhostArea, mdLive);
if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; }
b.wanderTX = null; b.retargetAt = 0;
}
}
scores[botId] = b.score;
results.push({
id: botId, nickname: 'บอท', right: !!b.answerRight, choice: b.choice,
eliminated: !!b.eliminated, score: b.score,
});
b.tx = null; b.ty = null; /* รีเซ็ตเป้า → เดินมั่วจนข้อถัดไป */
});
}
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,
};
space.quizSession.bots = createQuizBots(space, md);
scheduleQuizReadPhase(sid, space, md);
}
/* ===== MG1 quiz bots — server-authoritative (roster + เดิน + ตอบ + คะแนน) =====
เดิม host เดิน+คิดคะแนนบอทเอง → host หลุด = บอทหยุด + คะแนนเพี้ยน. ย้ายมา server → บอทเดินเอง time-based + คะแนน server เป็นเจ้าของ
id `__pv_bot_<slot>` 0..N-1 (N = effectiveBotSlotCount) ตรงกับ client */
const QUIZ_BOT_PREFIX = '__pv_bot_';
const QUIZ_BOT_SPEED_PER_SEC = 0.15 * 1.28 * 60; /* ~11.5 u/s ใกล้ความเร็วบอทเดิม */
function quizBotTierForSlot(i) {
const r = ((i * 73 + 17) % 100) / 100; /* deterministic per slot */
if (r < 0.30) return 'sharp';
if (r < 0.75) return 'avg';
return 'weak';
}
function quizBotCorrectProb(tier) {
return tier === 'sharp' ? 0.84 : tier === 'weak' ? 0.22 : 0.52;
}
function quizTileWalkableServer(md, x, y) {
const w = md.width || 20, h = md.height || 15;
const tx = Math.floor(x), ty = Math.floor(y);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const ob = md.objects && md.objects[ty] ? md.objects[ty][tx] : 0;
return ob !== 1;
}
function quizCellInGrid(grid, x, y) {
if (!Array.isArray(grid)) return false;
const tx = Math.floor(x), ty = Math.floor(y);
return !!(grid[ty] && Number(grid[ty][tx]) === 1);
}
function quizRandomCellInZone(grid, md) {
if (!Array.isArray(grid)) return null;
const w = md.width || 20, 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 && quizTileWalkableServer(md, x + 0.5, y + 0.5)) pool.push({ x, y });
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function createQuizBots(space, md) {
const botCount = effectiveBotSlotCount(space);
const humans = space.peers ? space.peers.size : 0;
const bots = new Map();
for (let i = 0; i < botCount; i++) {
const id = QUIZ_BOT_PREFIX + i;
const sp = quizWrongAnswerRespawnPosition(md, humans + i);
bots.set(id, {
tier: quizBotTierForSlot(i),
x: Number(sp.x), y: Number(sp.y), dir: 'down', isWalking: false,
score: 0, eliminated: false, choice: null, answerRight: false,
tx: null, ty: null, wanderTX: null, wanderTY: null, retargetAt: 0,
});
}
return bots;
}
/* ตัดสินคำตอบบอทแต่ละข้อ (สุ่มตาม tier เดิม) + ตั้งเป้าเดินไปโซนนั้น — ข้ามบอทที่ถูกคัดออกแล้ว */
function quizBotsDecideAnswers(space, md, correctTrue) {
const sess = space.quizSession;
if (!sess || !sess.bots) return;
const tGrid = md.quizTrueArea || [], fGrid = md.quizFalseArea || [];
sess.bots.forEach((b) => {
b.choice = null; b.answerRight = false; b.tx = null; b.ty = null;
if (b.eliminated) return;
if (Math.random() < 0.14) return; /* เดินมั่ว = ไม่ตอบ = ผิด */
const wantsCorrect = Math.random() < quizBotCorrectProb(b.tier);
const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
b.choice = targetTrue;
b.answerRight = (targetTrue === correctTrue);
const cell = quizRandomCellInZone(targetTrue ? tGrid : fGrid, md);
if (cell) { b.tx = cell.x + 0.5; b.ty = cell.y + 0.5; }
});
}
function stepQuizBots(sid, space, dt, now) {
const sess = space.quizSession;
if (!sess || !sess.active || !sess.bots || sess.bots.size === 0) return;
const md = sess.quizMapMd;
if (!md) return;
const w = md.width || 20, h = md.height || 15;
const step = QUIZ_BOT_SPEED_PER_SEC * dt;
const answering = sess.phase === 'answer';
const ghostGrid = md.quizGhostArea;
sess.bots.forEach((b) => {
/* บอทถูกคัดออก (ตอบผิด) = "ผี" → ขังในโซนผี เดินออกไม่ได้ (เหมือนคนจริง) */
const confineGhost = b.eliminated && Array.isArray(ghostGrid);
const canWalk = (x, y) => quizTileWalkableServer(md, x, y) && (!confineGhost || quizCellInGrid(ghostGrid, x, y));
/* hard-snap: ถ้าผีอยู่นอกโซนผี (warp พลาด/ติดขอบ) → ดึงกลับเข้าโซนทันทีทุก tick (กันผีหลุดออกสนาม) */
if (confineGhost && !quizCellInGrid(ghostGrid, b.x, b.y)) {
const gc = quizRandomCellInZone(ghostGrid, md);
if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; b.wanderTX = null; b.retargetAt = 0; }
}
let tgtX = b.tx, tgtY = b.ty;
if (b.eliminated || !answering || tgtX == null) {
/* ถูกคัดออก / ไม่ใช่ช่วงตอบ / บอทไม่ตอบ → เดินมั่ว (ผี = มั่วในโซนผี) */
const arrived = (typeof b.wanderTX === 'number') && Math.hypot(b.wanderTX - b.x, b.wanderTY - b.y) < 0.5;
if (typeof b.wanderTX !== 'number' || arrived || now >= b.retargetAt) {
if (confineGhost) {
const c = quizRandomCellInZone(ghostGrid, md);
b.wanderTX = c ? c.x + 0.5 : b.x;
b.wanderTY = c ? c.y + 0.5 : b.y;
} else {
b.wanderTX = 1 + Math.random() * Math.max(1, w - 2);
b.wanderTY = 1 + Math.random() * Math.max(1, h - 2);
}
b.retargetAt = now + 2000 + Math.floor(Math.random() * 3000);
}
tgtX = b.wanderTX; tgtY = b.wanderTY;
}
let dx = tgtX - b.x, dy = tgtY - b.y;
const dist = Math.hypot(dx, dy);
if (dist < 0.05) { b.isWalking = false; return; }
dx /= dist; dy /= dist;
if (Math.abs(dy) > Math.abs(dx)) b.dir = dy > 0 ? 'down' : 'up';
else b.dir = dx > 0 ? 'right' : 'left';
const sx = Math.min(step, dist);
const ox = b.x, oy = b.y;
const nx = b.x + dx * sx, ny = b.y + dy * sx;
if (canWalk(nx, ny)) { b.x = nx; b.y = ny; }
else if (canWalk(nx, b.y)) { b.x = nx; }
else if (canWalk(b.x, ny)) { b.y = ny; }
b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5;
});
const arr = [];
sess.bots.forEach((b, id) => arr.push({
id, x: Math.round(b.x * 1000) / 1000, y: Math.round(b.y * 1000) / 1000,
direction: b.dir || 'down', isWalking: !!b.isWalking, elim: !!b.eliminated,
}));
if (arr.length) io.to(sid).emit('quiz-bot-move', { bots: arr });
}
/* ============================================================
MG4 quiz_carry — server-authoritative engine
เดิม host-sim (เดิน/หยิบ/ส่ง/คะแนน/บอท บน client host) → ย้ายมา server:
server เป็นเจ้าของ คำถาม/เฟส/หยิบ-ส่ง/คะแนน/จบรอบ(ตอบถูกคนแรก)/บอท(BFS) + disconnect takeover
เปิดเฉพาะเกมจริง (space.detectiveMinigameActive) — editor preview คง client-sim เดิม
============================================================ */
const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
const QUIZ_CARRY_BOT_PREFIX = '__pv_bot_';
const QUIZ_CARRY_BOT_SPEED_PER_SEC = 0.15 * 1.28 * 60; /* ~11.5 u/s ใกล้บอทเดิม */
const QUIZ_CARRY_BETWEEN_MS = 2500;
function quizCarryNormalizeQuestionServer(q) {
if (!q) return null;
let text = String(q.text || q.question || q.prompt || q.title || '').trim();
if (Array.isArray(q.choices) && q.choices.length >= 2) {
const choices = q.choices.map((c) => String(c == null ? '' : c)).slice(0, QUIZ_CARRY_MAX_OPTION_SLOTS);
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
if (!text) text = 'เลือกคำตอบที่ถูกต้อง';
const out = { text, choices, correctIndex: Math.floor(ci) };
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) out.choiceImageUrls = q.choiceImageUrls.slice(0, choices.length);
return out;
}
if (!text) return null;
const t = !!q.answerTrue;
return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1 };
}
/** ชุดคำถาม carry — ลำดับ source ตรงกับ client: global carryQuestions → global questions → map.quizQuestions */
function getQuizCarryQuestionPool(md) {
const g = loadQuizSettings();
const src = (Array.isArray(g.carryQuestions) && g.carryQuestions.length) ? g.carryQuestions
: (Array.isArray(g.questions) && g.questions.length) ? g.questions
: (Array.isArray(md.quizQuestions) ? md.quizQuestions : []);
const out = [];
for (const q of src) { const n = quizCarryNormalizeQuestionServer(q); if (n) out.push(n); }
return out;
}
function quizCarryShuffleServer(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
/** entity (รวม footprint) ยืนที่ tile (tx,ty) ได้ไหม — เดินได้ทุกช่อง + ไม่ทับ hub (hub = กำแพง) */
function quizCarryEntityCanStandServer(md, tx, ty) {
const keys = quizCarryFootprintTileKeys(md, tx + 0.5, ty + 0.5);
for (const k of keys) {
const [cx, cy] = k.split(',').map(Number);
if (!quizTileWalkableServer(md, cx + 0.5, cy + 0.5)) return false;
if (quizCellOn(md.quizCarryHubArea || [], cx, cy)) return false;
}
return true;
}
/** option index (0-based) ที่ footprint ที่ (px,py) ยืนทับ (grid เก็บ 1..N) — null=ไม่ทับ */
function quizCarryOptionIndexAtServer(md, px, py, choiceCount) {
const oa = md.quizCarryOptionArea;
if (!Array.isArray(oa)) return null;
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
const v = oa[ty] && Number(oa[ty][tx]);
if (Number.isFinite(v) && v >= 1 && v <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
const idx = v - 1;
if (!(choiceCount > 0) || idx < choiceCount) return idx;
}
}
return null;
}
function quizCarryFootprintAdjacentHubServer(md, px, py) {
if (!md || !Array.isArray(md.quizCarryHubArea)) return false;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (quizCellOn(md.quizCarryHubArea, tx, ty)) continue;
for (const d of dirs) if (quizCellOn(md.quizCarryHubArea, tx + d[0], ty + d[1])) return true;
}
return false;
}
function quizCarryMapHasInteractiveServer(md) {
if (!md || !Array.isArray(md.interactive)) return false;
for (let y = 0; y < md.interactive.length; y++) {
const row = md.interactive[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
function quizCarryFootprintOverlapsInteractiveServer(md, px, py) {
if (!md || !Array.isArray(md.interactive)) return false;
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (quizCellOn(md.interactive, tx, ty)) return true;
}
return false;
}
function quizCarryFootprintAdjacentInteractiveServer(md, px, py) {
if (!md || !Array.isArray(md.interactive)) return false;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (quizCellOn(md.interactive, tx, ty)) continue;
for (const d of dirs) if (quizCellOn(md.interactive, tx + d[0], ty + d[1])) return true;
}
return false;
}
/** ส่งคำตอบได้ไหมที่ (px,py) — มีโซน interactive(เขียว) ใช้โซนนั้น ไม่งั้นชิด/ทับ hub(ม่วง) */
function quizCarryCanSubmitAtServer(md, px, py) {
if (quizCarryMapHasInteractiveServer(md)) {
return quizCarryFootprintOverlapsInteractiveServer(md, px, py) || quizCarryFootprintAdjacentInteractiveServer(md, px, py);
}
return quizCarryFootprintOverlapsHubServer(md, px, py) || quizCarryFootprintAdjacentHubServer(md, px, py);
}
/** option idx นี้มีคน/บอทถืออยู่แล้วไหม (exclusive lock) — ยกเว้น actor เอง */
function quizCarryOptionHeldByAnyoneServer(s, optionIdx, exceptId) {
if (optionIdx == null || optionIdx < 0) return false;
for (const id in s.players) {
if (String(id) === String(exceptId)) continue;
if (s.players[id] && s.players[id].held === optionIdx) return true;
}
let found = false;
s.bots.forEach((b, bid) => {
if (found || String(bid) === String(exceptId)) return;
if (b && b.held === optionIdx) found = true;
});
return found;
}
/** รวมรายชื่อช่องว่าง (เดินยืนได้ + ไม่ใช่ option/interactive) สำหรับเกิดบอท */
function quizCarrySpawnTilesServer(md) {
const w = md.width || 20, h = md.height || 15;
const free = [], any = [];
const sa = md.spawnArea;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!quizCarryEntityCanStandServer(md, x, y)) continue;
any.push({ x, y });
const isZone = (Array.isArray(md.quizCarryOptionArea) && md.quizCarryOptionArea[y] && Number(md.quizCarryOptionArea[y][x]) >= 1)
|| quizCellOn(md.interactive || [], x, y);
if (isZone) continue;
if (Array.isArray(sa) && sa.length) { if (quizCellOn(sa, x, y)) free.push({ x, y }); }
else free.push({ x, y });
}
}
return free.length ? free : any;
}
function createQuizCarryBots(space, md) {
const botCount = effectiveBotSlotCount(space);
const bots = new Map();
const tiles = quizCarrySpawnTilesServer(md);
for (let i = 0; i < botCount; i++) {
const id = QUIZ_CARRY_BOT_PREFIX + i;
const t = tiles.length ? tiles[(i * 3 + 1) % tiles.length] : { x: 1, y: 1 };
bots.set(id, {
tier: quizBotTierForSlot(i),
x: t.x + 0.5, y: t.y + 0.5, dir: 'down', isWalking: false,
held: null, score: 0,
botPath: [], pathfindAfter: 0, wantIdx: null,
wanderTX: null, wanderTY: null, retargetAt: 0,
});
}
return bots;
}
/** BFS หาเส้นทาง tile→tile (entity stand check + หลบ hub) → คืน array tile-center {x,y} (ไม่รวมจุดเริ่ม) */
function quizCarryPathfindServer(md, sx, sy, gx, gy) {
const w = md.width || 20, h = md.height || 15;
const startX = Math.floor(sx), startY = Math.floor(sy);
const goalX = Math.floor(gx), goalY = Math.floor(gy);
if (startX === goalX && startY === goalY) return [];
const key = (x, y) => y * w + x;
const prev = new Map();
const seen = new Uint8Array(w * h);
const q = [[startX, startY]];
seen[key(startX, startY)] = 1;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let found = false;
while (q.length) {
const [cx, cy] = q.shift();
if (cx === goalX && cy === goalY) { found = true; break; }
for (const d of dirs) {
const nx = cx + d[0], ny = cy + d[1];
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (seen[key(nx, ny)]) continue;
/* ปลายทางอาจเป็น option/hub-edge ที่ entity ยืนได้; เช็ค stand เฉพาะช่องที่ไม่ใช่ goal */
if (!(nx === goalX && ny === goalY) && !quizCarryEntityCanStandServer(md, nx, ny)) continue;
if (nx === goalX && ny === goalY && !quizCarryEntityCanStandServer(md, nx, ny)) {
/* goal ยืนไม่ได้ (เช่นชิด hub) — ยอมรับถ้าเดินได้พื้นฐาน */
if (!quizTileWalkableServer(md, nx + 0.5, ny + 0.5)) continue;
}
seen[key(nx, ny)] = 1;
prev.set(key(nx, ny), key(cx, cy));
q.push([nx, ny]);
}
}
if (!found) return [];
const path = [];
let cur = key(goalX, goalY);
const startKey = key(startX, startY);
while (cur !== startKey) {
const x = cur % w, y = Math.floor(cur / w);
path.push({ x: x + 0.5, y: y + 0.5 });
const p = prev.get(cur);
if (p == null) break;
cur = p;
}
path.reverse();
return path;
}
/** หยิบ/ส่ง (server เป็นเจ้าของ) — คืน true ถ้ามีการเปลี่ยนสถานะ */
function quizCarryServerTryInteract(sid, space, actorId, isBot) {
const s = space.quizCarrySession;
if (!s || !s.active || s.ended) return false;
if (s.phase !== 'answer') return false;
const md = s.mapMd;
if (!md || !s.current) return false;
let px, py;
if (isBot) { const b = s.bots.get(actorId); if (!b) return false; px = b.x; py = b.y; }
else { const p = space.peers.get(actorId); if (!p) return false; px = Number(p.x); py = Number(p.y); }
if (!Number.isFinite(px) || !Number.isFinite(py)) return false;
const holder = isBot ? s.bots.get(actorId) : s.players[actorId];
if (!holder) return false;
const choiceCount = (s.current.choices || []).length;
const pickupIdx = quizCarryOptionIndexAtServer(md, px, py, choiceCount);
const canSubmit = quizCarryCanSubmitAtServer(md, px, py);
const held = holder.held;
/* ถือป้าย + ยืนโซนส่ง → ส่งคำตอบ */
if (held != null && canSubmit) {
holder.held = null;
const ok = held === s.current.correctIndex;
io.to(sid).emit('quiz-carry-held', { id: actorId, held: null });
if (ok) {
holder.score = (Number(holder.score) || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
resolveQuizCarryRound(sid, space, actorId);
}
return true;
}
/* ว่างมือ + ยืนโซนตัวเลือก → หยิบ (ถ้าไม่มีคนถือ) */
if (held == null && pickupIdx != null) {
if (quizCarryOptionHeldByAnyoneServer(s, pickupIdx, actorId)) return false;
holder.held = pickupIdx;
io.to(sid).emit('quiz-carry-held', { id: actorId, held: pickupIdx });
return true;
}
return false;
}
function quizCarryScoresSnapshot(space) {
const s = space.quizCarrySession;
const scores = {};
if (!s) return scores;
for (const id in s.players) scores[id] = Number(s.players[id].score) || 0;
s.bots.forEach((b, bid) => { scores[bid] = Number(b.score) || 0; });
return scores;
}
/** จบรอบ (มีคนตอบถูก หรือหมดเวลา) → เคลียร์ held ทุกคน → ข้อถัดไป / จบเซสชัน */
function resolveQuizCarryRound(sid, space, winnerId) {
const s = space.quizCarrySession;
if (!s || !s.active || s.ended) return;
if (s.roundResolved) return;
s.roundResolved = true;
s.phase = 'between'; /* บอทหยุด interact ระหว่างช่วงพักรอข้อถัดไป */
clearSpaceQuizTimers(space);
/* เคลียร์ held ทุกคน (รอบจบ) */
for (const id in s.players) s.players[id].held = null;
s.bots.forEach((b) => { b.held = null; b.botPath = []; b.wantIdx = null; });
s.roundsCompleted++;
const willEnd = s.sessionLength > 0 && s.roundsCompleted >= s.sessionLength;
io.to(sid).emit('quiz-carry-result', {
questionIndex: s.qIndex + 1,
correctIndex: s.current ? s.current.correctIndex : null,
winnerId: winnerId || null,
scores: quizCarryScoresSnapshot(space),
willEnd,
});
if (willEnd) {
const t = setTimeout(() => endQuizCarryGame(sid, space), 900);
space.quizTimers.push(t);
return;
}
const t = setTimeout(() => scheduleQuizCarryReadPhase(sid, space), QUIZ_CARRY_BETWEEN_MS);
space.quizTimers.push(t);
}
/** หมดเวลาตอบ (ไม่มีใครถูก) → จบรอบเหมือนกัน (ไม่ให้คะแนน) */
function resolveQuizCarryTimeup(sid, space) {
const s = space.quizCarrySession;
if (!s || !s.active || s.ended || s.roundResolved) return;
resolveQuizCarryRound(sid, space, null);
}
function scheduleQuizCarryReadPhase(sid, space) {
const s = space.quizCarrySession;
if (!s || !s.active || s.ended) return;
if (!s.pool.length) { endQuizCarryGame(sid, space); return; }
/* server สุ่มคำถามใหม่ (วนชุดถ้าเล่นยาวกว่าจำนวนข้อ) */
if (s.deck == null || !s.deck.length) s.deck = quizCarryShuffleServer(s.pool.slice());
s.current = s.deck.shift();
s.qIndex++;
s.roundResolved = false;
s.phase = 'read';
const readMs = Math.max(0, s.carryReadMs | 0);
s.optionRevealAt = Date.now() + readMs;
s.phaseEndsAt = s.optionRevealAt;
io.to(sid).emit('quiz-carry-phase', {
srv: true,
phase: 'read',
questionIndex: s.qIndex,
questionTotal: s.sessionLength || s.pool.length,
text: s.current.text,
choices: s.current.choices,
correctIndex: s.current.correctIndex,
choiceImageUrls: s.current.choiceImageUrls || null,
optionRevealAt: s.optionRevealAt,
endsAt: s.phaseEndsAt,
});
if (readMs <= 0) { scheduleQuizCarryAnswerPhase(sid, space); return; }
const t = setTimeout(() => scheduleQuizCarryAnswerPhase(sid, space), readMs);
space.quizTimers.push(t);
}
function scheduleQuizCarryAnswerPhase(sid, space) {
const s = space.quizCarrySession;
if (!s || !s.active || s.ended) return;
s.phase = 'answer';
const ansMs = Math.max(1000, s.carryAnswerMs | 0);
s.optionRevealAt = Date.now();
s.phaseEndsAt = Date.now() + ansMs;
io.to(sid).emit('quiz-carry-phase', {
srv: true,
phase: 'answer',
questionIndex: s.qIndex,
questionTotal: s.sessionLength || s.pool.length,
text: s.current.text,
choices: s.current.choices,
correctIndex: s.current.correctIndex,
choiceImageUrls: s.current.choiceImageUrls || null,
optionRevealAt: s.optionRevealAt,
endsAt: s.phaseEndsAt,
});
const t = setTimeout(() => resolveQuizCarryTimeup(sid, space), ansMs);
space.quizTimers.push(t);
}
function startQuizCarryGame(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'quiz_carry') return;
if (!space.detectiveMinigameActive) return; /* เกมจริงเท่านั้น — editor preview คง client-sim */
if (space.quizCarrySession && space.quizCarrySession.active) return;
clearSpaceQuizTimers(space);
const settings = loadQuizSettings();
const pool = getQuizCarryQuestionPool(md);
const players = {};
space.peers.forEach((_, peerId) => { players[peerId] = { held: null, score: 0 }; });
let sessionLength = clampCarrySessionLength(settings.carrySessionLength, 0);
if (!(sessionLength > 0)) sessionLength = 10; /* detective ต้องจบเมื่อครบ (เหมือน client default 10) */
space.quizCarrySession = {
active: true, ended: false,
pool, deck: null, current: null, qIndex: 0,
phase: 'idle', phaseEndsAt: 0, optionRevealAt: 0,
carryReadMs: Math.max(0, Number(settings.carryReadMs) || 3000),
carryAnswerMs: Math.max(1000, Number(settings.carryAnswerMs) || 5000),
sessionLength, roundsCompleted: 0, roundResolved: false,
players, bots: createQuizCarryBots(space, md), mapMd: md,
};
if (!pool.length) { endQuizCarryGame(sid, space); return; }
scheduleQuizCarryReadPhase(sid, space);
}
function endQuizCarryGame(sid, space) {
const s = space.quizCarrySession;
if (s) { s.active = false; s.ended = true; }
clearSpaceQuizTimers(space);
io.to(sid).emit('quiz-carry-ended', { scores: quizCarryScoresSnapshot(space) });
if (space.detectiveMinigameActive) {
returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม', { allPlayers: true });
}
}
/** เดินบอท carry (BFS ไป option → หยิบ → BFS ไปโซนส่ง → ส่ง) + emit ตำแหน่ง/held */
function stepQuizCarryBots(sid, space, dt, now) {
const s = space.quizCarrySession;
if (!s || !s.active || s.ended || !s.bots || s.bots.size === 0) return;
const md = s.mapMd;
if (!md) return;
const w = md.width || 20, h = md.height || 15;
const step = QUIZ_CARRY_BOT_SPEED_PER_SEC * dt;
const answering = s.phase === 'answer';
const choiceCount = s.current ? (s.current.choices || []).length : 0;
const followPath = (b) => {
if (!b.botPath || !b.botPath.length) return false;
const tgt = b.botPath[0];
let dx = tgt.x - b.x, dy = tgt.y - b.y;
const dist = Math.hypot(dx, dy);
if (dist < 0.08) { b.botPath.shift(); b.isWalking = b.botPath.length > 0; return true; }
dx /= dist; dy /= dist;
if (Math.abs(dy) > Math.abs(dx)) b.dir = dy > 0 ? 'down' : 'up';
else b.dir = dx > 0 ? 'right' : 'left';
const sx = Math.min(step, dist);
const nx = b.x + dx * sx, ny = b.y + dy * sx;
const ox = b.x, oy = b.y;
if (!quizCarryFootprintOverlapsHubServer(md, nx, ny) && quizTileWalkableServer(md, nx, ny)) { b.x = nx; b.y = ny; }
else if (!quizCarryFootprintOverlapsHubServer(md, nx, b.y) && quizTileWalkableServer(md, nx, b.y)) { b.x = nx; }
else if (!quizCarryFootprintOverlapsHubServer(md, b.x, ny) && quizTileWalkableServer(md, b.x, ny)) { b.y = ny; }
else { b.botPath = []; }
b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5;
return true;
};
const wander = (b) => {
const arrived = (typeof b.wanderTX === 'number') && Math.hypot(b.wanderTX - b.x, b.wanderTY - b.y) < 0.5;
if (typeof b.wanderTX !== 'number' || arrived || now >= b.retargetAt) {
b.wanderTX = 1 + Math.random() * Math.max(1, w - 2);
b.wanderTY = 1 + Math.random() * Math.max(1, h - 2);
b.retargetAt = now + 2000 + Math.floor(Math.random() * 3000);
}
let dx = b.wanderTX - b.x, dy = b.wanderTY - b.y;
const dist = Math.hypot(dx, dy);
if (dist < 0.05) { b.isWalking = false; return; }
dx /= dist; dy /= dist;
if (Math.abs(dy) > Math.abs(dx)) b.dir = dy > 0 ? 'down' : 'up';
else b.dir = dx > 0 ? 'right' : 'left';
const sx = Math.min(step, dist);
const nx = b.x + dx * sx, ny = b.y + dy * sx;
const ox = b.x, oy = b.y;
if (!quizCarryFootprintOverlapsHubServer(md, nx, ny) && quizTileWalkableServer(md, nx, ny)) { b.x = nx; b.y = ny; }
else if (!quizCarryFootprintOverlapsHubServer(md, nx, b.y) && quizTileWalkableServer(md, nx, b.y)) { b.x = nx; }
else if (!quizCarryFootprintOverlapsHubServer(md, b.x, ny) && quizTileWalkableServer(md, b.x, ny)) { b.y = ny; }
else { b.wanderTX = null; }
b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5;
};
s.bots.forEach((b, bid) => {
/* ถ้าบังเอิญทับ hub → ดันออก */
if (quizCarryFootprintOverlapsHubServer(md, b.x, b.y)) {
const tiles = quizCarrySpawnTilesServer(md);
if (tiles.length) { const t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = []; }
}
/* ยังไม่มีเส้นทาง → ลองหยิบ/ส่งที่จุดยืนก่อน */
if (!b.botPath || !b.botPath.length) quizCarryServerTryInteract(sid, space, bid, true);
if (!answering || !s.current) { wander(b); return; }
if (b.botPath && b.botPath.length) { followPath(b); return; }
if (now < (b.pathfindAfter || 0)) { wander(b); return; }
const tier = b.tier || 'avg';
const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
if (b.held == null) {
/* เลือกข้อจะหยิบ (เลี่ยงข้อที่มีคนถือ) */
let want = Math.random() < pCorrect ? s.current.correctIndex : Math.floor(Math.random() * Math.max(2, choiceCount));
if (quizCarryOptionHeldByAnyoneServer(s, want, bid)) {
for (let i = 0; i < choiceCount; i++) { if (!quizCarryOptionHeldByAnyoneServer(s, i, bid)) { want = i; break; } }
}
const dest = quizCarryFindOptionTileServer(md, want, b);
if (dest) { b.botPath = quizCarryPathfindServer(md, b.x, b.y, dest.x + 0.5, dest.y + 0.5); b.wantIdx = want; }
b.pathfindAfter = now + 160 + Math.floor(Math.random() * 200);
if (!b.botPath || !b.botPath.length) wander(b);
} else {
const sub = quizCarryFindSubmitTileServer(md, b);
if (sub) b.botPath = quizCarryPathfindServer(md, b.x, b.y, sub.x + 0.5, sub.y + 0.5);
b.pathfindAfter = now + 160 + Math.floor(Math.random() * 200);
if (!b.botPath || !b.botPath.length) wander(b);
}
});
const arr = [];
s.bots.forEach((b, id) => arr.push({
id, x: Math.round(b.x * 1000) / 1000, y: Math.round(b.y * 1000) / 1000,
direction: b.dir || 'down', isWalking: !!b.isWalking, held: b.held == null ? null : b.held,
}));
if (arr.length) io.to(sid).emit('quiz-carry-bot-move', { bots: arr });
}
/** หา tile โซน option (เลขช่อง want+1) ที่เดินถึงได้ — สุ่ม */
function quizCarryFindOptionTileServer(md, optionIndex, b) {
const w = md.width || 20, h = md.height || 15;
const want = optionIndex + 1;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) if (Number(row[x]) === want && quizTileWalkableServer(md, x + 0.5, y + 0.5)) pool.push({ x, y });
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
/** หา tile โซนส่งคำตอบ (interactive ถ้ามี ไม่งั้นช่องชิด hub) */
function quizCarryFindSubmitTileServer(md, b) {
const w = md.width || 20, h = md.height || 15;
const pool = [];
if (quizCarryMapHasInteractiveServer(md)) {
for (let y = 0; y < h; y++) {
const row = md.interactive && md.interactive[y];
if (!row) continue;
for (let x = 0; x < w; x++) if (row[x] === 1 && quizTileWalkableServer(md, x + 0.5, y + 0.5)) pool.push({ x, y });
}
if (pool.length) return pool[Math.floor(Math.random() * pool.length)];
}
/* ช่องเดินได้ที่ชิด hub (ยืนส่งได้) */
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!quizCarryEntityCanStandServer(md, x, y)) continue;
if (quizCarryFootprintAdjacentHubServer(md, x + 0.5, y + 0.5)) pool.push({ x, y });
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function safeMapId(id) {
return (id || '').replace(/[^a-z0-9_-]/gi, '') || null;
}
/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 216หาย) */
function normalizeQuizCarryLayersOnMap(m) {
if (!m || m.gameType !== 'quiz_carry') return;
const w = m.width || 20, h = m.height || 15;
const normHub = () => {
const src = m.quizCarryHubArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizCarryHubArea = rows;
};
const normOptions = () => {
const src = m.quizCarryOptionArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
}
rows.push(row);
}
m.quizCarryOptionArea = rows;
};
const normCountdown = () => {
const src = m.carryEmbedCountdownArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
row.push(Number(v) === 1 ? 1 : 0);
}
rows.push(row);
}
m.carryEmbedCountdownArea = rows;
};
normHub();
normOptions();
normCountdown();
}
/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
function normalizeQuizBattleDomeAreaOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
const w = m.width || 20, h = m.height || 15;
const src = m.quizBattleDomeArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizBattleDomeArea = rows;
}
/** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */
function normalizeQuizBattleDomeCompOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
normalizeQuizBattleDomeAreaOnMap(m);
const w = m.width || 20, h = m.height || 15;
const grid = m.quizBattleDomeArea;
const comp = Array(h).fill(0).map(() => Array(w).fill(0));
let nextId = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue;
nextId++;
const stack = [[x, y]];
comp[y][x] = nextId;
while (stack.length) {
const c = stack.pop();
const cx = c[0], cy = c[1];
for (let i = 0; i < 4; i++) {
const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue;
comp[ny][nx] = nextId;
stack.push([nx, ny]);
}
}
}
}
m.quizBattleDomeComp = comp;
}
/**
* ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน
* ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน
*/
function hasQuizBattleManualPathGates(m) {
const mg = m && m.quizBattlePathGateArea;
if (!mg || !Array.isArray(mg)) return false;
for (let ty = 0; ty < mg.length; ty++) {
const row = mg[ty];
if (!row) continue;
for (let tx = 0; tx < row.length; tx++) {
if ((row[tx] | 0) > 0) return true;
}
}
return false;
}
function buildQuizBattlePathGateFromManualOnMap(m) {
const w = m.width || 20, h = m.height || 15;
const path = m.quizBattlePathArea;
const dome = m.quizBattleDomeArea;
const manual = m.quizBattlePathGateArea;
const gate = Array(h).fill(0).map(() => Array(w).fill(0));
const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
const queue = [];
function enqueue(tx, ty, req) {
if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
const onPath = path[ty] && path[ty][tx] === 1;
const onDome = dome && dome[ty] && dome[ty][tx] === 1;
if (!onPath && !onDome) return;
/* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */
if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
bestReq[ty][tx] = req;
queue.push({ tx, ty, req });
}
const sp = m.spawn || { x: 1, y: 1 };
const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
enqueue(stx, sty, 0);
if (bestReq[sty][stx] < 0) {
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
}
const sa = m.spawnArea;
if (sa && Array.isArray(sa)) {
for (let ty = 0; ty < h; ty++) {
const row = sa[ty];
if (!row) continue;
for (let tx = 0; tx < w; tx++) {
if (row[tx] === 1) enqueue(tx, ty, 0);
}
}
}
while (queue.length) {
const cur = queue.shift();
const { tx, ty, req } = cur;
if (bestReq[ty][tx] !== req) continue;
const onDome = dome && dome[ty] && dome[ty][tx] === 1;
let enterReq = req;
const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
if (onDome) enterReq = 0;
else if (mg > 0) enterReq = Math.max(enterReq, mg);
if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
let outReq = enterReq;
if (mg > 0) outReq = Math.max(outReq, mg);
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
enqueue(tx + dx, ty + dy, outReq);
}
}
m.quizBattlePathGateRequired = gate;
}
function buildQuizBattlePathGateRequiredOnMap(m) {
if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) {
if (m) m.quizBattlePathGateRequired = null;
return;
}
if (hasQuizBattleManualPathGates(m)) {
buildQuizBattlePathGateFromManualOnMap(m);
return;
}
normalizeQuizBattleDomeCompOnMap(m);
const w = m.width || 20, h = m.height || 15;
const path = m.quizBattlePathArea;
const dome = m.quizBattleDomeArea;
const domeComp = m.quizBattleDomeComp;
const gate = Array(h).fill(0).map(() => Array(w).fill(0));
const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
const queue = [];
const sp = m.spawn || { x: 1, y: 1 };
const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
function tryEnqueue(tx, ty, d) {
if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
if (dist[ty][tx] >= 0) return;
const onPath = path[ty] && path[ty][tx] === 1;
const onDome = dome && dome[ty] && dome[ty][tx] === 1;
if (!onPath && !onDome) return;
dist[ty][tx] = d;
queue.push({ tx, ty, d });
}
tryEnqueue(stx, sty, 0);
if (dist[sty][stx] < 0) {
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
}
const sa = m.spawnArea;
if (sa && Array.isArray(sa)) {
for (let ty = 0; ty < h; ty++) {
const row = sa[ty];
if (!row) continue;
for (let tx = 0; tx < w; tx++) {
if (row[tx] === 1) tryEnqueue(tx, ty, 0);
}
}
}
const domeMinDist = {};
let maxComp = 0;
while (queue.length) {
const cur = queue.shift();
const { tx, ty, d } = cur;
const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
if (dc > 0) {
maxComp = Math.max(maxComp, dc);
if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
}
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = tx + dx, ny = ty + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (dist[ny][nx] >= 0) continue;
const onPath = path[ny] && path[ny][nx] === 1;
const onDome = dome && dome[ny] && dome[ny][nx] === 1;
if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
}
}
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!path[ty] || path[ty][tx] !== 1) continue;
const d = dist[ty][tx];
if (d < 0) {
gate[ty][tx] = maxComp;
continue;
}
let req = 0;
for (let c = 1; c <= maxComp; c++) {
if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
}
gate[ty][tx] = req;
}
}
m.quizBattlePathGateRequired = gate;
}
function finalizeQuizBattleMapLayersOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
normalizeQuizBattleDomeAreaOnMap(m);
normalizeQuizBattlePathAreaOnMap(m);
normalizeQuizBattleDomeCompOnMap(m);
buildQuizBattlePathGateRequiredOnMap(m);
}
function normalizeQuizBattlePathAreaOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
const w = m.width || 20, h = m.height || 15;
const src = m.quizBattlePathArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizBattlePathArea = rows;
}
function quizBattlePathModeActiveServer(m) {
if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
const g = m.quizBattlePathArea;
for (let y = 0; y < g.length; y++) {
const row = g[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
function quizBattleFootprintFullyOnPathServer(m, px, py) {
if (!quizBattlePathModeActiveServer(m)) return true;
const g = m.quizBattlePathArea;
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
if (!keys.length) return false;
for (const k of keys) {
const [tx, ty] = k.split(',').map(Number);
if (!g[ty] || g[ty][tx] !== 1) return false;
}
return true;
}
/** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */
function quizBattlePositionAllowedServer(m, px, py) {
if (!quizBattlePathModeActiveServer(m)) return true;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true;
const sa = m.spawnArea;
if (!sa || !Array.isArray(sa)) return false;
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
if (!keys.length) return false;
for (const k of keys) {
const [tx, ty] = k.split(',').map(Number);
if (!sa[ty] || sa[ty][tx] !== 1) return false;
}
return true;
}
function quizBattlePlayerSolvedCompsServer(space, socketId) {
if (!space || !space.qbwSolved) return new Set();
const set = space.qbwSolved.get(socketId);
return set || new Set();
}
function quizBattlePathGateRequiredAtServer(m, px, py) {
const gate = m && m.quizBattlePathGateRequired;
if (!gate || !quizBattlePathModeActiveServer(m)) return 0;
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
const path = m.quizBattlePathArea;
let maxReq = 0;
for (const k of keys) {
const [tx, ty] = k.split(',').map(Number);
if (!path[ty] || path[ty][tx] !== 1) continue;
maxReq = Math.max(maxReq, gate[ty][tx] || 0);
}
return maxReq;
}
function quizBattleFootprintOnDomeServer(m, px, py) {
const dome = m && m.quizBattleDomeArea;
if (!dome) return false;
const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
for (const k of keys) {
const [tx, ty] = k.split(',').map(Number);
if (dome[ty] && dome[ty][tx] === 1) return true;
}
return false;
}
function quizBattlePathGateAllowsServer(m, space, socketId, px, py) {
if (!m || !quizBattlePathModeActiveServer(m)) return true;
if (quizBattleFootprintOnDomeServer(m, px, py)) return true;
const maxReq = quizBattlePathGateRequiredAtServer(m, px, py);
if (maxReq <= 0) return true;
const solved = quizBattlePlayerSolvedCompsServer(space, socketId);
for (let c = 1; c <= maxReq; c++) {
if (!solved.has(c)) return false;
}
return true;
}
function serverFootprintClearOfWalls(md, px, py) {
const w = md.width || 20, h = md.height || 15;
const keys = serverWallCollisionTileKeys(md, px, py);
if (!keys.length) return false;
for (const k of keys) {
const [tx, ty] = k.split(',').map(Number);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = md.objects && md.objects[ty];
if (!row || row[tx] === 1) return false;
}
return true;
}
/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
function snapPositionOntoQuizBattlePathServer(md, px, py) {
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
if (quizBattleSnapTargetValidServer(md, px, py)) return { x: px, y: py };
const w = md.width || 20, h = md.height || 15;
const g = md.quizBattlePathArea;
let bestX = null, bestY = null, bestD = Infinity;
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const nx = tx + 0.01;
const ny = ty + 0.01;
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const nx = tx + 0.5;
const ny = ty + 0.5;
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
const sa = md.spawnArea;
if (sa && Array.isArray(sa)) {
for (let ty = 0; ty < h; ty++) {
const row = sa[ty];
if (!row) continue;
for (let tx = 0; tx < w; tx++) {
if (row[tx] !== 1) continue;
for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
if (!quizBattleSnapTargetValidServer(md, nx, ny)) continue;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
}
}
if (bestX != null) return { x: bestX, y: bestY };
return { x: px, y: py };
}
function collectQuizBattleValidSpawnCentersServer(md) {
const out = [];
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out;
const w = md.width || 20, h = md.height || 15;
const g = md.quizBattlePathArea;
const seen = new Set();
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
const key = nx.toFixed(3) + ',' + ny.toFixed(3);
if (seen.has(key)) continue;
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
seen.add(key);
out.push({ tx, ty, x: nx, y: ny });
}
}
}
out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
return out;
}
function quizBattleSnapTargetValidServer(md, x, y) {
if (!md || !md.objects) return false;
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
if (!serverFootprintClearOfWalls(md, x, y)) return false;
if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false;
return true;
}
function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) {
const ptx = Math.floor(Number(prefTx)) || 0;
const pty = Math.floor(Number(prefTy)) || 0;
for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny };
}
const pool = collectQuizBattleSpawnPoolServer(md);
if (pool.length) {
let best = pool[0], bestD = Infinity;
for (const c of pool) {
const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
if (d < bestD) { bestD = d; best = c; }
}
return { x: best.x, y: best.y };
}
const valid = collectQuizBattleValidSpawnCentersServer(md);
if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
let best = valid[0], bestD = Infinity;
for (const c of valid) {
const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
if (d < bestD) { bestD = d; best = c; }
}
return { x: best.x, y: best.y };
}
function collectQuizBattleSpawnPoolServer(md) {
const out = [];
if (!md) return out;
const w = md.width || 20, h = md.height || 15;
const grid = md.spawnArea;
const seen = new Set();
const cells = [];
if (grid && Array.isArray(grid)) {
for (let ty = 0; ty < h; ty++) {
const row = grid[ty];
if (!row) continue;
for (let tx = 0; tx < w; tx++) {
if (Number(row[tx]) === 1) cells.push({ tx, ty });
}
}
}
if (!cells.length && md.spawn) {
cells.push({
tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
});
}
for (const { tx, ty } of cells) {
for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
const key = nx.toFixed(3) + ',' + ny.toFixed(3);
if (seen.has(key)) continue;
if (quizBattlePathModeActiveServer(md)) {
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
} else if (!isMapTileWalkableForSpawn(md, tx, ty)) {
continue;
}
seen.add(key);
out.push({ tx, ty, x: nx, y: ny });
}
}
if (!out.length && quizBattlePathModeActiveServer(md)) {
return collectQuizBattleValidSpawnCentersServer(md);
}
return out;
}
function pickQuizBattleRandomSpawnWorldServer(md) {
const pool = collectQuizBattleSpawnPoolServer(md);
const fb = md.spawn || { x: 1, y: 1 };
if (!pool.length) {
return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y);
}
const idx = typeof crypto.randomInt === 'function'
? crypto.randomInt(0, pool.length)
: Math.floor(Math.random() * pool.length);
const pick = pool[idx];
return { x: pick.x, y: pick.y };
}
function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) {
if (!md) return { x: 1.5, y: 1.5 };
const ord = joinOrderIndex | 0;
const mode = md.lobbySpawnMode;
if (mode === 'slots6') {
const slots = parseLobbyPlayerSpawnsFromMap(md);
const j = Math.min(Math.max(0, ord), 5);
const slot = slots[j];
if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y);
return pickQuizBattleRandomSpawnWorldServer(md);
}
if (mode === 'fixed' && md.spawn) {
const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
return quizBattleNearestValidSpawnWorldServer(md, sx, sy);
}
return pickQuizBattleRandomSpawnWorldServer(md);
}
function pickQuizBattleSpawnFromMap(md, joinOrderIndex) {
const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex);
return { x: Math.floor(world.x), y: Math.floor(world.y) };
}
function normalizeJumpSurvivePlatformAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const src = m.jumpSurvivePlatformArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.jumpSurvivePlatformArea = rows;
}
function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const pa = m.jumpSurvivePlatformArea || [];
const src = m.jumpSurvivePlatformVariantArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const row = [];
for (let x = 0; x < w; x++) {
const has = pa[y] && pa[y][x] === 1;
let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
if (has && (!Number.isFinite(v) || v < 1)) v = 1;
if (v > 3) v = 3;
if (!has) v = 0;
row.push(v);
}
rows.push(row);
}
m.jumpSurvivePlatformVariantArea = rows;
}
function normalizeJumpSurviveHazardAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const src = m.jumpSurviveHazardArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.jumpSurviveHazardArea = rows;
}
/**
* แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
*/
function applyStackTowerMissionMapPlayPolicy(safeId, m) {
if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
const spNum = Number(raw.speedPxPerSec);
const speedPxPerSec = Number.isFinite(spNum)
? Math.max(0, Math.min(400, Math.floor(spNum)))
: 0;
const sel = Math.floor(Number(raw.stepEveryLayers));
const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
const ssp = Math.floor(Number(raw.stepScrollPx));
const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
const sam = Math.floor(Number(raw.stepAnimMs));
const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
const st = {
enabled: raw.enabled === true,
speedPxPerSec,
scrollDirection,
stepEveryLayers,
stepScrollPx,
stepAnimMs,
};
if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
const rg = Number(raw.releaseGapWorldPx);
if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
m.stackTowerBgScroll = st;
const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
}
function loadMaps() {
try {
if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(MAPS_DIR);
for (const f of files) {
if (!f.endsWith('.json')) continue;
const id = f.slice(0, -5);
if (!safeMapId(id)) continue;
try {
const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
normalizeQuizCarryLayersOnMap(data);
normalizeJumpSurvivePlatformAreaOnMap(data);
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
normalizeJumpSurviveHazardAreaOnMap(data);
finalizeQuizBattleMapLayersOnMap(data);
applyStackTowerMissionMapPlayPolicy(id, data);
maps.set(id, data);
} catch (e) { console.error('load map', f, e.message); }
}
} catch (e) { console.error('loadMaps', e.message); }
}
/**
* อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
* (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
*/
function rehydrateSingleMapFromDisk(safeId) {
if (!safeId) return false;
const fp = path.join(MAPS_DIR, safeId + '.json');
if (!fs.existsSync(fp)) return false;
try {
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
normalizeQuizCarryLayersOnMap(data);
normalizeJumpSurvivePlatformAreaOnMap(data);
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
normalizeJumpSurviveHazardAreaOnMap(data);
finalizeQuizBattleMapLayersOnMap(data);
applyStackTowerMissionMapPlayPolicy(safeId, data);
maps.set(safeId, data);
return true;
} catch (e) {
console.error('rehydrate map', safeId, e.message);
return false;
}
}
/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
function ensurePostCaseLobbyMapLoaded() {
if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
try {
const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
if (!fs.existsSync(fp)) return false;
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
normalizeQuizCarryLayersOnMap(data);
maps.set(POST_CASE_LOBBY_SPACE_ID, data);
return true;
} catch (e) {
console.error('ensurePostCaseLobbyMapLoaded', e.message);
return false;
}
}
function saveMap(id, m) {
const safe = safeMapId(id);
if (!safe) return;
try {
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
} catch (e) { console.error('saveMap', id, e.message); }
}
function isMapNameTaken(name, excludeId) {
const n = (name || '').trim().toLowerCase();
for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
return false;
}
function applyDevCors(req, res) {
const origin = req.headers.origin;
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Vary', 'Origin');
}
}
const server = http.createServer((req, res) => {
applyDevCors(req, res);
if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
res.writeHead(204);
res.end();
return;
}
let url = req.url;
if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
if (url.startsWith(BASE_PATH + '/api/maps')) {
let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
if (id.includes('?')) id = id.split('?')[0];
if (req.method === 'GET' && id) {
const sid = safeMapId(decodeURIComponent(id));
if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
rehydrateSingleMapFromDisk(sid);
const m = maps.get(sid);
if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
}
if (req.method === 'GET' && !id) {
const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
}
if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body);
const name = (d.name || '').trim() || 'ฉากใหม่';
if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
const id = Date.now().toString(36);
const m = { ...defaultMap(), ...d, name };
if (!m.blockPlayer) m.blockPlayer = [];
if (!m.interactive) m.interactive = [];
if (!m.startGameArea) m.startGameArea = [];
if (!m.spawnArea) m.spawnArea = [];
if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
if (!m.quizTrueArea) m.quizTrueArea = [];
if (!m.quizFalseArea) m.quizFalseArea = [];
if (!m.quizQuestionArea) m.quizQuestionArea = [];
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
if (!m.stackLandArea) m.stackLandArea = [];
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
normalizeQuizCarryLayersOnMap(m);
normalizeJumpSurvivePlatformAreaOnMap(m);
normalizeJumpSurvivePlatformVariantAreaOnMap(m);
normalizeJumpSurviveHazardAreaOnMap(m);
finalizeQuizBattleMapLayersOnMap(m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mapId: id }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
if (req.method === 'PUT' && id && maps.has(id)) {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body);
const name = (d.name || '').trim() || maps.get(id).name;
if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
const m = { ...maps.get(id), ...d, name };
if (!m.blockPlayer) m.blockPlayer = [];
if (!m.interactive) m.interactive = [];
if (!m.startGameArea) m.startGameArea = [];
if (!m.spawnArea) m.spawnArea = [];
if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
if (!m.quizTrueArea) m.quizTrueArea = [];
if (!m.quizFalseArea) m.quizFalseArea = [];
if (!m.quizQuestionArea) m.quizQuestionArea = [];
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
if (!m.stackLandArea) m.stackLandArea = [];
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
normalizeQuizCarryLayersOnMap(m);
normalizeJumpSurvivePlatformAreaOnMap(m);
normalizeJumpSurvivePlatformVariantAreaOnMap(m);
normalizeJumpSurviveHazardAreaOnMap(m);
finalizeQuizBattleMapLayersOnMap(m);
applyStackTowerMissionMapPlayPolicy(id, m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mapId: id }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
}
const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
const quizCarryDiskPaths = new Set([
BASE_PATH + '/api/quiz-carry-from-disk',
BASE_PATH + '/api-quiz-carry-from-disk.php',
]);
if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
if (req.method === 'GET') {
const g = loadQuizSettings();
const slice = {
carryMapPanelTheme: g.carryMapPanelTheme,
carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
carryReadMs: g.carryReadMs,
carryAnswerMs: g.carryAnswerMs,
carrySessionLength: g.carrySessionLength,
carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
carryWalkSpeedMult: g.carryWalkSpeedMult,
carryQuestions: g.carryQuestions,
};
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(slice));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(getSystemStats()));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadQuizSettings()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveQuizSettings(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
battleQuizMcqSaved: saved.battleQuizMcqSaved,
carryQuestionsSaved: saved.carryQuestionsSaved,
specialQuizSets: saved.specialQuizSets,
carryMapPanelTheme: saved.carryMapPanelTheme,
carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadEvidenceCards()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveEvidenceCards(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadCaseMedia()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveCaseMedia(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
});
return res.end(JSON.stringify({ ...runtimeGameTiming }));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
try {
const d = JSON.parse(body || '{}');
const saved = saveGameTimingToFile(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
const { ok: _ok, error: _err, ...timingRest } = saved;
return res.end(JSON.stringify({ ok: true, ...timingRest }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const dataUrl = d.imageDataUrl;
if (!dataUrl || typeof dataUrl !== 'string') {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
}
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
if (!m) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
}
const extRaw = m[1].toLowerCase();
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
const b64 = m[2].replace(/\s/g, '');
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
}
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
}
ensureGauntletAssetsDir();
const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
let fname;
if (replaceFilename) {
const s = safeGauntletStoredFilename(replaceFilename);
if (!s) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
}
const cur = path.join(GAUNTLET_ASSETS_DIR, s);
if (!fs.existsSync(cur)) {
res.writeHead(404);
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
}
const norm = (e) => {
let x = String(e || '').toLowerCase();
if (x.startsWith('.')) x = x.slice(1);
return x === 'jpeg' ? 'jpg' : x;
};
if (norm(path.extname(s)) !== norm(ext)) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
}
fname = s;
} else {
fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
}
fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
const meta = loadGauntletAssetsMeta();
const prev = meta[fname] || {};
meta[fname] = {
...prev,
label: labelIn || prev.label || fname,
updatedAt: Date.now(),
};
saveGauntletAssetsMeta(meta);
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
filename: fname,
url: BASE_PATH + '/img/gauntlet-assets/' + fname,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const dataUrl = d.imageDataUrl;
if (!dataUrl || typeof dataUrl !== 'string') {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
}
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
if (!m) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
}
const extRaw = m[1].toLowerCase();
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
const b64 = m[2].replace(/\s/g, '');
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
}
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
}
ensureQuizCarryPlaqueAssetsDir();
const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
filename: fname,
url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const fname = safeGauntletStoredFilename(d.filename);
if (!fname) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (!fs.existsSync(fp)) {
res.writeHead(404);
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
}
const meta = loadGauntletAssetsMeta();
const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
saveGauntletAssetsMeta(meta);
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
try {
const q = url.includes('?') ? url.split('?')[1] : '';
const sp = new URLSearchParams(q);
const rawFn = sp.get('file');
const fname = safeGauntletStoredFilename(rawFn);
if (!fname) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
const meta = loadGauntletAssetsMeta();
delete meta[fname];
saveGauntletAssetsMeta(meta);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
}
}
if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const ids = new Set();
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
files.forEach(f => {
let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
});
const idArr = [...ids];
const oldestMsById = {};
idArr.forEach((id) => {
let oldest = Infinity;
const prefix = id + '_';
files.forEach((f) => {
if (!f.startsWith(prefix) || !ext.test(f)) return;
try {
const st = fs.statSync(path.join(CHARACTERS_DIR, f));
const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
if (t < oldest) oldest = t;
} catch (e) { /* ignore */ }
});
oldestMsById[id] = oldest === Infinity ? 0 : oldest;
});
idArr.sort((a, b) => {
const ta = oldestMsById[a] || 0;
const tb = oldestMsById[b] || 0;
if (ta !== tb) return ta - tb;
return String(a).localeCompare(String(b));
});
const list = idArr.map((id) => {
const prefix = id + '_';
const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
const layerNameSet = new Set();
if (hasLayerFiles) {
files.forEach((f) => {
if (!f.startsWith(prefix) || !ext.test(f)) return;
let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
if (m) {
layerNameSet.add(m[1]);
return;
}
m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
if (m) layerNameSet.add(m[1]);
});
}
const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
return { id, name: id, hasLayerFiles, layers, layerManifest };
});
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(list));
} catch (e) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify([]));
}
}
const urlPath = url.split('?')[0];
{
const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
if (lm && req.method === 'GET') {
const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
if (!id) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
}
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const manifest = buildCharacterLayerManifest(id, files);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(manifest));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
}
}
}
if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
if (/\/layer-manifest$/i.test(urlPath)) {
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
}
const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
if (!id) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
}
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
let removed = 0;
files.forEach(f => {
if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
try {
fs.unlinkSync(path.join(CHARACTERS_DIR, f));
removed++;
} catch (e) {
// ignore single file errors
}
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, removed }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
}
return;
}
if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
if (!body || body.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
}
const d = JSON.parse(body);
const dirs = ['up', 'down', 'left', 'right'];
const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
let walkWritten = 0;
for (const dir of dirs) {
let data = d[dir];
if (!data) continue;
const list = Array.isArray(data) ? data : [data];
let frameIndex = 0;
for (const dataUrl of list) {
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
// เขียนไฟล์เฟรม (สำหรับ animation)
const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
// ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก <id>_<dir>.png เพื่อใช้เป็น preview / fallback
if (list.length > 1 && frameIndex === 0) {
const baseName = id + '_' + dir + '.png';
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
}
walkWritten++;
frameIndex++;
}
}
let layerWritten = 0;
// ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
const lf = d.layerFrames;
if (lf && typeof lf === 'object') {
for (const dir of dirs) {
const arr = lf[dir];
if (!Array.isArray(arr)) continue;
const multi = arr.length > 1;
arr.forEach((frameObj, frameIndex) => {
if (!frameObj || typeof frameObj !== 'object') return;
for (const layerName of Object.keys(frameObj)) {
if (!ALLOWED_LAYER[layerName]) continue;
const dataUrl = frameObj[layerName];
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
const fnameLayer = multi
? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
: (id + '_' + dir + '_layer_' + layerName + '.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
layerWritten++;
}
});
}
}
const lfIdle = d.layerFramesIdle;
if (lfIdle && typeof lfIdle === 'object') {
for (const dir of dirs) {
const arr = lfIdle[dir];
if (!Array.isArray(arr)) continue;
const multi = arr.length > 1;
arr.forEach((frameObj, frameIndex) => {
if (!frameObj || typeof frameObj !== 'object') return;
for (const layerName of Object.keys(frameObj)) {
if (!ALLOWED_LAYER[layerName]) continue;
const dataUrl = frameObj[layerName];
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
const fnameLayer = multi
? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
: (id + '_' + dir + '_idle_layer_' + layerName + '.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
layerWritten++;
}
});
}
}
const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
if (idleObj) {
for (const dir of dirs) {
let data = idleObj[dir];
if (!data) continue;
const list = Array.isArray(data) ? data : [data];
let frameIndex = 0;
for (const dataUrl of list) {
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
const fnameFrame = list.length > 1
? (id + '_' + dir + '_idle_' + frameIndex + '.png')
: (id + '_' + dir + '_idle.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
if (list.length > 1 && frameIndex === 0) {
const baseName = id + '_' + dir + '_idle.png';
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
}
frameIndex++;
}
}
}
if (walkWritten === 0 && layerWritten === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, characterId: id }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
if (url.startsWith(BASE_PATH + '/api/spaces')) {
const parts = url.slice((BASE_PATH + '/api/spaces').length).split('?')[0].split('#')[0].split('/').filter(Boolean);
const spaceId = parts[0];
if (req.method === 'GET' && !spaceId) {
const now = Date.now();
for (const [id, s] of [...spaces.entries()]) {
if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
const n = s.peers ? s.peers.size : 0;
if (n !== 0) continue;
/* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */
const since = s.emptySince || s.createdAt;
const stale = since == null || now - since > SPACE_EMPTY_TTL_MS;
if (stale) spaces.delete(id);
}
const list = [...spaces]
.filter(([, s]) => !s.isPrivate)
.filter(([, s]) => (s.peers && s.peers.size > 0))
.filter(([, s]) => !s.joinLocked) /* ห้องที่เริ่มคดีแล้ว (ออกจาก LobbyA) → ซ่อนจาก Join Room list ไม่ให้ใครเข้า */
.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;
/** เข้าเส้นชัยแล้ว — หยุด obstacle ทันที แล้วรอ grace นี้ให้ client เดินทุกคนเข้าเส้นครบก่อนค่อยจบ (ms)
ต้อง >= client GAUNTLET_FINISH_WALK_MAX_MS (2600) + เผื่อยืนที่เส้นเล็กน้อย */
const GAUNTLET_FINISH_WALK_MS = 3000;
/** กัน client ยิง gauntlet-finish-phase เร็วผิดปกติ (ปลอม/desync) — run จริงใช้เวลานานกว่านี้มากเสมอ
server เป็นผู้ตัดสินว่ายังถึงเส้นชัยไม่ได้ ก่อนเวลานี้นับจาก liveStartMs */
const GAUNTLET_FINISH_MIN_RUN_MS = 4000;
/** G2: หน่วงเริ่มวิ่ง — หลัง 3-2-1 จบ ค้างที่จุด start ให้ผู้เล่นเห็นเส้นเริ่มก่อน (ทั้ง bg+bots/obstacles sync จาก liveStartMs เดียวกัน) */
const GAUNTLET_PRERUN_HOLD_MS = 1000;
/** ระยะอยู่กลางอากาศ (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,
isBot: false,
}));
/* รวมบอท server-authoritative ในอันดับ/เกรด (client เติมชื่อ/อวตารจาก id) — แหล่งเดียว ไม่ให้ client คำนวณซ้ำ */
const gr = space && space.gauntletRun;
if (gr && gr.bots && gr.bots.size) {
gr.bots.forEach((b, id) => {
rows.push({
id,
nickname: 'บอท',
characterId: null,
baseScore: b.gauntletEliminated ? 0 : Math.max(0, b.gauntletScore | 0),
eliminated: !!b.gauntletEliminated,
isBot: true,
});
});
}
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,
isBot: !!rows[i].isBot,
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 survivorCount = rows.filter((r) => !r.eliminated).length;
/* เกรด F = ไม่มีผู้รอด (ตรงกับ client เดิม) → ไม่มีการ์ดรางวัล */
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (survivorCount <= 0) grade = 'F';
const rewardCard = grade === 'F' ? null : rollGauntletCrownRewardCard(grade);
return {
ranked,
totalSum,
averageScore,
grade,
rewardCard,
totalParts: ranked.map((r) => r.finalScore),
participantCount: ranked.length,
survivorCount,
};
}
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;
}
/* ===== MG2 gauntlet bots — server-authoritative (roster + กระโดด + ชน + คะแนน) =====
เดิม host จำลองบอท __pv_bot_N เอง (AI+ฟิสิกส์+ตายตกขอบ) → host หลุด = บอทหยุด/คะแนนเพี้ยน.
ย้ายมา server: บอทกระโดด/ชน/คะแนนบน server แล้วส่งใน gauntlet-sync.players (isBot)
id `__pv_bot_<slot>` 0..N-1 (N = effectiveBotSlotCount) ตรงกับ client (PREVIEW_BOT_PREFIX) */
const GAUNTLET_BOT_PREFIX = '__pv_bot_';
function gauntletBotTierForSlot(i) {
const r = ((i * 73 + 17) % 100) / 100; /* deterministic per slot */
if (r < 0.28) return 'sharp';
if (r < 0.62) return 'avg';
return 'weak';
}
/** ความน่าจะกระโดด (ตรง maybePreviewBotGauntletJump ฝั่ง client) — ชนเซลล์เดียวกัน vs คอลัมน์ถัดไป */
function gauntletBotJumpProbs(tier) {
return {
pSame: tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86,
pAhead: tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72,
};
}
/** เลเซอร์ครอบแถว py ไหม (ตรงกับ collision ใน runGauntletTick) */
function gauntletLaserOverlapsRowServer(o, py, h) {
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);
return py >= lo && py <= hi;
}
/** ตัดสินใจกระโดดของบอท 1 ตัว (server) — mirror maybePreviewBotGauntletJump */
function gauntletBotDecideJump(b, obstacles, md, w, h) {
if ((b.gauntletJumpTicks || 0) > 0) return;
let px = Math.floor(Number(b.x)) || 0;
let py = Math.floor(Number(b.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
const { ch: gauntletCh } = getCharacterFootprintWHForMove(md);
let sameCell = false;
let incomingCol = false;
for (let i = 0; i < obstacles.length; i++) {
const o = obstacles[i];
if (!o) continue;
if (o.kind === 'lane' && typeof o.y === 'number') {
if (o.x === px && o.y >= py && o.y < py + gauntletCh) sameCell = true;
if (o.x === px + 1 && o.y >= py && o.y < py + gauntletCh) incomingCol = true;
}
if (o.kind === 'laser' && typeof o.x === 'number') {
if (o.x === px && gauntletLaserOverlapsRowServer(o, py, h)) sameCell = true;
if (o.x === px + 1 && gauntletLaserOverlapsRowServer(o, py, h)) incomingCol = true;
}
}
const { pSame, pAhead } = gauntletBotJumpProbs(b.tier || 'avg');
const roll = Math.random();
if (sameCell && roll < pSame) {
b.gauntletJumpTicks = getGauntletJumpTicks();
return;
}
if (incomingCol && roll < pAhead) b.gauntletJumpTicks = getGauntletJumpTicks();
}
/** ฟิสิกส์บอท 1 ตัว 1 tick (server) — mirror applyGauntletPhysicsToPreviewBot + runGauntletTick peer loop */
function gauntletBotStepPhysics(b, obstacles, md, w, h, crownHeist) {
if (crownHeist && b.gauntletEliminated) return;
let px = Math.floor(Number(b.x)) || 0;
let py = Math.floor(Number(b.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
const air = (b.gauntletJumpTicks || 0) > 0;
const { ch: gauntletCh } = getCharacterFootprintWHForMove(md);
let advanceX = false;
let hitBack = false;
for (const o of 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 && gauntletLaserOverlapsRowServer(o, py, h)) {
if (air) advanceX = true;
else hitBack = true;
}
}
if (advanceX) {
px = Math.min(w - 2, px + 1);
b.gauntletJumpTicks = 0;
if (!crownHeist) b.gauntletScore = (b.gauntletScore || 0) + 1;
} else if (hitBack) {
if (crownHeist) {
if (px <= 0) {
b.gauntletEliminated = true;
b.gauntletScore = 0;
} else {
b.gauntletScore = Math.max(0, (b.gauntletScore || 0) - 10);
px = Math.max(0, px - 1);
}
} else {
px = Math.max(0, px - 1);
}
}
if ((b.gauntletJumpTicks || 0) > 0) b.gauntletJumpTicks--;
b.x = px;
b.y = py;
}
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,
finishEndAt: null,
};
}
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',
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
characterId: p.characterId ?? null,
/* สีจาก lobbyB — กัน client ที่ re-create peer ผ่าน gauntlet-sync ได้สี hash สุ่มไม่ตรง */
lobbyColorThemeIndex: p.lobbyColorThemeIndex || null,
lobbySkinToneIndex: p.lobbySkinToneIndex || null,
gauntletJumpTicks: p.gauntletJumpTicks || 0,
gauntletScore: p.gauntletScore || 0,
gauntletEliminated: !!p.gauntletEliminated,
}));
/* บอท server-authoritative — ส่งตำแหน่ง/คะแนน/สถานะให้ทุก client (identity/สี client gen จาก id) */
if (gr.bots && gr.bots.size) {
gr.bots.forEach((b, id) => {
const row = {
id,
x: b.x,
y: b.y,
direction: b.dir || 'right',
isBot: true,
gauntletJumpTicks: b.gauntletJumpTicks || 0,
gauntletScore: b.gauntletScore || 0,
gauntletEliminated: !!b.gauntletEliminated,
};
/* บอท takeover (คนหลุด → id เป็น socket.id จริง ไม่ใช่ __pv_bot_) — ส่งชื่อ+"(บอท)"+สี เดิม
ให้ client คงชื่อ/สีเดิมไว้ แม้ avatar ถูก re-create จาก sync (กันชื่อกลับเป็น "ผู้เล่น" + สีเพี้ยน) */
if (b.takeover) {
row.isTakeover = true;
const baseNick = String(b.nickname || '').trim();
if (baseNick) row.nickname = baseNick.indexOf('(บอท)') >= 0 ? baseNick : (baseNick + ' (บอท)');
if (b.characterId != null) row.characterId = b.characterId;
if (b.lobbyColorThemeIndex != null) row.lobbyColorThemeIndex = b.lobbyColorThemeIndex;
if (b.lobbySkinToneIndex != null) row.lobbySkinToneIndex = b.lobbySkinToneIndex;
}
playersOut.push(row);
});
}
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,
gauntletLiveStartMs: gr.liveStartMs || null, /* G1: เวลาเริ่ม run (server) → client anchor scroll เดียวกันทุกเครื่อง */
...getGauntletVisualsForClient(),
});
}
/* ============================================================
MG3 Stack Tower — server-authoritative engine (mnn93hpi)
server เป็นเจ้าของ: ลำดับตา (turn order), เฟสแกว่ง (swing), ดรอปบอท, คะแนน/ความคืบหน้า, จบเกม
แล้ว emit event เดิม (stack-state/swing/drop/over) ไปทุกเครื่อง (io.to) → client เป็น renderer
(client เลิก host-sim เมื่อได้ stack-state ที่มี srv:true) — บอท avatar ยังถูก spawn ฝั่ง client ตามเดิม
============================================================ */
const STACK_OVERLAP_MISS_MIN_ON_LAND = 0.42;
const STACK_OVERLAP_MISS_FRAC_ON_LAND = 0.14;
const STACK_OVERLAP_MISS_MIN_ON_LAYER = 0.4;
const STACK_OVERLAP_MISS_FRAC_ON_LAYER = 0.3;
const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38;
const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0;
const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5;
const STACK_TICK_MS = 60;
const STACK_DROP_BUSY_MS = 1500; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป */
const STACK_BOT_THINK_MIN_MS = 560;
const STACK_BOT_THINK_MAX_MS = 1180;
const STACK_SEATS = 6;
function stackBoundsServer(area, w, h) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, any = false;
for (let y = 0; y < h; y++) {
const row = area && area[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1) { any = true; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); }
}
}
if (!any) return null;
return { minX, minY, maxX, maxY, cx: (minX + maxX + 1) / 2, cy: (minY + maxY + 1) / 2 };
}
function stackProgressBlocksServer() {
const n = Number(runtimeGameTiming.stackTowerProgressBlocks);
return Number.isFinite(n) && n >= 1 ? Math.max(1, Math.min(500, Math.floor(n))) : 50;
}
function stackTimeLimitSecServer() {
const t = Number(runtimeGameTiming.stackTowerMissionTimeSec);
return Number.isFinite(t) && t > 0 ? Math.max(10, Math.min(7200, Math.floor(t))) : 90;
}
function stackTeamMissesServer() {
const m = Math.floor(Number(runtimeGameTiming.stackTeamMissesMax) || 3);
return Math.max(1, Math.min(20, m));
}
function newStackSessionState(space, md) {
const w = md.width || 20, h = md.height || 15;
const land = stackBoundsServer(md.stackLandArea, w, h);
let towerCX = land ? land.cx : w * 0.5;
let swingAmp = 1.85;
if (land) {
const span = Math.max(1, land.maxX - land.minX + 1);
swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
}
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
const cfgW = Number(runtimeGameTiming.stackBlockWidthTiles);
const initW = Number.isFinite(cfgW) && cfgW > 0 ? Math.max(0.85, Math.min(3.2, cfgW)) : autoW;
const hz = Number(runtimeGameTiming.stackSwingHz);
return {
active: true,
topCenterX: towerCX,
widthTiles: initW,
initialWidthTiles: initW,
swingAmp,
phase: Math.random() * Math.PI * 2,
phaseSpeed: Number.isFinite(hz) && hz > 0 ? hz : 0.6,
layers: [],
score: 0,
combo: 0,
progressPct: 0,
teamMissesLeft: stackTeamMissesServer(),
turnOrder: [],
turnIndex: 0,
bots: new Map(), /* botId -> { tier, score, takeover?, takeoverPlayerKey? } */
humanPts: {}, /* peerId -> pts */
startMs: Date.now(),
busyUntil: 0,
botThinkUntil: 0,
botAimPhase: null, /* เฟสเป้าหมายฝั่งหน้า (ปล่อยตอนแกว่งวิ่งมาถึงเองธรรมชาติ — ไม่สแนป) */
botAimDeadline: 0,
lastSwingEmit: 0,
lastStateEmit: 0,
ended: false,
timerId: null,
};
}
/* port computeStackDropResult (gameplay-only) */
function stackComputeDropServer(s, md) {
const raw = s.swingAmp * Math.sin(s.phase);
const w = md.width || 20, h = md.height || 15;
const land = stackBoundsServer(md.stackLandArea, w, h);
const fallW = Math.max(0.85, Math.min(3.2, s.widthTiles != null ? s.widthTiles : s.initialWidthTiles));
const wMul = STACK_TOWER_BLOCK_WORLD_SCALE;
const fallWEff = fallW * wMul;
let supportCx, supportW;
if (s.layers.length === 0) {
supportCx = land ? land.cx : w * 0.5;
supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
} else {
const sup = s.layers[s.layers.length - 1];
supportCx = sup.cx;
supportW = Math.max(0.85, Math.min(3.2, sup.w != null ? sup.w : s.initialWidthTiles));
}
const c1 = s.topCenterX + raw;
const half1 = fallWEff * 0.5;
const half0 = (s.layers.length === 0 ? supportW : supportW * wMul) * 0.5;
const left = Math.max(supportCx - half0, c1 - half1);
const right = Math.min(supportCx + half0, c1 + half1);
const overlap = right - left;
const missThreshold = s.layers.length === 0
? Math.max(STACK_OVERLAP_MISS_MIN_ON_LAND, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAND)
: Math.max(STACK_OVERLAP_MISS_MIN_ON_LAYER, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAYER);
const overlapMiss = overlap < missThreshold;
const placedCx = c1;
let driftMiss = false;
if (!overlapMiss && s.layers.length > 0) {
const anchorCx = land ? land.cx : w * 0.5;
const maxDrift = land
? Math.max(1, land.maxX - land.minX + 1) * STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND
: Math.max(0.85, fallW * STACK_MAX_CENTER_DRIFT_NO_LAND_MULT);
if (Math.abs(placedCx - anchorCx) > maxDrift) driftMiss = true;
}
const miss = overlapMiss || driftMiss;
const delta = c1 - supportCx;
const span = supportW * 0.5 + fallW * 0.5;
const offN = Math.abs(delta) / Math.max(0.01, span);
const perfect = !miss && offN < 0.055;
const comboBefore = s.combo || 0;
const mult = !miss && perfect && comboBefore >= 1 ? 2 : 1;
const progPerLayer = 100 / stackProgressBlocksServer();
const pts = miss ? 0 : (10 * mult);
const progressDelta = miss ? 0 : (progPerLayer * mult);
const supportRatio = fallWEff > 0 ? overlap / fallWEff : 0;
return { miss, perfect, pts, progressDelta, landOffsetTiles: raw, placedW: fallW, placedCx, overlap, supportRatio, delta };
}
/* port applyStackHitFromDrop (state-only, no FX) */
function stackApplyHitServer(s, hit, actor) {
if (hit.miss) {
s.combo = 0;
s.teamMissesLeft = Math.max(0, (s.teamMissesLeft != null ? s.teamMissesLeft : stackTeamMissesServer()) - 1);
return;
}
s.layers.push({ w: hit.placedW, cx: hit.placedCx });
s.score += hit.pts;
if (hit.progressDelta > 0) s.progressPct = Math.min(100, s.progressPct + hit.progressDelta);
s.combo = hit.perfect ? s.combo + 1 : 0;
s.topCenterX = hit.placedCx;
if (actor) {
if (actor.botId) { const b = s.bots.get(actor.botId); if (b) b.score = (b.score || 0) + hit.pts; }
else if (actor.id != null) { s.humanPts[actor.id] = (s.humanPts[actor.id] || 0) + hit.pts; }
}
}
/* port previewBotAlignStackSwingPhasePlay — เลื่อนเฟสให้บอทเล็งก่อนปล่อย (ตาม tier) */
function stackBotAlignServer(s, md, tier) {
const w = md.width || 20, h = md.height || 15;
const land = stackBoundsServer(md.stackLandArea, w, h);
const fallW = Math.max(0.85, Math.min(3.2, s.widthTiles != null ? s.widthTiles : s.initialWidthTiles));
let supportCx, supportW;
if (s.layers.length === 0) { supportCx = land ? land.cx : w * 0.5; supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2); }
else { const sup = s.layers[s.layers.length - 1]; supportCx = sup.cx; supportW = Math.max(0.85, Math.min(3.2, sup.w != null ? sup.w : s.initialWidthTiles)); }
const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17;
const layerTight = 1 / Math.sqrt(1 + (s.layers.length || 0) * 1.05);
const tri = Math.random() + Math.random() - 1;
let errTiles = tri * errMax * layerTight;
if (s.layers.length >= 2) { const L = s.layers.length; errTiles -= (s.layers[L - 1].cx - s.layers[L - 2].cx) * 0.28; }
let targetCx = supportCx + errTiles;
if (land && s.layers.length > 0) {
const pull = (land.cx - supportCx) * 0.1;
const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06;
targetCx += Math.max(-cap, Math.min(cap, pull));
}
const rawWant = targetCx - s.topCenterX;
const amp = Math.max(1e-5, s.swingAmp);
const sNorm = Math.max(-1, Math.min(1, rawWant / amp));
const phi0 = Math.asin(sNorm);
const phi1 = Math.PI - phi0;
const twoPi = Math.PI * 2;
const nearest = (anchor, p) => { let best = p, bd = Infinity; for (let k = -5; k <= 5; k++) { const c = p + k * twoPi; const dd = Math.abs(c - anchor); if (dd < bd) { bd = dd; best = c; } } return best; };
const n0 = nearest(s.phase, phi0), n1 = nearest(s.phase, phi1);
s.phase = Math.abs(n0 - s.phase) <= Math.abs(n1 - s.phase) ? n0 : n1;
}
/* เหมือน stackBotAlignServer แต่ "ไม่สแนป" — คืนเฟสเป้าหมายที่อยู่ "ข้างหน้า" (forward) ของเฟสปัจจุบัน
ให้บอทรอแกว่งวิ่งมาถึงเองตามธรรมชาติแล้วค่อยปล่อย → client เห็นแกว่งลื่น ไม่กระตุก (กันบล็อกวาปก่อนวาง) */
function stackBotComputeAimPhaseServer(s, md, tier) {
const w = md.width || 20, h = md.height || 15;
const land = stackBoundsServer(md.stackLandArea, w, h);
let supportCx;
if (s.layers.length === 0) { supportCx = land ? land.cx : w * 0.5; }
else { supportCx = s.layers[s.layers.length - 1].cx; }
const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17;
const layerTight = 1 / Math.sqrt(1 + (s.layers.length || 0) * 1.05);
const tri = Math.random() + Math.random() - 1;
let errTiles = tri * errMax * layerTight;
if (s.layers.length >= 2) { const L = s.layers.length; errTiles -= (s.layers[L - 1].cx - s.layers[L - 2].cx) * 0.28; }
let targetCx = supportCx + errTiles;
if (land && s.layers.length > 0) {
const pull = (land.cx - supportCx) * 0.1;
const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06;
targetCx += Math.max(-cap, Math.min(cap, pull));
}
const rawWant = targetCx - s.topCenterX;
const amp = Math.max(1e-5, s.swingAmp);
const sNorm = Math.max(-1, Math.min(1, rawWant / amp));
const phi0 = Math.asin(sNorm);
const phi1 = Math.PI - phi0;
const twoPi = Math.PI * 2;
/* หาเฟสรูปแบบ {phi0,phi1}+2πk ที่ "มากกว่า" เฟสปัจจุบันเล็กน้อย (รอบถัดไปตามทิศแกว่ง) */
const margin = 0.06;
let best = Infinity;
for (const base of [phi0, phi1]) {
let k = Math.ceil((s.phase + margin - base) / twoPi);
let cand = base + k * twoPi;
if (cand < s.phase + margin) cand += twoPi;
if (cand < best) best = cand;
}
return Number.isFinite(best) ? best : s.phase + twoPi * 0.5;
}
/* สร้างลำดับตา: humans (เรียง spawnJoinOrder) + bots เติมที่ว่าง ≤ 6 ที่นั่ง */
function buildStackTurnOrderServer(space, s) {
const humans = [...space.peers.entries()].filter(([id]) => !isBannedPeerForRun(space, id));
humans.sort((a, b) => (((a[1].spawnJoinOrder || 0) - (b[1].spawnJoinOrder || 0)) || (String(a[0]) < String(b[0]) ? -1 : 1)));
const order = [];
let seat = 1;
for (const [id, p] of humans) {
if (seat > STACK_SEATS) break;
order.push({ kind: 'human', seat, id, botId: null, theme: (p.lobbyThemeIndex != null ? (Number(p.lobbyThemeIndex) % 8) + 1 : null) });
seat++;
}
const botCount = Math.max(0, Math.min(STACK_SEATS - order.length, effectiveBotSlotCount(space)));
let bannedBotSlot = -1;
const bannedId = space.bannedThisRunPlayerId;
if (typeof bannedId === 'string' && bannedId.indexOf('__lobby_bot_') === 0) {
const n = parseInt(bannedId.slice('__lobby_bot_'.length), 10);
if (Number.isFinite(n) && n >= 0) bannedBotSlot = n;
}
const want = bannedBotSlot >= 0 ? Math.max(0, botCount - 1) : botCount;
let created = 0, slot = 0;
while (created < want && slot < STACK_SEATS + botCount && seat <= STACK_SEATS) {
if (slot === bannedBotSlot) { slot++; continue; }
const botId = GAUNTLET_BOT_PREFIX + slot;
if (!s.bots.has(botId)) s.bots.set(botId, { tier: gauntletBotTierForSlot(slot), score: 0 });
order.push({ kind: 'bot', seat, id: null, botId, theme: ((slot % 8) + 1) });
created++; slot++; seat++;
}
s.turnOrder = order;
if (s.turnIndex >= order.length) s.turnIndex = 0;
}
function stackCurrentTurnEntry(s) {
if (!s.turnOrder || !s.turnOrder.length) return null;
const n = s.turnOrder.length;
return s.turnOrder[((s.turnIndex % n) + n) % n] || null;
}
function emitStackStateServer(sid, space) {
const s = space.stackSession;
if (!s) return;
const lthemes = s.layers.map((l) => (l.theme && l.theme >= 1 && l.theme <= 8 ? l.theme : 0));
io.to(sid).emit('stack-state', {
srv: true,
order: s.turnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId, theme: e.theme })),
idx: s.turnIndex,
hostId: null,
lthemes,
});
}
function stackAdvanceTurnServer(s) {
if (!s.turnOrder || !s.turnOrder.length) return;
s.turnIndex = (s.turnIndex + 1) % s.turnOrder.length;
s.botThinkUntil = 0;
s.botAimPhase = null;
}
function stackSessionMissionGrade(space) {
const s = space.stackSession;
if (!s) return 'C';
const scores = [];
s.turnOrder.forEach((e) => {
if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); scores.push(Math.max(0, (b && b.score) | 0)); }
else if (e.id != null) scores.push(Math.max(0, s.humanPts[e.id] | 0));
});
const n = scores.length || 1;
const avg = Math.floor(scores.reduce((a, b) => a + b, 0) / n);
let grade = avg >= 80 ? 'A' : avg >= 60 ? 'B' : 'C';
return grade;
}
function stackSessionEnd(sid, space, outcome) {
const s = space.stackSession;
if (!s || s.ended) return;
s.ended = true;
stopStackTicker(space);
/* คะแนนผู้เล่นจริง → reportedMiniScore (ใช้ทางเดิมของ coin/evidence) */
s.turnOrder.forEach((e) => {
if (e.kind === 'human' && e.id != null) {
const p = space.peers.get(e.id);
if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; }
}
});
try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; }
const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' };
io.to(sid).emit('stack-over', overPayload);
space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() };
}
function runStackTick(sid, space) {
const s = space.stackSession;
if (!s || !s.active || s.ended) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; }
const now = Date.now();
/* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */
if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; }
if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; }
if ((now - s.startMs) >= stackTimeLimitSecServer() * 1000) { stackSessionEnd(sid, space, 'time_up'); return; }
if (!s.turnOrder.length) return;
/* รอแอนิเมชันบล็อกก่อนหน้า (busy) → ไม่แกว่ง/ไม่ดรอป */
if (now < s.busyUntil) return;
const entry = stackCurrentTurnEntry(s);
if (!entry) return;
if (entry.kind === 'bot' && entry.botId) {
const bot = s.bots.get(entry.botId);
const tier = bot ? bot.tier : 'avg';
if (s.botThinkUntil === 0) {
s.botThinkUntil = now + STACK_BOT_THINK_MIN_MS + Math.floor(Math.random() * (STACK_BOT_THINK_MAX_MS - STACK_BOT_THINK_MIN_MS));
} else {
/* เลื่อนเฟสตามเวลา (ให้ client เห็นแกว่ง) */
s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2;
/* ส่ง swingAmp+topCenterX ด้วย → client วาดบล็อกที่ห้อยตรงกับโมเดล server เป๊ะ (drop landOffset = swingAmp*sin(phase)) ไม่วาปตอนปล่อย */
if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX }); }
if (now >= s.botThinkUntil) {
/* คิดเสร็จ → ล็อกเฟสเป้าหมาย "ข้างหน้า" ครั้งเดียว แล้วรอแกว่งวิ่งมาถึงเองธรรมชาติ
(ไม่สแนปเฟส = ไม่กระตุก) ปล่อยเมื่อถึงเป้า หรือเลย deadline กันค้าง */
if (s.botAimPhase == null) {
s.botAimPhase = stackBotComputeAimPhaseServer(s, md, tier);
s.botAimDeadline = now + 2600;
}
if (!(s.phase >= s.botAimPhase || now >= s.botAimDeadline)) return;
s.botAimPhase = null;
const hit = stackComputeDropServer(s, md);
stackApplyHitServer(s, hit, { botId: entry.botId });
io.to(sid).emit('stack-drop', {
placedCx: hit.placedCx, placedW: hit.placedW, miss: hit.miss, perfect: hit.perfect,
pts: hit.pts, progressDelta: hit.progressDelta, landOffsetTiles: hit.landOffsetTiles,
overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta,
seat: entry.seat, heavy: false, theme: entry.theme || null,
actorId: entry.botId, isBot: true,
phase: s.phase, /* เฟสจริง ณ ปล่อย → client snap เชือกให้ตรงบล็อกที่ร่วง (กันบล็อกไม่ตรงเชือก) */
});
s.busyUntil = now + STACK_DROP_BUSY_MS;
stackAdvanceTurnServer(s);
emitStackStateServer(sid, space);
}
}
} else {
/* ตาคนจริง — server แกว่งให้ + emit ให้ทุกเครื่อง (รวมเจ้าของตา) เห็นเฟสเดียวกัน รอ stack-drop-req */
s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2;
if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX }); }
}
/* broadcast stack-state เป็นระยะ (กัน late-join / desync) */
if (now - s.lastStateEmit > 700) { s.lastStateEmit = now; emitStackStateServer(sid, space); }
}
function startStackTicker(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'stack') return;
if (!space.detectiveMinigameActive) return; /* เกมจริงเท่านั้น — editor preview (เล่นเดี่ยว) คง client-sim เดิม */
if (space.stackSession && space.stackSession.timerId) return;
space.stackSession = newStackSessionState(space, md);
buildStackTurnOrderServer(space, space.stackSession);
emitStackStateServer(sid, space);
space.stackSession.timerId = setInterval(() => { runStackTick(sid, space); }, STACK_TICK_MS);
}
function stopStackTicker(space) {
if (space && space.stackSession && space.stackSession.timerId) {
clearInterval(space.stackSession.timerId);
space.stackSession.timerId = null;
}
}
/* ============================================================
MG5 Jump Survive — server-authoritative bots + รอบ/เกรด + disconnect takeover (mnptfts2)
ผู้เล่นจริง sim ฟิสิกส์เองในเครื่อง (ลื่น/ไม่หน่วง) แล้วรายงานตำแหน่ง+สถานะตาย ผ่าน 'move';
server เป็นเจ้าของ: บอท (port ฟิสิกส์ pixel-space ด้วย ts=md.tileSize คงที่), จับเวลา/จบรอบ, ตัดสินรอด/เกรด,
และแปลงคนหลุดเป็นบอท server. emit jump-survive-bot-move / jump-survive-player-to-bot / jump-survive-ended
(client เป็น renderer — เลิก host-sim บอทเมื่อ server-auth) — บอทใช้ id __pv_bot_N (ตรงกับ avatar ฝั่ง client)
============================================================ */
const JS_BOT_PREFIX = '__pv_bot_';
const JS_TICK_MS = 33; /* ~30fps server sim — client lerp tx/ty นุ่มอยู่แล้ว */
const JS_BOT_EMIT_MS = 60;
function jsTileSize(md) { const t = Math.floor(Number(md && md.tileSize)); return Number.isFinite(t) && t > 0 ? t : 32; }
function jsFootprint(md) {
if (!md) return { cw: 1, ch: 1 };
let cw = Math.floor(Number(md.characterCellsW));
let ch = Math.floor(Number(md.characterCellsH));
if (Number.isFinite(cw) && cw >= 1 && Number.isFinite(ch) && ch >= 1) {
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 jsPlatformPeriodPx(md, ts) { return (md.height || 15) * ts; }
function jsOverlapsObjectSolids(md, ts, left, top, right, bottom) {
const w = md.width || 20, h = md.height || 15;
const x0 = Math.floor(left / ts), x1 = Math.floor((right - 1e-6) / ts);
const y0 = Math.floor(top / ts), y1 = Math.floor((bottom - 1e-6) / ts);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (tx < 0 || tx >= w) return true;
if (ty < 0) continue;
if (ty >= h) continue; /* แพลตฟอร์มวนแนวตั้ง — ไม่ถือ ty>=h เป็นซีลพื้น */
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
if (ob === 1) return true;
}
}
return false;
}
function jsOverlapsObjectWall(md, ts, left, top, right, bottom) {
const w = md.width || 20, h = md.height || 15;
const x0 = Math.floor(left / ts), x1 = Math.floor((right - 1e-6) / ts);
const y0 = Math.floor(top / ts), y1 = Math.floor((bottom - 1e-6) / ts);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
if (ob === 1) return true;
}
}
return false;
}
function jsOverlapsPlatforms(md, ts, left, top, right, bottom, scrollPx) {
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
if (!pa) return false;
const period = jsPlatformPeriodPx(md, ts);
if (period < ts) return false;
const x0 = Math.max(0, Math.floor(left / ts));
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
const vm = ts * 2;
for (let ty = 0; ty < h; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!pa[ty] || pa[ty][tx] !== 1) continue;
const rawTop = ty * ts + scrollPx;
let kMin = Math.ceil((top - rawTop - ts - vm) / period);
let kMax = Math.floor((bottom - rawTop + vm) / period);
if (kMin > kMax) {
const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
kMin = kGuess; kMax = kGuess;
}
for (let k = kMin; k <= kMax; k++) {
const platTop = rawTop + k * period;
const platBot = platTop + ts;
const tileLeft = tx * ts, tileRight = (tx + 1) * ts;
if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
}
}
}
return false;
}
function jsOverlapsHazardArea(md, ts, left, top, right, bottom) {
const ha = md && md.jumpSurviveHazardArea;
if (!ha) return false;
const pa = md && md.jumpSurvivePlatformArea;
const w = md.width || 20, h = md.height || 15;
const x0 = Math.max(0, Math.floor(left / ts));
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
const y0 = Math.max(0, Math.floor(top / ts));
const y1 = Math.min(h - 1, Math.floor((bottom - 1e-6) / ts));
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!ha[ty] || ha[ty][tx] !== 1) continue;
if (pa && pa[ty] && pa[ty][tx] === 1) continue;
return true;
}
}
return false;
}
function jsOverlapsSolid(md, ts, left, top, right, bottom, scrollPx) {
if (jsOverlapsObjectSolids(md, ts, left, top, right, bottom)) return true;
return jsOverlapsPlatforms(md, ts, left, top, right, bottom, scrollPx);
}
function jsOverlapsSolidForHorzMove(md, ts, left, top, right, bottom, scrollPx) {
if (jsOverlapsObjectSolids(md, ts, left, top, right, bottom)) return true;
const footSlop = ts * 0.16;
const platformBottom = bottom - footSlop;
if (platformBottom <= top + ts * 0.05) return false;
return jsOverlapsPlatforms(md, ts, left, top, right, platformBottom, scrollPx);
}
/* port jumpSurvivePhysicsIntegrate (mission map เสมอ → moveX*1.35, jump mult 1.5) */
function jsPhysicsIntegrate(md, ts, scrollPx, opts) {
const { dt, ch, pxc0, feetY0, vy0, wasOnGround, jumpQueued, moveLeft, moveRight } = opts;
const w = md.width || 20, h = md.height || 15;
const bw = ts * 0.34;
const bh = ts * 0.9;
let pxc = pxc0, feetY = feetY0, vy = vy0, onGround = wasOnGround;
const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
const g = gFull * dt;
let jumpV0;
if (Number(md.jumpSurviveJumpImpulse) > 0) {
jumpV0 = -Number(md.jumpSurviveJumpImpulse);
} else {
const mult = 1.5;
const charHpixels = ch * ts;
const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
jumpV0 = -Math.min(impulse, ts * 36);
}
let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
moveX *= 1.35; /* mission map */
if (jumpQueued && onGround) {
vy = jumpV0; onGround = false;
} else if (wasOnGround) {
const platformRise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
feetY += platformRise;
}
vy += g;
let nx = pxc;
if (moveLeft) nx -= moveX;
if (moveRight) nx += moveX;
nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
const headY0 = feetY - bh;
if (!jsOverlapsSolidForHorzMove(md, ts, nx - bw, headY0, nx + bw, feetY, scrollPx)) pxc = nx;
feetY += vy * dt;
let headTop = feetY - bh;
if (vy > 0 && jsOverlapsSolid(md, ts, pxc - bw, headTop, pxc + bw, feetY, scrollPx)) {
const step = ts * 0.035;
const penetrationEst = Math.abs(vy) * dt + ts * 0.55;
const maxSteps = Math.min(160, Math.max(28, Math.ceil(penetrationEst / step) + 6));
let cleared = false;
for (let s = 0; s < maxSteps; s++) {
feetY -= step; headTop = feetY - bh;
if (!jsOverlapsSolid(md, ts, pxc - bw, headTop, pxc + bw, feetY, scrollPx)) { cleared = true; break; }
}
if (cleared) {
let lo = feetY, hi = feetY + step;
for (let i = 0; i < 20; i++) {
const mid = (lo + hi) * 0.5;
if (jsOverlapsSolid(md, ts, pxc - bw, mid - bh, pxc + bw, mid, scrollPx)) hi = mid; else lo = mid;
}
feetY = lo; headTop = feetY - bh;
}
if (feetY - bh < ts) {
feetY = ts + bh + ts; vy = Math.max(vy, ts * 6); onGround = false; headTop = feetY - bh;
} else { vy = 0; onGround = true; }
} else if (vy < 0 && jsOverlapsObjectSolids(md, ts, pxc - bw, headTop, pxc + bw, feetY)) {
const stepUp = ts * 0.035;
const penetrationEstUp = Math.abs(vy) * dt + ts * 0.55;
const maxStepsUp = Math.min(160, Math.max(28, Math.ceil(penetrationEstUp / stepUp) + 6));
for (let s = 0; s < maxStepsUp; s++) {
feetY += stepUp; headTop = feetY - bh;
if (!jsOverlapsObjectSolids(md, ts, pxc - bw, headTop, pxc + bw, feetY)) { vy = 0; break; }
}
} else { onGround = false; }
headTop = feetY - bh;
const headBandBottom = headTop + bh * 0.42;
if (vy < 0 && jsOverlapsSolid(md, ts, pxc - bw, headTop, pxc + bw, feetY, scrollPx)
&& jsOverlapsObjectWall(md, ts, pxc - bw, headTop, pxc + bw, headBandBottom)) vy = 0;
headTop = feetY - bh;
if (headTop < ts) { feetY = ts + bh; if (vy < 0) vy = ts * 6; headTop = feetY - bh; }
headTop = feetY - bh;
if (jsOverlapsHazardArea(md, ts, pxc - bw, headTop, pxc + bw, feetY)) return { pxc, feetY, vy, onGround, died: true };
if (feetY > h * ts + ts * 8) return { pxc, feetY, vy, onGround, died: true };
return { pxc, feetY, vy, onGround, died: false };
}
function jsBotHasFloorAhead(md, ts, pxc, feetY, wishDir, scrollPx) {
if (!wishDir) return true;
const bw = ts * 0.34;
const probeX = pxc + wishDir * (bw + ts * 0.82);
const pad = ts * 0.24;
return jsOverlapsPlatforms(md, ts, probeX - pad, feetY - ts * 0.45, probeX + pad, feetY + ts * 8, scrollPx);
}
function jsCollectPlatformCells(md) {
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
const out = [];
if (!pa) return out;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (pa[y] && pa[y][x] === 1) out.push({ x, y });
return out;
}
function jsGridPosStandingOnPlatform(md, ts, tx, ty, scrollPx) {
const h = md.height || 15;
const period = jsPlatformPeriodPx(md, ts);
const { cw, ch } = jsFootprint(md);
const rawTop = ty * ts + scrollPx;
const hintY = h * ts * 0.72; /* server ไม่มีกล้อง → ใช้ hint กลางค่อนล่างเหมือน client ตอนยังไม่มี camCenter */
const k = Math.round((hintY - rawTop - ts * 0.35) / period);
const platTop = rawTop + k * period;
return { x: (tx + 0.5) - cw * 0.5, y: platTop / ts - ch };
}
function jsScrollPx(s, md) {
const risePerSec = Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42;
const elapsed = Math.max(0, (Date.now() - (s.liveBeginsAt || s.startMs)) / 1000);
return elapsed * risePerSec;
}
function jsTimeLimitSec(md) {
/* ตรงกับ client jumpSurviveTimeLimitSecForMission: md.jumpSurviveTimeSec → game-timing.jumpSurviveMissionTimeSec → 60 */
const t = Number(md.jumpSurviveTimeSec);
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const rt = Number(runtimeGameTiming && runtimeGameTiming.jumpSurviveMissionTimeSec);
return Number.isFinite(rt) && rt > 0 && rt < 7200 ? Math.floor(rt) : 60;
}
function jsBannedBotSlot(space) {
const bannedId = space.bannedThisRunPlayerId;
if (typeof bannedId === 'string' && bannedId.indexOf('__lobby_bot_') === 0) {
const n = parseInt(bannedId.slice('__lobby_bot_'.length), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return -1;
}
function createJumpSurviveBots(space, md) {
const s = space.jumpSurviveSession;
const ts = jsTileSize(md);
const cells = jsCollectPlatformCells(md);
cells.sort((a, b) => (b.y - a.y) || (a.x - b.x)); /* ล่างก่อน (ใกล้พื้นเริ่ม) */
const sc = 0;
const pool = cells.length
? cells.map((c) => jsGridPosStandingOnPlatform(md, ts, c.x, c.y, sc))
: [jsGridPosStandingOnPlatform(md, ts, Number(md.spawn && md.spawn.x) || 1, Number(md.spawn && md.spawn.y) || 1, sc)];
const humanCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length;
const botCount = Math.max(0, Math.min(6 - Math.min(humanCount, 6), effectiveBotSlotCount(space)));
const bannedSlot = jsBannedBotSlot(space);
let poolIdx = 1 + humanCount;
let created = 0, slot = 0;
while (created < botCount && slot < 64) {
if (slot === bannedSlot) { slot++; continue; }
const id = JS_BOT_PREFIX + slot;
const p = pool[poolIdx % pool.length] || pool[0];
poolIdx++;
s.bots.set(id, {
tier: 'avg',
x: p.x + (Math.random() - 0.5) * 0.08,
y: p.y + (Math.random() - 0.5) * 0.08,
vy: 0, onGround: true, dir: 'down', walking: false, eliminated: false,
wishX: 0, wishNext: Date.now() + 200, nextJumpAi: Date.now() + 300 + Math.floor(Math.random() * 500),
takeover: false, takeoverPlayerKey: '',
});
created++; slot++;
}
}
function startJumpSurviveGame(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'jump_survive') return;
if (!space.detectiveMinigameActive) return; /* เกมจริงเท่านั้น — editor preview คง client-sim */
if (space.jumpSurviveSession && space.jumpSurviveSession.active) return;
const now = Date.now();
const liveBeginsAt = space.missionLiveBeginsAt && space.missionLiveBeginsAt > now ? space.missionLiveBeginsAt : now;
space.jumpSurviveSession = {
active: true, ended: false,
startMs: liveBeginsAt, liveBeginsAt,
/* +grace: client (anchored serverNow) จบ time_up ก่อนเสมอ; server เป็น backstop เผื่อ host หลุด/ไม่มีใครจบ */
endsAt: liveBeginsAt + jsTimeLimitSec(md) * 1000 + 2000,
bots: new Map(),
players: {}, /* peerId -> { eliminated } */
lastEmit: 0,
};
createJumpSurviveBots(space, md);
}
function endJumpSurviveGame(sid, space, reason) {
const s = space.jumpSurviveSession;
if (!s || s.ended) return;
s.ended = true; s.active = false;
/* นับผู้รอด (คนจริงจาก players + บอท server ที่ยังไม่ตาย) → เกรดจากจำนวนรอด */
let survivors = 0;
space.peers.forEach((p, id) => {
const st = s.players[id];
const dead = st && st.eliminated;
if (!dead) survivors++;
if (p) { p.reportedMiniSurvived = !dead; p.reportedMiniScore = dead ? 0 : 100; }
});
s.bots.forEach((b) => { if (b && !b.eliminated) survivors++; });
const grade = survivors >= 6 ? 'A' : survivors === 5 ? 'B' : survivors === 4 ? 'C' : survivors === 3 ? 'D' : survivors === 2 ? 'E' : 'F';
space.lastJumpSurviveGrade = grade;
io.to(sid).emit('jump-survive-ended', { reason: reason || 'time_up', grade, survivors });
}
function stepJumpSurviveBots(sid, space, dt, now) {
const s = space.jumpSurviveSession;
if (!s || !s.active || s.ended) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'jump_survive') return;
/* ขยับบอทเฉพาะหลังนับถอยหลัง 3-2-1 จบ (liveBeginsAt) — ก่อนหน้านี้บอทเริ่มทันทีที่ session active
ทำให้บอทวิ่ง/กระโดดก่อน "1" จบ → กันด้วยการ return จนถึงเวลาจริง (เหมือน scroll ที่อิง liveBeginsAt อยู่แล้ว) */
if (now < (s.liveBeginsAt || s.startMs || 0)) return;
/* จบรอบ: หมดเวลา หรือทุก entity ตาย */
if (now >= s.endsAt) { endJumpSurviveGame(sid, space, 'time_up'); return; }
const ts = jsTileSize(md);
const { cw, ch } = jsFootprint(md);
const h = md.height || 15;
const wpx = (md.width || 20) * ts;
const centerPx = wpx * 0.5;
const sc = jsScrollPx(s, md);
const lowDangerY = h * ts * 0.60;
const outBots = [];
s.bots.forEach((b, id) => {
if (b.eliminated) return;
let pxc = (b.x + cw * 0.5) * ts;
let feetY = (b.y + ch) * ts;
const lowDanger = feetY > lowDangerY;
if (now >= b.wishNext) {
const wMin = lowDanger ? 180 : 380;
const wSpan = lowDanger ? 480 : 700;
b.wishNext = now + wMin + Math.floor(Math.random() * wSpan);
if (lowDanger && Math.random() < 0.55) b.wishX = 0;
else if (pxc < centerPx - ts * 1.8) b.wishX = 1;
else if (pxc > centerPx + ts * 1.8) b.wishX = -1;
else b.wishX = Math.random() < 0.3 ? (Math.random() < 0.5 ? -1 : 1) : 0;
}
let moveLeft = b.wishX < 0, moveRight = b.wishX > 0;
let hazardJump = false;
if (b.onGround) {
if (moveRight && !jsBotHasFloorAhead(md, ts, pxc, feetY, 1, sc)) { b.wishX = Math.random() < 0.65 ? -1 : 0; hazardJump = true; }
else if (moveLeft && !jsBotHasFloorAhead(md, ts, pxc, feetY, -1, sc)) { b.wishX = Math.random() < 0.65 ? 1 : 0; hazardJump = true; }
moveLeft = b.wishX < 0; moveRight = b.wishX > 0;
}
let jumpQ = false;
if (hazardJump) b.nextJumpAi = Math.min(b.nextJumpAi, now);
if (b.onGround && now >= b.nextJumpAi) {
b.nextJumpAi = now + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100));
let jp = 0.36 + 0.12; /* mission */
if (lowDanger) jp += 0.22;
jp = Math.min(0.9, jp);
if (hazardJump || Math.random() < jp) jumpQ = true;
}
const r = jsPhysicsIntegrate(md, ts, sc, {
dt, ch, pxc0: pxc, feetY0: feetY, vy0: b.vy, wasOnGround: b.onGround, jumpQueued: jumpQ, moveLeft, moveRight,
});
b.x = r.pxc / ts - cw * 0.5;
b.y = r.feetY / ts - ch;
if (r.died) {
b.eliminated = true; b.vy = 0; b.walking = false;
outBots.push({ id, x: b.x, y: b.y, dir: b.dir, walking: false, elim: true });
return;
}
b.vy = r.vy; b.onGround = r.onGround;
if (moveRight) b.dir = 'right'; else if (moveLeft) b.dir = 'left';
b.walking = (!r.onGround && Math.abs(r.vy) > 40) || moveLeft || moveRight;
outBots.push({ id, x: b.x, y: b.y, dir: b.dir, walking: b.walking, elim: false });
});
if (now - s.lastEmit >= JS_BOT_EMIT_MS) {
s.lastEmit = now;
if (outBots.length) io.to(sid).emit('jump-survive-bot-move', { bots: outBots });
}
/* ทุก entity ตายหมด → จบรอบ (all_dead) */
let anyAlive = false;
space.peers.forEach((p, id) => { const st = s.players[id]; if (!(st && st.eliminated)) anyAlive = true; });
if (!anyAlive) s.bots.forEach((b) => { if (!b.eliminated) anyAlive = true; });
if (!anyAlive) endJumpSurviveGame(sid, space, 'all_dead');
}
/* ============================================================
MG6 space_shooter — server-authoritative บอท + รอบ + disconnect takeover (mnpz6rkp)
โลก (อุกาบาต deterministic ต่อ client, กระสุนคน relay) คงเดิม → host หลุด asteroid/timer ไม่พัง;
server ขับ "บอท" (เดิน+ยิง+คะแนน), จับเวลา/จบรอบ (backstop), แปลงคนหลุดเป็นบอท.
reuse 'space-shooter-shot' (กระสุนบอท) + emit 'space-shooter-bot-move' + จบรอบด้วย 'space-shooter-over'
ผู้เล่นจริง sim ยาน/อุกาบาต/ชน เอง (รายงานคะแนน/ตาย ผ่าน 'move')
============================================================ */
const SS_TICK_MS = 50;
const SS_BOT_EMIT_MS = 60;
const SS_MAX_ASTEROID_HITS = 3; /* ต้องตรงกับ client SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS */
function ssTimeLimitSec(md) {
const t = Number(md.spaceShooterTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const rt = Number(runtimeGameTiming && runtimeGameTiming.spaceShooterMissionTimeSec);
return Number.isFinite(rt) && rt > 0 && rt < 7200 ? Math.floor(rt) : 60;
}
function ssClampCy(cy, mh) { const lo = mh * 0.5; const hi = Math.max(lo + 4, mh - 20); return Math.max(lo, Math.min(hi, cy)); }
function ssShooterSpawnCenter(md, slot, ts) {
const w = md.width || 20, h = md.height || 15;
const g = md.shooterSpawnSlots;
if (!g) return null;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { if (g[y] && Number(g[y][x]) === slot) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts }; }
return null;
}
function createSpaceShooterBots(space, md) {
const s = space.spaceShooterSession;
const ts = jsTileSize(md);
const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
const humanCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length;
const botCount = Math.max(0, Math.min(6 - Math.min(humanCount, 6), effectiveBotSlotCount(space)));
const bannedSlot = jsBannedBotSlot(space);
let created = 0, slot = 0;
while (created < botCount && slot < 64) {
if (slot === bannedSlot) { slot++; continue; }
const id = JS_BOT_PREFIX + slot;
const slotNum = ((humanCount + created) % 6) + 1;
const cen = ssShooterSpawnCenter(md, slotNum, ts);
let cx = cen ? cen.cx : ((created + 1) / (botCount + 1)) * mw;
let cy = cen ? cen.cy : (mh - ts * 1.5);
cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
cy = ssClampCy(cy, mh);
s.bots.set(id, {
tier: 'avg', cx, cy, dir: 'up', score: 0, eliminated: false, ssHits: 0,
fireCd: 0.3 + Math.random() * 0.4,
wanderVx: Math.random() < 0.5 ? -1 : 1, wanderVy: Math.random() < 0.5 ? -1 : 1, wanderNext: Date.now() + 600,
takeover: false, takeoverPlayerKey: '',
});
created++; slot++;
}
}
function startSpaceShooterGame(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'space_shooter') return;
if (!space.detectiveMinigameActive) return;
if (space.spaceShooterSession && space.spaceShooterSession.active) return;
const now = Date.now();
const liveBeginsAt = space.missionLiveBeginsAt && space.missionLiveBeginsAt > now ? space.missionLiveBeginsAt : now;
const lim = ssTimeLimitSec(md);
space.spaceShooterSession = {
active: true, ended: false, startMs: liveBeginsAt, liveBeginsAt,
endsAt: lim > 0 ? liveBeginsAt + lim * 1000 + 2000 : 0, /* +grace: client จบ time_up ก่อน; server backstop */
bots: new Map(), players: {}, lastEmit: 0,
};
createSpaceShooterBots(space, md);
}
function endSpaceShooterGame(sid, space, reason) {
const s = space.spaceShooterSession;
if (!s || s.ended) return;
s.ended = true; s.active = false;
const scores = [];
space.peers.forEach((p, id) => {
const st = s.players[id];
const dead = st && st.eliminated;
scores.push({ id, score: Math.max(0, p.spaceShooterScore | 0), hits: 0, eliminated: !!dead });
});
s.bots.forEach((b, id) => scores.push({ id, score: Math.max(0, b.score | 0), hits: 0, eliminated: !!b.eliminated }));
io.to(sid).emit('space-shooter-over', { kind: reason === 'all_dead' ? 'all_dead' : 'time_up', scores, srv: true });
}
function stepSpaceShooterBots(sid, space, dt, now) {
const s = space.spaceShooterSession;
if (!s || !s.active || s.ended) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'space_shooter') return;
/* คำถามพิเศษเปิดอยู่ → หยุดบอท (เดิน+ยิง) + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน gauntlet) */
if (isSpecialQuizPausedServer(space)) { if (s.endsAt > 0) s.endsAt += SS_TICK_MS; return; }
if (s.endsAt > 0 && now >= s.endsAt) { endSpaceShooterGame(sid, space, 'time_up'); return; }
const ts = jsTileSize(md);
const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
const padX = ts * 0.6;
const outBots = [];
s.bots.forEach((b, id) => {
if (b.eliminated) return;
if (now >= b.wanderNext) {
b.wanderNext = now + 400 + Math.floor(Math.random() * 700);
b.wanderVx = Math.random() < 0.5 ? -1 : (Math.random() < 0.5 ? 1 : 0);
b.wanderVy = Math.random() < 0.5 ? -1 : 1;
}
const spd = b.tier === 'sharp' ? 195 : b.tier === 'weak' ? 115 : 155;
b.cx = Math.max(padX, Math.min(mw - padX, b.cx + b.wanderVx * spd * dt));
b.cy = ssClampCy(b.cy + b.wanderVy * spd * 0.55 * dt, mh);
b.fireCd -= dt;
if (b.fireCd <= 0) {
b.fireCd = b.tier === 'sharp' ? 0.22 : b.tier === 'weak' ? 0.48 : 0.34;
const bX = b.cx, bY = b.cy - 16;
io.to(sid).emit('space-shooter-shot', { x: bX, y: bY, vy: -500, ownerId: id, t0: now });
/* คะแนนบอท: โอกาสยิงโดน (ตาม tier) → +5 (ทำให้คะแนนสมเหตุผลโดยไม่ต้องจำลอง asteroid ฝั่ง server) */
const hitProb = b.tier === 'sharp' ? 0.6 : b.tier === 'weak' ? 0.28 : 0.42;
if (Math.random() < hitProb) b.score += 5;
}
outBots.push({ id, cx: b.cx, cy: b.cy, dir: 'up', score: b.score, elim: false, hits: b.ssHits || 0 });
});
if (now - s.lastEmit >= SS_BOT_EMIT_MS) {
s.lastEmit = now;
if (outBots.length) io.to(sid).emit('space-shooter-bot-move', { bots: outBots });
}
}
/* ============================================================
MG7 balloon_boss — server-authoritative บอท + รอบ + disconnect takeover (mnq1eml7)
โลก (บอส/กระสุนบอส deterministic ต่อ client, กระสุนคน relay) คงเดิม → host หลุดไม่พัง;
server ขับ "บอท" (ลอย+ยิง+คะแนน+ดาเมจบอส), รวม HP บอส (host-independent), จบรอบ, แปลงคนหลุดเป็นบอท.
reuse 'balloon-boss-shot' (กระสุนบอท) + emit 'balloon-boss-bot-move' + จบรอบด้วย 'balloon-boss-over'
ผู้เล่นจริง sim ยาน/ชน/บอส เอง (รายงานคะแนน/ดาเมจ/ลูกโป่ง/ตาย ผ่าน 'move')
============================================================ */
const BB_TICK_MS = 50;
const BB_BOT_EMIT_MS = 70;
function bbTimeLimitSec(md) {
const t = Number(md.balloonBossTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const rt = Number(runtimeGameTiming && runtimeGameTiming.balloonBossMissionTimeSec);
return Number.isFinite(rt) && rt > 0 && rt < 7200 ? Math.floor(rt) : 120;
}
function bbMaxHp(md) { const n = Number(md.balloonBossMaxHp); return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100)); }
function bbBossCenter(md, ts) {
const bx = md.balloonBossBossSpawn;
if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
const w = md.width || 20, h = md.height || 15;
return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
}
function bbPlayerSpawnCenter(md, slot, ts) {
const g = md.balloonBossPlayerSlots;
if (!g) return null;
const w = md.width || 20, h = md.height || 15;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { if (g[y] && Number(g[y][x]) === slot) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts }; }
return null;
}
function bbClampWorld(cx, cy, mw, mh) { const e = 22; return { cx: Math.max(e, Math.min(mw - e, cx)), cy: Math.max(e, Math.min(mh - e, cy)) }; }
function bbTeamDmg(space) {
let s = 0;
space.peers.forEach((p) => { s += Math.max(0, p.balloonBossBossDmg | 0); });
const sess = space.balloonBossSession;
if (sess && sess.bots) sess.bots.forEach((b) => { s += Math.max(0, b.dmg | 0); });
return s;
}
function createBalloonBossBots(space, md) {
const s = space.balloonBossSession;
const ts = jsTileSize(md);
const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
const startBalloons = balloonBossBalloonsForMap(md);
const humanCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length;
const botCount = Math.max(0, Math.min(6 - Math.min(humanCount, 6), effectiveBotSlotCount(space)));
const bannedSlot = jsBannedBotSlot(space);
let created = 0, slot = 0;
while (created < botCount && slot < 64) {
if (slot === bannedSlot) { slot++; continue; }
const id = JS_BOT_PREFIX + slot;
const slotNum = ((humanCount + created) % 6) + 1;
const cen = bbPlayerSpawnCenter(md, slotNum, ts);
let cx = cen ? cen.cx : ((created + 1) / (botCount + 1)) * mw;
let cy = cen ? cen.cy : (mh - ts * 1.4);
const cl = bbClampWorld(cx, cy, mw, mh);
s.bots.set(id, {
tier: 'avg', homeCx: cl.cx, homeCy: cl.cy, cx: cl.cx, cy: cl.cy,
score: 0, dmg: 0, balloons: startBalloons, eliminated: false,
fireCd: 0.8 + Math.random() * 1.0, phase: Math.random() * Math.PI * 2,
takeover: false, takeoverPlayerKey: '',
});
created++; slot++;
}
}
function startBalloonBossGame(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'balloon_boss') return;
if (!space.detectiveMinigameActive) return;
if (space.balloonBossSession && space.balloonBossSession.active) return;
const now = Date.now();
const liveBeginsAt = space.missionLiveBeginsAt && space.missionLiveBeginsAt > now ? space.missionLiveBeginsAt : now;
const lim = bbTimeLimitSec(md);
space.balloonBossSession = {
active: true, ended: false, startMs: liveBeginsAt, liveBeginsAt,
endsAt: lim > 0 ? liveBeginsAt + lim * 1000 + 2000 : 0,
maxHp: bbMaxHp(md), bots: new Map(), lastEmit: 0,
};
createBalloonBossBots(space, md);
}
function endBalloonBossGame(sid, space, reason) {
const s = space.balloonBossSession;
if (!s || s.ended) return;
s.ended = true; s.active = false;
const timeUp = reason === 'time';
const scores = [];
space.peers.forEach((p, id) => {
scores.push({ id, score: Math.max(0, p.balloonBossScore | 0), eliminated: timeUp ? true : !!p.balloonBossEliminated });
});
s.bots.forEach((b, id) => scores.push({ id, score: Math.max(0, b.score | 0), eliminated: timeUp ? true : !!b.eliminated }));
io.to(sid).emit('balloon-boss-over', { reason: reason === 'victory' ? 'victory' : (timeUp ? 'time' : 'all_dead'), scores, srv: true });
}
function stepBalloonBossBots(sid, space, dt, now) {
const s = space.balloonBossSession;
if (!s || !s.active || s.ended) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'balloon_boss') return;
/* คำถามพิเศษเปิดอยู่ → หยุดบอท (ลอย+ยิง) + เลื่อน endsAt ไม่ให้เวลาเดิน (เหมือน gauntlet)
กันกระสุนบอทถูก emit สะสมตอน pause แล้วไหลรัวหลังตอบเสร็จ */
if (isSpecialQuizPausedServer(space)) { if (s.endsAt > 0) s.endsAt += BB_TICK_MS; return; }
/* จบรอบ: ชนะบอส (HP→0) ก่อน, แล้วหมดเวลา */
if (bbTeamDmg(space) >= s.maxHp) { endBalloonBossGame(sid, space, 'victory'); return; }
if (s.endsAt > 0 && now >= s.endsAt) { endBalloonBossGame(sid, space, 'time'); return; }
const ts = jsTileSize(md);
const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
const boss = bbBossCenter(md, ts);
const outBots = [];
s.bots.forEach((b, id) => {
if (b.eliminated) return;
b.phase += dt;
const wob = Math.sin(b.phase * 1.25) * 26;
const wob2 = Math.cos(b.phase * 0.9) * 18;
const cl = bbClampWorld(b.homeCx + wob, b.homeCy + wob2, mw, mh);
b.cx = cl.cx; b.cy = cl.cy;
b.fireCd -= dt;
if (b.fireCd <= 0) {
b.fireCd = 0.9 + Math.random() * 1.1;
const aim = Math.atan2(boss.cy - b.cy, boss.cx - b.cx) + (Math.random() - 0.5) * 0.5;
const bspd = 500 + Math.random() * 90;
const vx = Math.cos(aim) * bspd, vy = Math.sin(aim) * bspd;
const sx = b.cx + Math.cos(aim) * 26, sy = b.cy - 10.5 + Math.sin(aim) * 26;
io.to(sid).emit('balloon-boss-shot', { sx, sy, vx, vy, ownerId: id, t0: now });
b.score = Math.max(0, (b.score | 0) - 5); /* สเปก: ยิง 1 ครั้ง −5 */
const hitProb = b.tier === 'sharp' ? 0.55 : b.tier === 'weak' ? 0.26 : 0.4;
if (Math.random() < hitProb) { b.score += 10; b.dmg += 2; } /* โดนบอส: +10 คะแนน, −2 HP บอส */
}
outBots.push({ id, cx: b.cx, cy: b.cy, score: b.score, dmg: b.dmg, balloons: b.balloons, elim: false });
});
if (now - s.lastEmit >= BB_BOT_EMIT_MS) {
s.lastEmit = now;
if (outBots.length) io.to(sid).emit('balloon-boss-bot-move', { bots: outBots });
}
}
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 botCount = Math.max(0, Math.min(GAUNTLET_MAX_PLAYERS - list.length, effectiveBotSlotCount(space)));
const pos = gauntletSpawnPositions(md, list.length + botCount);
const crown = isGauntletCrownHeistMapBySpace(space);
/* จัด lane ให้ "ไม่ทับกันแน่นอน": เรียงตาม spawnJoinOrder (id เป็น tiebreak ให้เสถียร)
แล้วแจกช่อง pos แบบ unique — ถ้า ord ชนกัน/เกินจำนวนช่อง (pos[ord] ซ้ำ) ดันไปช่องว่างถัดไป
= แก้ MG2 user1/user2 spawn ทับกัน (เดิม pos[ord] ซ้ำเมื่อ ord ชนหรือ fallback ช่องสุดท้าย) */
const ordered = list
.map((p, i) => ({ p, ord: (typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : i) }))
.sort((a, b) => (a.ord - b.ord) || (String(a.p.id) < String(b.p.id) ? -1 : 1));
const usedPosIdx = new Set();
ordered.forEach((entry, seq) => {
const p = entry.p;
let idx = Number.isFinite(entry.ord) ? Math.max(0, Math.min(entry.ord, pos.length - 1)) : seq;
let guard = 0;
while (usedPosIdx.has(idx) && guard < pos.length) { idx = (idx + 1) % pos.length; guard++; }
usedPosIdx.add(idx);
const pt = pos[idx] || 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);
/* บอท server-authoritative — แจกช่อง spawn ที่เหลือ (ไม่ทับผู้เล่น)
Card Ban: ถ้าแบน "บอท" (__lobby_bot_N) → ข้าม slot N + ลดจำนวน 1 (ตรงกับ client rebalancePreviewBots) */
if (botCount > 0) {
let bannedBotSlot = -1;
const bannedId = space.bannedThisRunPlayerId;
if (typeof bannedId === 'string' && bannedId.indexOf('__lobby_bot_') === 0) {
const n = parseInt(bannedId.slice('__lobby_bot_'.length), 10);
if (Number.isFinite(n) && n >= 0) bannedBotSlot = n;
}
const want = bannedBotSlot >= 0 ? Math.max(0, botCount - 1) : botCount;
const bots = new Map();
let created = 0;
let slot = 0;
while (created < want && slot < GAUNTLET_MAX_PLAYERS + botCount) {
if (slot === bannedBotSlot) { slot++; continue; }
let idx = 0;
while (usedPosIdx.has(idx) && idx < pos.length) idx++;
usedPosIdx.add(idx);
const pt = pos[idx] || pos[pos.length - 1] || { x: 1, y: 1 };
bots.set(GAUNTLET_BOT_PREFIX + slot, {
tier: gauntletBotTierForSlot(slot),
x: pt.x,
y: pt.y,
dir: 'right',
gauntletJumpTicks: 0,
gauntletScore: crown ? 100 : 0,
gauntletEliminated: false,
});
created++;
slot++;
}
if (bots.size) space.gauntletRun.bots = bots;
}
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;
}
/* ===== Lobby authoritative (server เป็นเจ้าของ LobbyA/B) =====================
เดิม: บอท lobby เดินบน "host client" (frame-based) แล้ว broadcast → บอทวิ่งเร็วช้าต่างกันตาม FPS/host
+ ผู้เล่นออกแล้ว client อื่นไม่ได้ user-left = ผีค้าง
ใหม่: server เดินบอทเอง (time-based, ความเร็วเดียวทุกเครื่อง) + ส่ง roster snapshot ให้ client reconcile (ฆ่าผี)
bot id = '__lobby_bot_<slot>' (ตรงกับ client LOBBY_BOT_PREFIX) → client map สี/ตัวละครได้ถูกตัว */
const LOBBY_BOT_PREFIX = '__lobby_bot_';
const LOBBY_MOVE_SPEED_PER_SEC = 0.15 * 60; /* ตรงกับ client MOVE_SPEED_PER_SEC (0.15/เฟรม × 60fps) */
function lobbyMapOfSpace(space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
return (md && md.gameType === 'lobby') ? md : null;
}
/* จุดเกิดผู้เล่น 6 ช่อง (mirror client parseLobbyPlayerSpawnsFromMapLobby) — บอทเกิดที่ช่องเดียวกับคน */
function parseLobbyPlayerSpawnsServer(md) {
const w = md.width || 20, 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;
}
/* เดินได้ไหม — mirror client canWalkLobbyBot เป๊ะ: กำแพง objects===1 เดินไม่ได้ (เดินได้ทุก tile ที่ไม่ใช่กำแพง เหมือนคน);
tile blockPlayer===1 = ห้ามทับ peer/บอทตัวอื่น (tile ปกติทับได้)
หมายเหตุ: ห้ามจำกัดด้วย spawnArea — นั่นคือ "จุดเกิด" ไม่ใช่ "พื้นที่เดิน" (เคยทำให้บอทติดอยู่กับที่) */
function lobbyTileWalkable(md, space, x, y, selfBotId) {
const w = md.width || 20, h = md.height || 15;
const tx = Math.floor(x), ty = Math.floor(y);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const ob = md.objects && md.objects[ty] ? md.objects[ty][tx] : 0;
if (ob === 1) return false;
const bp = md.blockPlayer;
if (bp && bp[ty] && bp[ty][tx] === 1) {
for (const p of space.peers.values()) {
if (p && Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false;
}
if (space.lobbyBots) {
for (const [bid, b] of space.lobbyBots) {
if (bid === selfBotId) continue;
if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false;
}
}
}
return true;
}
/* เกิดบอทที่ "ช่องเกิดผู้เล่น" เดียวกับคน (slotOrd = humans + botIndex → ช่องไม่ทับคน, ไม่ทับกันเอง)
mirror client pickLobbySpawnForJoin: slots6 → lobbyPlayerSpawns[j], fixed → md.spawn */
function pickLobbyBotSpawnServer(md, space, slotOrd) {
const w = md.width || 20, h = md.height || 15;
const mode = md.lobbySpawnMode;
const ord = slotOrd | 0;
if (mode === 'slots6') {
const j = Math.min(Math.max(0, ord), 5);
const slots = parseLobbyPlayerSpawnsServer(md);
const pick = slots[j];
if (pick) return { x: pick.x, y: pick.y };
} else if (mode === 'fixed' && md.spawn) {
const fx = Math.floor(Number(md.spawn.x));
const fy = Math.floor(Number(md.spawn.y));
if (Number.isFinite(fx) && Number.isFinite(fy)) return { x: Math.max(0, Math.min(w - 1, fx)), y: Math.max(0, Math.min(h - 1, fy)) };
}
/* fallback: หาช่อง spawn ที่ define ไว้ตัวแรก ๆ แล้วค่อยสุ่ม tile ที่ไม่ใช่กำแพง */
const slots = parseLobbyPlayerSpawnsServer(md);
const defined = slots.filter(Boolean);
if (defined.length) { const s = defined[Math.min(ord, defined.length - 1)]; return { x: s.x, y: s.y }; }
for (let i = 0; i < 24; i++) {
const x = 1 + Math.floor(Math.random() * Math.max(1, w - 2));
const y = 1 + Math.floor(Math.random() * Math.max(1, h - 2));
if (lobbyTileWalkable(md, space, x, y, null)) return { x, y };
}
const sp = md.spawn;
if (sp && Number.isFinite(Number(sp.x)) && Number.isFinite(Number(sp.y))) return { x: Math.floor(Number(sp.x)), y: Math.floor(Number(sp.y)) };
return { x: 1, y: 1 };
}
/* เดินบอท lobby ของ space เดียว (เรียกจาก sweeper ทุก ~60ms) — time-based, ความเร็วคงที่ */
function stepLobbyBotsForSpace(sid, space, dt, nowMs) {
const md = lobbyMapOfSpace(space);
const botCount = md ? effectiveBotSlotCount(space) : 0;
const humans = space.peers ? space.peers.size : 0;
if (!md || botCount <= 0 || humans <= 0) {
if (space.lobbyBots) space.lobbyBots = null;
return;
}
if (!space.lobbyBots) space.lobbyBots = new Map();
const bots = space.lobbyBots;
/* ปรับจำนวนบอทให้ตรง botCount (id ตาม slot) — เกิดที่ช่องผู้เล่น humans+i (ไม่ทับคน/ไม่ทับกัน) */
for (let i = 0; i < botCount; i++) {
const id = LOBBY_BOT_PREFIX + i;
if (!bots.has(id)) {
const sp = pickLobbyBotSpawnServer(md, space, humans + i);
bots.set(id, { x: sp.x, y: sp.y, direction: 'down', isWalking: false, wTX: null, wTY: null, retargetAt: 0, pauseUntil: 0 });
}
}
for (const id of [...bots.keys()]) {
const slot = parseInt(id.slice(LOBBY_BOT_PREFIX.length), 10);
if (!(slot >= 0 && slot < botCount)) bots.delete(id);
}
const w = md.width || 20, h = md.height || 15;
const step = LOBBY_MOVE_SPEED_PER_SEC * dt;
bots.forEach((b, id) => {
const arrived = (typeof b.wTX === 'number') && Math.hypot(b.wTX - b.x, b.wTY - b.y) < 0.5;
if (typeof b.wTX !== 'number' || arrived || nowMs >= b.retargetAt) {
/* เป้า wander = สุ่มทั้งแมป (เหมือน client เดิม) — การเดินถูกจำกัดด้วยกำแพงอยู่แล้ว */
b.wTX = 1 + Math.random() * Math.max(1, w - 2);
b.wTY = 1 + Math.random() * Math.max(1, h - 2);
b.retargetAt = nowMs + 3000 + Math.floor(Math.random() * 4500);
b.pauseUntil = (arrived && Math.random() < 0.45) ? nowMs + 500 + Math.floor(Math.random() * 1600) : 0;
}
if (b.pauseUntil && nowMs < b.pauseUntil) { b.isWalking = false; return; }
let dx = b.wTX - b.x, dy = b.wTY - b.y;
const dist = Math.hypot(dx, dy) || 1;
dx /= dist; dy /= dist;
if (Math.abs(dy) > Math.abs(dx)) b.direction = dy > 0 ? 'down' : 'up';
else b.direction = dx > 0 ? 'right' : 'left';
const ox = b.x, oy = b.y;
const nx = b.x + dx * step, ny = b.y + dy * step;
if (lobbyTileWalkable(md, space, nx, ny, id)) { b.x = nx; b.y = ny; }
else if (lobbyTileWalkable(md, space, nx, b.y, id)) { b.x = nx; }
else if (lobbyTileWalkable(md, space, b.x, ny, id)) { b.y = ny; }
else { b.retargetAt = 0; } /* ติด → หาเป้าใหม่เฟรมหน้า */
b.x = Math.max(0, Math.min(w - 0.01, b.x));
b.y = Math.max(0, Math.min(h - 0.01, b.y));
b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5;
});
const arr = [];
bots.forEach((b, id) => arr.push({
id,
x: Math.round(b.x * 1000) / 1000,
y: Math.round(b.y * 1000) / 1000,
direction: b.direction || 'down',
isWalking: !!b.isWalking,
}));
if (arr.length) io.to(sid).emit('lobby-bot-move', { bots: arr });
/* roster snapshot (L3 ฆ่าผี) — ทุก ~1.5s ส่ง id ผู้เล่นจริงทั้งหมด ให้ client ตัดตัวที่ไม่อยู่ทิ้ง */
if (!space.lobbyRosterSyncTs || nowMs - space.lobbyRosterSyncTs > 1500) {
space.lobbyRosterSyncTs = nowMs;
emitLobbyRosterSync(sid, space);
}
}
function emitLobbyRosterSync(sid, space) {
if (!space || !space.peers) return;
const ids = [...space.peers.keys()];
io.to(sid).emit('lobby-roster', { ids });
}
let lobbyBotSweeperTs = 0;
function lobbyBotSweepTick() {
const now = Date.now();
let dt = lobbyBotSweeperTs ? (now - lobbyBotSweeperTs) / 1000 : 0.06;
if (!(dt > 0)) dt = 0.06;
if (dt > 0.2) dt = 0.2;
lobbyBotSweeperTs = now;
for (const [sid, space] of spaces) {
try { stepLobbyBotsForSpace(sid, space, dt, now); } catch (e) { /* กัน 1 ห้องพังทั้ง sweeper */ }
}
}
setInterval(lobbyBotSweepTick, 60);
/* roster heartbeat ระหว่างเล่นมินิเกม (gameType != lobby) → client (play.js) ตัด "ผี" (peer ที่ไม่อยู่ใน roster จริง)
แก้บั๊ก: คนเดียวมี avatar 2 อัน (อันเก่าค้างตอน reconnect/ออก เพราะ others เป็น add-only ไม่เคย prune) */
setInterval(() => {
for (const [sid, space] of spaces) {
if (!space || !space.peers || space.peers.size === 0) continue;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType === 'lobby') continue; /* lobby ใช้ lobby-roster แยก */
try { io.to(sid).emit('play-roster', { ids: [...space.peers.keys()] }); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, 1500);
/* เดินบอท MG1 quiz บน server (time-based) → บอทเดินเองไม่พึ่ง host (host หลุดก็ไม่หยุด) */
let quizBotSweepTs = 0;
setInterval(() => {
const now = Date.now();
let dt = quizBotSweepTs ? (now - quizBotSweepTs) / 1000 : 0.06;
if (!(dt > 0)) dt = 0.06;
if (dt > 0.2) dt = 0.2;
quizBotSweepTs = now;
for (const [sid, space] of spaces) {
const sess = space && space.quizSession;
if (!sess || !sess.active || !sess.bots || sess.bots.size === 0) continue;
try { stepQuizBots(sid, space, dt, now); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, 60);
/* เดินบอท MG4 quiz_carry บน server (time-based, BFS) → host หลุดก็ไม่หยุด */
let quizCarryBotSweepTs = 0;
setInterval(() => {
const now = Date.now();
let dt = quizCarryBotSweepTs ? (now - quizCarryBotSweepTs) / 1000 : 0.06;
if (!(dt > 0)) dt = 0.06;
if (dt > 0.2) dt = 0.2;
quizCarryBotSweepTs = now;
for (const [sid, space] of spaces) {
const s = space && space.quizCarrySession;
if (!s || !s.active || s.ended || !s.bots || s.bots.size === 0) continue;
try { stepQuizCarryBots(sid, space, dt, now); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, 60);
/* MG5 jump_survive — sweep บอท server + จับเวลา/จบรอบ (server-auth) */
let jumpSurviveBotSweepTs = 0;
setInterval(() => {
const now = Date.now();
let dt = jumpSurviveBotSweepTs ? (now - jumpSurviveBotSweepTs) / 1000 : 0.033;
if (!(dt > 0)) dt = 0.033;
if (dt > 0.05) dt = 0.05; /* clamp เหมือน client (กันกระโดดไกลตอน lag) */
jumpSurviveBotSweepTs = now;
for (const [sid, space] of spaces) {
const s = space && space.jumpSurviveSession;
if (!s || !s.active || s.ended) continue;
try { stepJumpSurviveBots(sid, space, dt, now); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, JS_TICK_MS);
/* MG6 space_shooter — sweep บอท server + จับเวลา/จบรอบ (server-auth) */
let spaceShooterBotSweepTs = 0;
setInterval(() => {
const now = Date.now();
let dt = spaceShooterBotSweepTs ? (now - spaceShooterBotSweepTs) / 1000 : 0.05;
if (!(dt > 0)) dt = 0.05;
if (dt > 0.08) dt = 0.08;
spaceShooterBotSweepTs = now;
for (const [sid, space] of spaces) {
const s = space && space.spaceShooterSession;
if (!s || !s.active || s.ended) continue;
try { stepSpaceShooterBots(sid, space, dt, now); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, SS_TICK_MS);
/* MG7 balloon_boss — sweep บอท server + รวม HP บอส/จบรอบ (server-auth) */
let balloonBossBotSweepTs = 0;
setInterval(() => {
const now = Date.now();
let dt = balloonBossBotSweepTs ? (now - balloonBossBotSweepTs) / 1000 : 0.05;
if (!(dt > 0)) dt = 0.05;
if (dt > 0.08) dt = 0.08;
balloonBossBotSweepTs = now;
for (const [sid, space] of spaces) {
const s = space && space.balloonBossSession;
if (!s || !s.active || s.ended) continue;
try { stepBalloonBossBots(sid, space, dt, now); } catch (e) { /* กัน 1 ห้องล้ม */ }
}
}, BB_TICK_MS);
/** หมดเวลา 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 แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client
เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */
if (gr.finishPhase) {
if (gr.obstacles.length) { gr.obstacles = []; }
if (gr.finishEndAt == null) gr.finishEndAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
if (Date.now() >= gr.finishEndAt) {
endGauntletGame(sid, space, 'finish');
}
/* ไม่ emitGauntletSync ระหว่าง grace — ปล่อย client คุมการเดินเข้าเส้นเอง (กัน sync ดึงตำแหน่งกลับ) */
return;
}
if (gr.crownRunHeld) {
space.peers.forEach((p) => {
p.gauntletJumpPending = false;
});
emitGauntletSync(sid, space);
return;
}
/* G2: pre-run hold — หลัง 3-2-1 จบ ค้างที่จุด start ให้เห็นเส้นเริ่มก่อน (ยังไม่เลื่อน/เกิด obstacle/ขยับบอท)
client ค้าง bg เองด้วย max(0, serverNow liveStartMs) → bg+bots/obstacles sync ที่ start พร้อมกัน */
if (gr.liveStartMs != null && Date.now() < gr.liveStartMs) {
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);
});
/* บอท server-authoritative — แถวเลนของบอทที่ยังเล่นอยู่ (ไม่ต้องพึ่ง gauntlet-preview-rows จาก host) */
if (gr.bots && gr.bots.size) {
gr.bots.forEach((b) => {
if (b.gauntletEliminated) return;
const lr = gauntletLaneGridRowFromPlayerTop(md, b.y, h);
if (lr != null) laneSpawnRows.add(lr);
});
}
/* แถวบนที่ client แจ้ง (บอท preview — editor หรือ client เก่า) — แปลงเป็นแถว 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;
});
/* บอท server-authoritative — ตัดสินใจกระโดด + ฟิสิกส์ รอบ tick เดียวกับผู้เล่น (obstacle เลื่อนแล้วข้างบน) */
if (gr.bots && gr.bots.size) {
gr.bots.forEach((b) => {
if (crownHeist && b.gauntletEliminated) return;
gauntletBotDecideJump(b, gr.obstacles, md, w, h);
gauntletBotStepPhysics(b, gr.obstacles, md, w, h, crownHeist);
});
}
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 ของแต่ละคนสลับกันมั่ว */
/** เลือก host คนถัดไปเมื่อ host ปัจจุบันหลุด — คนที่อยู่ในห้องนานสุด (spawnJoinOrder ต่ำสุด)
เสถียรข้ามการ reconnect (spawnJoinOrder ผูกกับ playerKey) — แทน Map-first ที่สลับได้ */
function pickNextHostPeer(space) {
if (!space || !space.peers || space.peers.size === 0) return null;
let best = null;
let bestOrd = Infinity;
space.peers.forEach((p, id) => {
if (!p) return;
const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999;
if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) {
bestOrd = ord;
best = p;
}
});
return best;
}
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 });
});
/* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */
if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) {
delete space.graceReconnect[joinPlayerKey];
if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; }
io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' });
}
/* MG1 takeover: ผู้เล่นกลับมากลาง quiz (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (sess.bots) คืนสิทธิ์เล่น
กัน "ตัวซ้ำ" (บอท takeover + ตัวที่ rejoin) */
const sessJ = space.quizSession;
if (sessJ && sessJ.bots) {
const dropBotIds = [];
sessJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBotIds.push(bid); });
dropBotIds.forEach((bid) => {
sessJ.bots.delete(bid);
io.to(spaceId).emit('user-left', { id: bid }); /* ลบ avatar บอท takeover เก่า — ตัวจริงจะมาใหม่ผ่าน user-joined */
});
}
/* MG2 takeover เช่นกัน: ผู้เล่นกลับมากลาง gauntlet (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (gauntletRun.bots) */
const grJ = space.gauntletRun;
if (grJ && grJ.bots) {
const dropG = [];
grJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropG.push(bid); });
dropG.forEach((bid) => {
grJ.bots.delete(bid);
io.to(spaceId).emit('user-left', { id: bid });
});
}
/* MG3 takeover เช่นกัน: กลับมากลาง stack (playerKey เดิม) → ถอนบอท + คืน seat ให้คนจริง (inherit คะแนนกลับ) */
const s3J = space.stackSession;
if (s3J && s3J.bots) {
let restored = false;
const dropS = [];
s3J.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropS.push(bid); });
dropS.forEach((bid) => {
const inherited = Math.max(0, ((s3J.bots.get(bid) || {}).score) | 0);
s3J.bots.delete(bid);
const entry = (s3J.turnOrder || []).find((e) => e.kind === 'bot' && String(e.botId) === String(bid));
if (entry) { entry.kind = 'human'; entry.id = socket.id; entry.botId = null; s3J.humanPts[socket.id] = inherited; restored = true; }
io.to(spaceId).emit('user-left', { id: bid });
});
if (restored) emitStackStateServer(spaceId, space);
}
/* MG4 takeover เช่นกัน: กลับมากลาง quiz_carry (playerKey เดิม) → ถอนบอท + คืน slot ให้คนจริง (inherit ป้าย/คะแนนกลับ) */
const sCJ = space.quizCarrySession;
if (sCJ && sCJ.bots) {
const dropC = [];
sCJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropC.push(bid); });
dropC.forEach((bid) => {
const bb = sCJ.bots.get(bid) || {};
sCJ.bots.delete(bid);
sCJ.players[socket.id] = { held: (bb.held == null ? null : bb.held), score: Math.max(0, Number(bb.score) || 0) };
io.to(spaceId).emit('user-left', { id: bid });
});
}
/* MG5 takeover เช่นกัน: กลับมากลาง jump_survive (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit สถานะตาย) */
const sJJ = space.jumpSurviveSession;
if (sJJ && sJJ.bots) {
const dropJ = [];
sJJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropJ.push(bid); });
dropJ.forEach((bid) => {
const bb = sJJ.bots.get(bid) || {};
sJJ.bots.delete(bid);
sJJ.players[socket.id] = { eliminated: !!bb.eliminated };
io.to(spaceId).emit('user-left', { id: bid });
});
}
/* MG6 takeover: กลับมากลาง space_shooter (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit คะแนน/ตาย) */
const sSJ = space.spaceShooterSession;
if (sSJ && sSJ.bots) {
const dropSS = [];
sSJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropSS.push(bid); });
dropSS.forEach((bid) => {
const bb = sSJ.bots.get(bid) || {};
sSJ.bots.delete(bid);
sSJ.players[socket.id] = { eliminated: !!bb.eliminated };
io.to(spaceId).emit('user-left', { id: bid });
});
}
/* MG7 takeover: กลับมากลาง balloon_boss (playerKey เดิม) → ถอนบอท + คืน slot คนจริง */
const sBJ = space.balloonBossSession;
if (sBJ && sBJ.bots) {
const dropBB = [];
sBJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBB.push(bid); });
dropBB.forEach((bid) => { sBJ.bots.delete(bid); io.to(spaceId).emit('user-left', { id: bid }); });
}
}
/* reconnect dedup ชั้น 2: ghost ที่ playerKey block จับไม่ได้ (peer เก่าไม่มี playerKey)
ลบด้วย "ชื่อ custom เดียวกัน" — แต่ **เฉพาะ peer เก่าที่ไม่มี valid playerKey** เท่านั้น
⚠️ ห้ามลบ peer ที่มี playerKey คนละค่า: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละคน ต้องเก็บทั้งคู่
(เดิมลบทุกตัวที่ชื่อตรง → client ที่ชื่อซ้ำ host ถูกลบ = "ไม่ได้เล่นเลย") */
const validPkStr = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : '';
const newNickNorm = normalizeLobbyNickname(nickname);
const newNickIsCustom = newNickNorm && newNickNorm !== 'ผู้เล่น'
&& newNickNorm.indexOf('ผู้เล่น') !== 0 && newNickNorm.indexOf('บอท') !== 0;
if (newNickIsCustom) {
const ghostIds = [];
space.peers.forEach((p, pid) => {
if (pid === socket.id || !p) return;
const ppk = validPkStr(p);
if (ppk && ppk !== joinPlayerKey) return; /* คนละ playerKey = คนละคน (ชื่อซ้ำได้) → ไม่ลบ */
if (normalizeLobbyNickname(p.nickname) === newNickNorm) ghostIds.push(pid);
});
ghostIds.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);
/* เก็บกวาด peer ซ้ำทั้งก้อน (ghost) — เผื่อ dedup ต่อ-join ด้านบนพลาด/race จนคนเดิมค้าง 2 ตัว */
pruneDuplicateSpacePeers(spaceId, space);
glog(spaceId, space, 'join', 'สี#' + (peer.lobbyColorThemeIndex || '?') + ' · คน ' + space.peers.size + ' + บอท ' + effectiveBotSlotCount(space) + (joinPlayerKey ? '' : ' · NO-KEY'), nickname || 'ผู้เล่น');
/* invariant: คน + บอท ≤ 6 — กัน "เงาสะท้อนของ user": คนหลุดแล้ว grace แปลงเป็นบอท (botSlotCount+1, สีตามคนออก)
พอคนนั้นกลับเข้ามา (reconnect หลัง grace) จะมีทั้งคนจริง + บอทสีเดียวกัน = ตัวซ้ำ. ตัดบอทส่วนเกินออกให้รวมไม่เกิน 6
(มินิเกม cap ที่ 6-humans อยู่แล้ว ~rebalancePreviewBots แต่ LobbyB ไม่ได้ cap → ตัดที่ server ต้นทาง) */
{
const humansNow = space.peers.size;
const botsNow = effectiveBotSlotCount(space);
if (humansNow + botsNow > LOBBY_SLOT_TOTAL) {
space.botSlotCount = Math.max(0, LOBBY_SLOT_TOTAL - humansNow);
}
/* re-sync สีบอททุกครั้งที่มีคน (re)join + แจ้ง "ทุกจอ" (รวมคนที่อยู่ในมินิเกม/LobbyB อยู่แล้ว):
เคส "เงาสะท้อนของ user" = คนหลุด→บอทใช้สีคนนั้น (pin)→คนนั้นกลับมา. join handler เคลียร์ pin ที่ชนสีคนแล้ว
(7704) + syncLobbyBotThemeSlots เลือกสีใหม่ที่ไม่ชนคน แต่เดิม emit เฉพาะตอน total>6 → จออื่นไม่อัปเดต = บอทยังสีเดิม.
emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */
emitLobbyTintSync(spaceId, space);
}
/* กลับ 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);
/* นับช่อง spawn ให้พอกับ "คน + บอท" — เดิมส่งแค่ peers.size: ห้อง 1 คน = 1 ช่อง = pos[0] (เลนบนสุด) เสมอ
แล้วไปทับบอทที่ initGauntletPeersAll วางไว้ที่ pos[0] → คน(host ใหม่)+บอท วิ่งเลนเดียวกันเลนบนสุด (bug ที่เจอซ้ำ) */
const grJoin = space.gauntletRun;
const botCntJoin = (grJoin && grJoin.bots) ? grJoin.bots.size : 0;
const pos = gauntletSpawnPositions(mdJoin, space.peers.size + botCntJoin);
/* เลี่ยงทับทั้ง "peer อื่น" และ "บอท" ที่อยู่ในเลนแล้ว — ดันไปช่องว่างถัดไป (เดิมเช็คแต่ peer ไม่เช็คบอท) */
const occupied = new Set();
space.peers.forEach((q) => { if (q && q.id !== socket.id) occupied.add(Math.floor(q.x) + ',' + Math.floor(q.y)); });
if (grJoin && grJoin.bots) grJoin.bots.forEach((b) => { if (b) occupied.add(Math.floor(Number(b.x)) + ',' + Math.floor(Number(b.y))); });
let pidx = Number.isFinite(ord) ? Math.max(0, Math.min(ord, pos.length - 1)) : pos.length - 1;
let pguard = 0;
while (pos[pidx] && occupied.has(Math.floor(pos[pidx].x) + ',' + Math.floor(pos[pidx].y)) && pguard < pos.length) { pidx = (pidx + 1) % pos.length; pguard++; }
const pt = pos[pidx] != null ? pos[pidx] : 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);
/* กันค้างตอน reconnect: event "จบมินิเกม" (balloon-boss-over/stack-over/space-shooter-over)
host ยิงครั้งเดียวผ่าน socket.to — ถ้า client หลุดตอนนั้นจะค้างถาวร (ไม่มี replay).
เก็บ over ล่าสุดไว้ → resend ให้ socket ที่เพิ่ง (re)join ภายใน 45s ถ้ายังอยู่ในแมปมินิเกม
(gameType !== 'lobby' = ยังไม่ทรานซิชันกลับ). delay เล็กน้อยให้ client applyMapAndStart เสร็จก่อน */
const lmo = space.lastMinigameOver;
if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') {
const targetSid = socket.id;
setTimeout(() => {
try {
const sp = spaces.get(spaceId);
if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) {
io.to(targetSid).emit(lmo.event, lmo.payload);
}
} catch (_e) { /* ignore */ }
}, 400);
}
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 จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */
/* DEPRECATED: server เป็นเจ้าของบอท lobby เองแล้ว (stepLobbyBotsForSpace/lobbyBotSweepTick)
→ ไม่ relay ตำแหน่งบอทจาก client อีก (กัน host เวอร์ชันเก่าฉีดตำแหน่งชนกับ server tick = บอทกระตุก/2 แหล่ง) */
socket.on('lobby-bot-move', () => { /* no-op: server-authoritative */ });
/* 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;
if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */
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 === 'time') ? 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 }))
: [];
const overPayload = { reason, scores };
socket.to(sid).emit('balloon-boss-over', overPayload);
/* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */
space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() };
});
/* 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;
if (space.stackSession && space.stackSession.active) return; /* server-auth: client ไม่เป็นเจ้าของดรอป */
const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0;
const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; };
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,
theme: themeOf(data.theme), /* สี block ที่วาง (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client freeze สีตัวเอง = เพี้ยน */
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;
if (space.stackSession && space.stackSession.active) return; /* server-auth: server แกว่งเอง */
const ph = Number(data.phase);
if (!Number.isFinite(ph)) return;
socket.to(sid).emit('stack-swing', { phase: ph });
});
/* MG3 stack (server-auth) — รับ "เจตนากดวาง" จากผู้เล่นจริง → server คำนวณ ณ เฟสปัจจุบัน + emit ผลให้ทุกเครื่อง */
socket.on('stack-drop-req', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return;
const s = space.stackSession;
if (!s || !s.active || s.ended) return;
if (Date.now() < s.busyUntil) return; /* กำลังแอนิเมชันดรอปก่อนหน้า */
const entry = stackCurrentTurnEntry(s);
if (!entry || entry.kind !== 'human' || String(entry.id) !== String(socket.id)) return; /* ต้องเป็นตาของคนนี้ */
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'stack') return;
const hit = stackComputeDropServer(s, md);
stackApplyHitServer(s, hit, { id: entry.id });
const peer = space.peers.get(socket.id);
const theme = peer && peer.lobbyThemeIndex != null ? (Number(peer.lobbyThemeIndex) % 8) + 1 : (entry.theme || null);
io.to(sid).emit('stack-drop', {
placedCx: hit.placedCx, placedW: hit.placedW, miss: hit.miss, perfect: hit.perfect,
pts: hit.pts, progressDelta: hit.progressDelta, landOffsetTiles: hit.landOffsetTiles,
overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta,
seat: entry.seat, heavy: false, theme,
actorId: entry.id, isBot: false,
phase: s.phase, /* เฟสจริง ณ ปล่อย → client snap เชือกให้ตรงบล็อกที่ร่วง */
});
s.busyUntil = Date.now() + STACK_DROP_BUSY_MS;
stackAdvanceTurnServer(s);
emitStackStateServer(sid, space);
});
/* 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 เท่านั้น */
if (space.stackSession && space.stackSession.active) return; /* server-auth: server เป็นเจ้าของลำดับตา */
const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; };
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,
theme: themeOf(e.theme), /* สีต่อ seat (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client คำนวณเอง = เพี้ยน */
}))
: [];
const idx = Math.max(0, Math.floor(Number(data.idx)) || 0);
/* สีของ block ที่วางทั้งหอ (host authoritative) — ต้อง relay ด้วย ไม่งั้นถูก allowlist ตัดทิ้ง */
const lthemes = Array.isArray(data.lthemes)
? data.lthemes.slice(0, 200).map((v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : 0; })
: [];
/* hostId (= host จริงที่ server ยืนยัน) → client ที่เข้าใจผิดว่าตัวเองเป็น host (dual-host) แก้ playHostId แล้วหยุด sim เอง */
socket.to(sid).emit('stack-state', { order, idx, hostId: socket.id, lthemes });
});
/* MG3 stack — host ตัดสินจบรอบ relay ให้ทุกคนจบพร้อมกัน */
socket.on('stack-over', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return;
if (space.stackSession && space.stackSession.active) return; /* server-auth: server ตัดสินจบเกม */
const outcome = (data && typeof data.outcome === 'string') ? data.outcome.slice(0, 32) : 'time_up';
const overPayload = { outcome };
socket.to(sid).emit('stack-over', overPayload);
space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() };
});
/* 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 }))
: [];
const overPayload = { kind, scores };
socket.to(sid).emit('space-shooter-over', overPayload);
space.lastMinigameOver = { event: 'space-shooter-over', payload: overPayload, atMs: Date.now() };
});
/* 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;
if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */
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()),
});
});
/* MG6 — host คิดการชน "บอท × อุกาบาต" (asteroid deterministic + ตำแหน่งบอทจาก server) แล้วรายงานจำนวนชน
server เป็นคนตัดสินตายจริง (>= SS_MAX_ASTEROID_HITS) → กันบอททะลุอุกาบาตไม่มีวันตาย และสถานะตายตรงกันทุกเครื่อง */
socket.on('space-shooter-bot-hit', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id || !data || typeof data.id !== 'string') return; /* เชื่อเฉพาะ host */
const s = space.spaceShooterSession;
if (!s || !s.active || s.ended) return;
if (isSpecialQuizPausedServer(space)) return;
const b = s.bots.get(data.id);
if (!b || b.eliminated) return;
const hits = Math.max(0, Math.min(99, Number(data.hits) || 0));
b.ssHits = Math.max(b.ssHits || 0, hits); /* monotonic */
if (b.ssHits >= SS_MAX_ASTEROID_HITS) {
b.eliminated = true;
/* step loop ข้ามบอทที่ตายแล้ว (ไม่ emit ต่อ) → ส่ง bot-move แจ้ง elim เดี๋ยวนี้ครั้งเดียวให้ทุกเครื่องรู้ */
io.to(sid).emit('space-shooter-bot-move', { bots: [{ id: data.id, cx: b.cx, cy: b.cy, dir: 'up', score: b.score, elim: true, hits: b.ssHits || 0 }] });
}
});
/** เปลี่ยนชื่อที่แสดงใน 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: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' });
}
/* จำกัดสืบผู้ต้องสงสัยละไม่เกิน 3 ครั้ง (#1) — นับจาก max หลักฐานของผู้ต้องสงสัยนี้ข้ามผู้เล่น
(แต่ละรอบทุกคนได้ +1) → กันเล่นครั้งที่ 4 + กันการ์ดพิเศษโผล่รอบที่ 4 (#2) */
let timesPlayedThisSuspect = 0;
space.peers.forEach((_p, pid) => {
const prog = playerSuspectProgressFromEvidence(space, pid);
const n = Array.isArray(prog) ? (prog[selectedIndex] | 0) : 0;
if (n > timesPlayedThisSuspect) timesPlayedThisSuspect = n;
});
if (timesPlayedThisSuspect >= 3) {
return reply({ ok: false, error: 'สืบผู้ต้องสงสัยคนนี้ครบ 3 ครั้งแล้ว — เลือกคนอื่น' });
}
/* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */
const pend = findPendingSpecialCard(space, 3);
if (pend && !space.pickVote) {
pend.consumed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' });
startPlayerPickVote(sid, space, 'ban', (targetId) => {
const sp = spaces.get(sid);
if (!sp) return;
sp.bannedPlayerId = targetId || null;
const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex);
if (!res || !res.ok) {
io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true });
}
});
return reply({ ok: true, banVote: true });
}
const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex);
reply(started);
});
socket.on('detective-minigame-finished', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
}
/* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
const finPeer = space.peers.get(socket.id);
if (finPeer && data && typeof data === 'object') {
/* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */
if (Number.isFinite(Number(data.score))) {
let rs = Math.max(0, Math.floor(Number(data.score)));
if (rs > REPORTED_MINI_SCORE_MAX) {
console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX);
rs = REPORTED_MINI_SCORE_MAX;
}
finPeer.reportedMiniScore = rs;
}
if (data.survived === false) finPeer.reportedMiniSurvived = false;
}
if (!space.detectiveMinigameActive) {
if (serverMapIsPostCaseLobbyB(space)) {
const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
return reply({
ok: true,
alreadyOnLobbyB: true,
suspectIndex: grant.suspectIdx,
awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
grade: grant.grade || null,
freeEvidenceCount: grant.freeEvidenceCount || 0,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
}
return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
}
/* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก"
จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด
ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */
const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) {
if (finPeer) finPeer.detectiveFinished = true;
const allDone = [...space.peers.values()].every((p) =>
p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished
);
if (!allDone) {
/* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */
return reply({ ok: true, holdInScene: true });
}
/* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */
}
// เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
const g = space.lastEvidenceGrant || {};
reply({
ok: true,
suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
grade: g.grade || null,
freeEvidenceCount: g.freeEvidenceCount || 0,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
});
/**
* DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 02) ทันที
* (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)
*/
socket.on('detective-debug-award-evidence', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
const idx = Math.floor(Number(data && data.suspectIndex));
if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 02' });
const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C');
if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' });
emitLobbyEvidenceSync(sid, space);
reply({
ok: true,
suspectIndex: idx,
awardedCard: card,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
});
// Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย)
socket.on('suspect-accuse-open', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
const prog = playerSuspectProgressFromEvidence(space, socket.id);
if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3);
space.suspectPhaseActive = false;
// เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต
space.testimonyActive = true;
space.testimonyRound = 0;
emitTestimonyOpen(sid, space);
reply({ ok: true });
});
// ผู้เล่นส่งหลักฐาน 1–2 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง
socket.on('testimony-submit', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' });
if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' });
const raw = (data && Array.isArray(data.picks)) ? data.picks : [];
const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []);
const required = Math.max(1, Math.min(2, owned.length));
/* raw = ตำแหน่ง index ใน owned (0..n-1) → เก็บเป็น card object */
const usedIdx = [];
const picks = [];
raw.forEach((n) => {
const v = Math.floor(Number(n));
if (v >= 0 && v < owned.length && usedIdx.indexOf(v) === -1) { usedIdx.push(v); picks.push(owned[v]); }
});
if (owned.length === 0) {
/* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */
if (!space.testimonySubmitted) space.testimonySubmitted = {};
space.testimonySubmitted[socket.id] = [];
emitTestimonyStatus(sid, space);
/* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design */
return reply({ ok: true, skipped: true });
}
if (picks.length !== required) {
return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' });
}
if (!space.testimonySubmitted) space.testimonySubmitted = {};
space.testimonySubmitted[socket.id] = picks.slice(0, required);
emitTestimonyStatus(sid, space);
/* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design
(UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */
reply({ ok: true });
});
// Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง)
socket.on('testimony-reveal', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' });
doTestimonyReveal(sid, space);
reply({ ok: true });
});
// ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) — ไม่ auto-advance รอ host กด "เริ่มพิจารณาคดี"
socket.on('testimony-ready-next', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' });
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
space.testimonyReadyNext[socket.id] = true;
const totalRN = testimonyTotalParticipants(space);
io.to(sid).emit('testimony-ready-update', {
ready: Object.keys(space.testimonyReadyNext),
totalPlayers: totalRN,
hostId: space.hostId,
});
/* ไม่ auto-advance — รอ host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote.png) เอง */
reply({ ok: true });
});
// Host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote) — ไปปากคำถัดไป/เข้าโหวต
socket.on('testimony-start-vote', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังไม่ถึงเวลา' });
/* host อาจกดก่อนทุกคน READY — บังคับให้ทุกคน (รวมบอท) ready ก่อน advance เพื่อให้ flow consistent */
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; });
advanceTestimony(sid, space);
reply({ ok: true });
});
// ผู้เล่นโหวตชี้ตัวคนร้าย
socket.on('trial-vote', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' });
if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true });
let idx = Math.floor(Number(data && data.index));
if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' });
/* รอบจับผิดตัว: ห้ามโหวตผู้ต้องสงสัยที่โหวตผิดไปแล้ว */
if (Number.isInteger(space.trialRevoteExcluded) && idx === space.trialRevoteExcluded) {
return reply({ ok: false, error: 'ผู้ต้องสงสัยนี้ถูกโหวตไปแล้ว (ผิด) — เลือกคนอื่น', excluded: true });
}
space.trialVotes = space.trialVotes || {};
space.trialVotes[socket.id] = idx;
emitTrialVoteUpdate(sid, space);
const totalTV = trialEligibleVoterTotal(space);
if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) {
computeAndEmitTrialResult(sid, space);
}
reply({ ok: true, index: idx });
});
// Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต)
socket.on('trial-reveal', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' });
computeAndEmitTrialResult(sid, space);
reply({ ok: true });
});
/* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */
socket.on('player-pick-vote', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const pv = space.pickVote;
if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' });
const tid = String((data && data.targetId) || '');
if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' });
pv.votes[socket.id] = tid;
reply({ ok: true, targetId: tid });
emitPickVoteUpdate(sid, space);
maybeResolvePickVote(sid, space);
});
/** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */
socket.on('host-console-apply', (data, cb) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id) {
return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' });
}
const humans = space.peers.size;
let total = parseInt(data && data.totalSlots, 10);
if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space);
total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total));
let bots = parseInt(data && data.botSlotCount, 10);
if (!Number.isFinite(bots)) bots = total - humans;
bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots));
total = humans + bots;
space.botSlotCount = bots;
space.maxPlayers = Math.max(humans, total - bots);
const lobbyBotThemes = syncLobbyBotThemeSlots(space);
io.to(sid).emit('host-console-settings', {
maxPlayers: space.maxPlayers,
botSlotCount: space.botSlotCount,
totalSlots: total,
lobbyBotThemes,
});
emitLobbyTintSync(sid, space);
cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes });
});
socket.on('host-console-kick-peer', ({ targetId }, cb) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id) {
return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' });
}
const tid = targetId != null ? String(targetId) : '';
if (!tid || tid === socket.id || tid === space.hostId) {
return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' });
}
if (!space.peers.has(tid)) {
return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' });
}
const targetSock = io.sockets.sockets.get(tid);
if (targetSock) {
targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' });
targetSock.leave(sid);
delete targetSock.data.spaceId;
}
space.peers.delete(tid);
io.to(sid).emit('user-left', { id: tid });
cb && cb({ ok: true });
});
/* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */
socket.on('set-ready', ({ ready }) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
if (p) p.ready = !!ready;
io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
break;
}
}
});
socket.on('start-game', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id) && space.hostId === socket.id) {
let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : '';
let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : '';
if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = '';
/* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */
if (caseId && !/^(?:[1-9]|1[0-5])$/.test(caseId)) caseId = '';
ensurePostCaseLobbyMapLoaded();
const mdBefore = getLobbyLayoutMapForSpace(space);
if (mdBefore && mdBefore.gameType === 'quiz') {
const qErr = validateQuizMap(mdBefore);
if (qErr) return reply({ ok: false, error: qErr });
clearSpaceQuizTimers(space);
startQuizGame(sid, space, mdBefore);
io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true });
return reply({ ok: true });
}
const detectiveB = !!(data && data.detectiveLobbyBStart);
if (detectiveB && (!lobbyLevel || !caseId)) {
return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' });
}
const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : '';
const isNamedLobbyA = mapNameNorm === 'lobbya';
const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet');
/* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */
const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore)
&& lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID)
&& (
mdBefore.gameType === 'lobby'
|| isNamedLobbyA
|| (detectiveB && notZepOrFrogger)
));
if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) {
return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' });
}
/* detective LobbyA→LobbyB: ไม่บังคับยืนโซนส้ม (หน้าจอเลือกคดีบังแผนที่ ขยับตัวไม่ได้) */
if (!isLobbyADetectiveStart && !lobbyHostStandingInStartArea(space, socket.id)) {
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
}
if (isLobbyADetectiveStart) {
assignDetectiveSuspectCardMinigames(space);
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
const caseNickWl = new Set();
space.peers.forEach((p) => {
caseNickWl.add(normalizeLobbyNickname(p.nickname));
});
space.caseParticipantNicknames = caseNickWl;
space.joinLocked = true;
space.troublesomeOfferSent = false;
space.troublesomeTargetId = null;
space.troublesomeResponseReceived = false;
space.troublesomeAccept = false;
space.disruptorPlayerKey = '';
space.disruptorNickname = '';
space.suspectPhaseActive = false;
space.suspectPickIndex = 0;
space.detectiveLobbyLevel = Number(lobbyLevel) || 1;
space.detectiveLobbyCaseId = Number(caseId) || 1;
space.caseId = Number(caseId) || 1;
if (space.troublesomeEligible) space.troublesomeEligible.clear();
else space.troublesomeEligible = new Set();
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
initTroublesomeState(space);
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
});
const peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null
}));
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('game-start', {
stayInRoomLobby: true,
mapId: POST_CASE_LOBBY_SPACE_ID,
lobbyLevel,
caseId,
peersSnap,
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
});
});
return reply({ ok: true });
}
const mapId = (data && data.mapId) ? String(data.mapId).trim() : null;
let peersSnap = null;
let gauntletEndsAtEmit = undefined;
if (mapId && maps.has(mapId)) {
const prevMapIdForReturn = space.mapId;
space.mapId = mapId;
space.mapData = maps.get(mapId);
const md = space.mapData;
if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
space.peers.forEach((p) => {
const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y));
p.x = sn.x;
p.y = sn.y;
});
}
if (!md || md.gameType !== 'gauntlet') {
stopGauntletTicker(space);
space.gauntletReturnMapId = null;
space.gauntletRun = null;
}
if (md && md.gameType === 'gauntlet') {
stopGauntletTicker(space);
space.gauntletReturnMapId = prevMapIdForReturn;
initGauntletPeersAll(space, md);
ensureGauntletEndsAtIfNeeded(space);
startGauntletTicker(sid, space);
gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null
? space.gauntletRun.endsAt
: null;
peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null,
gauntletJumpTicks: p.gauntletJumpTicks || 0,
gauntletScore: p.gauntletScore || 0,
gauntletEliminated: !!p.gauntletEliminated,
}));
}
if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) {
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
space.peers.forEach((_p, id) => {
space.gauntletCrownLobbyReady[id] = false;
});
space.gauntletRun = newBalloonBossShellGauntletRunState();
}
}
const gameStartPayload = {
mapId: mapId || undefined,
lobbyLevel: lobbyLevel || undefined,
caseId: caseId || undefined,
peersSnap: peersSnap || undefined,
};
if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null;
if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) {
const grs = space.gauntletRun;
gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld);
}
io.to(sid).emit('game-start', gameStartPayload);
return reply({ ok: true });
}
}
reply({ ok: false, error: 'ไม่ใช่ host' });
});
socket.on('disconnect', () => {
qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space)
let sid = socket.data.spaceId;
let space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
for (const [s, sp] of spaces) {
if (sp.peers.has(socket.id)) { sid = s; space = sp; break; }
}
}
if (!space || !space.peers.has(socket.id)) return;
const wasHost = space.hostId === socket.id;
/* เก็บข้อมูลก่อนลบ peer — ใช้ทำ grace 10 วิ (รอ reconnect) + bot takeover ระหว่างมินิเกม */
const leftPeerInfo = space.peers.get(socket.id) || {};
const leftPlayerKey = (typeof leftPeerInfo.playerKey === 'string') ? leftPeerInfo.playerKey : '';
const leftNick = (leftPeerInfo.nickname || '').trim() || 'ผู้เล่น';
/* คนที่หลุดตาย/ตกรอบไปแล้วหรือยัง (ตอนหลุด) — ใช้ตัดสินว่า bot ที่มาแทนควร spawn ใหม่ไหม (คนตายแล้วไม่ควรฟื้น) */
let leftWasEliminated = false;
try {
const md8 = (space.mapId && maps.get(space.mapId)) || space.mapData;
const gt8 = md8 && md8.gameType;
leftWasEliminated = minigamePlayerScoreSurvived(space, leftPeerInfo, gt8).survived === false;
} catch (e) { /* ignore */ }
/* สี (ธีม/สกิน) ของคนที่ออก — ใช้ให้ bot ที่มาแทนใช้สีเดียวกัน (b) */
const leftThemeIndex = parseInt(leftPeerInfo.lobbyColorThemeIndex, 10);
const leftSkinIndex = parseInt(leftPeerInfo.lobbySkinToneIndex, 10);
/* MG1 (quiz live): หลุด → แปลง entity เดิม (socket.id) เป็นบอท server "ในเกมทันที"
เก็บ avatar เดิมไว้ = สี+ตำแหน่งเดิมอัตโนมัติ + inherit คะแนน/สถานะตกรอบ (ผีถ้าตอบผิด) */
let mg1Takeover = false;
try {
const md9 = (space.mapId && maps.get(space.mapId)) || space.mapData;
const sessT = space.quizSession;
if (sessT && sessT.active && sessT.bots && md9 && md9.gameType === 'quiz') {
const st = (sessT.players && sessT.players[socket.id]) || {};
const px = Number(leftPeerInfo.x), py = Number(leftPeerInfo.y);
sessT.bots.set(socket.id, {
tier: quizBotTierForSlot(sessT.bots.size),
x: Number.isFinite(px) ? px : 1, y: Number.isFinite(py) ? py : 1,
dir: leftPeerInfo.direction || 'down', isWalking: false,
score: Math.max(0, Number(st.score) || 0),
eliminated: !!(st.cannotTrue && st.cannotFalse),
choice: null, answerRight: false, tx: null, ty: null, wanderTX: null, wanderTY: null, retargetAt: 0,
takeover: true, takeoverPlayerKey: leftPlayerKey || '',
});
mg1Takeover = true;
}
} catch (e) { /* ignore */ }
/* MG2 (gauntlet live): หลุดกลาง run → แปลง peer เป็นบอท server "ในที่เดิม" (ตำแหน่ง/คะแนน/สถานะเดิม)
เก็บ avatar เดิมไว้ + เล่นต่อเป็นบอท (host หลุดก็ไม่หยุด race) */
let mg2Takeover = false;
try {
const md9b = (space.mapId && maps.get(space.mapId)) || space.mapData;
const grT = space.gauntletRun;
/* takeover ตราบที่ run ยังมีอยู่ (รวมเฟสเดินเข้าเส้นชัย) — เหมือน MG1 ที่ takeover ตราบ session active
ยกเว้นช่วง pre-run hold (ยังไม่เริ่มวิ่ง) ที่ลบ avatar ตรงๆ ได้ */
if (grT && md9b && md9b.gameType === 'gauntlet' && !grT.crownRunHeld) {
if (!grT.bots) grT.bots = new Map();
const px = Number(leftPeerInfo.x), py = Number(leftPeerInfo.y);
const themeIdx = parseInt(leftPeerInfo.lobbyColorThemeIndex, 10);
const skinIdx = parseInt(leftPeerInfo.lobbySkinToneIndex, 10);
grT.bots.set(socket.id, {
tier: 'avg',
/* คงตำแหน่ง float เดิม (อย่า floor — เดิม floor ทำให้บอทสแนปไปทับเลน/ตัวอื่น) เหมือน MG1 */
x: Number.isFinite(px) ? px : 1,
y: Number.isFinite(py) ? py : 1,
dir: leftPeerInfo.direction || 'right',
gauntletJumpTicks: 0,
gauntletScore: Math.max(0, Number(leftPeerInfo.gauntletScore) || 0),
gauntletEliminated: !!leftPeerInfo.gauntletEliminated,
/* เก็บ identity เดิมไว้ → sync ส่งชื่อ+"(บอท)"+สี ให้ client คงไว้ (กันชื่อ/สีเพี้ยนตอน re-create) */
nickname: leftNick,
characterId: leftPeerInfo.characterId != null ? leftPeerInfo.characterId : null,
lobbyColorThemeIndex: Number.isFinite(themeIdx) ? themeIdx : null,
lobbySkinToneIndex: Number.isFinite(skinIdx) ? skinIdx : null,
takeover: true,
takeoverPlayerKey: leftPlayerKey || '',
});
mg2Takeover = true;
}
} catch (e) { /* ignore */ }
/* MG3 (stack live): หลุดกลางรอบ → แปลง turn-order entry เป็นบอท server "ในที่เดิม" (ใช้ socket.id เป็น botId
→ client เก็บ avatar เดิมไว้) inherit คะแนนสะสม แล้วบอทเล่นตาแทนต่อ (host หลุดก็ไม่ค้างตา) */
let mg3Takeover = false;
try {
const md9c = (space.mapId && maps.get(space.mapId)) || space.mapData;
const s3 = space.stackSession;
if (s3 && s3.active && !s3.ended && md9c && md9c.gameType === 'stack') {
const entry = (s3.turnOrder || []).find((e) => e.kind === 'human' && String(e.id) === String(socket.id));
if (entry) {
const inheritedPts = Math.max(0, s3.humanPts[socket.id] | 0);
s3.bots.set(socket.id, { tier: 'avg', score: inheritedPts, takeover: true, takeoverPlayerKey: leftPlayerKey || '' });
entry.kind = 'bot';
entry.botId = socket.id;
entry.id = null;
mg3Takeover = true;
}
}
} catch (e) { /* ignore */ }
/* MG4 (quiz_carry live): หลุดกลางเกม → แปลง entity เดิม (socket.id) เป็นบอท server "ในที่เดิม"
เก็บ avatar เดิม + inherit ป้ายที่ถือ/คะแนน → บอท BFS เล่นต่อ (host หลุดก็ไม่หยุด) */
let mg4Takeover = false;
try {
const md9d = (space.mapId && maps.get(space.mapId)) || space.mapData;
const sC = space.quizCarrySession;
if (sC && sC.active && !sC.ended && md9d && md9d.gameType === 'quiz_carry') {
const st = sC.players[socket.id] || {};
const px = Number(leftPeerInfo.x), py = Number(leftPeerInfo.y);
sC.bots.set(socket.id, {
tier: 'avg',
x: Number.isFinite(px) ? px : 1, y: Number.isFinite(py) ? py : 1,
dir: leftPeerInfo.direction || 'down', isWalking: false,
held: (st.held == null ? null : st.held),
score: Math.max(0, Number(st.score) || 0),
botPath: [], pathfindAfter: 0, wantIdx: null,
wanderTX: null, wanderTY: null, retargetAt: 0,
takeover: true, takeoverPlayerKey: leftPlayerKey || '',
});
delete sC.players[socket.id];
mg4Takeover = true;
}
} catch (e) { /* ignore */ }
/* MG5 (jump_survive live): หลุดกลางเกม → แปลง entity เดิม (socket.id) เป็นบอท server "ในที่เดิม"
เก็บ avatar/ตำแหน่งเดิม + inherit สถานะตาย → บอทกระโดดเอาตัวรอดต่อ (host หลุดก็ไม่หยุด) */
let mg5Takeover = false;
try {
const md9e = (space.mapId && maps.get(space.mapId)) || space.mapData;
const sJ = space.jumpSurviveSession;
if (sJ && sJ.active && !sJ.ended && md9e && md9e.gameType === 'jump_survive') {
const st = sJ.players[socket.id] || {};
const wasDead = !!st.eliminated || !!leftPeerInfo.jumpSurviveEliminated;
const px = Number(leftPeerInfo.x), py = Number(leftPeerInfo.y);
sJ.bots.set(socket.id, {
tier: 'avg',
x: Number.isFinite(px) ? px : 1, y: Number.isFinite(py) ? py : 1,
vy: 0, onGround: false, dir: leftPeerInfo.direction || 'down', walking: false,
eliminated: wasDead,
wishX: 0, wishNext: Date.now() + 200, nextJumpAi: Date.now() + 200,
takeover: true, takeoverPlayerKey: leftPlayerKey || '',
});
delete sJ.players[socket.id];
mg5Takeover = true;
}
} catch (e) { /* ignore */ }
/* MG6 (space_shooter live): หลุดกลางเกม → แปลงเป็นบอท server "ในที่เดิม" (คะแนนเดิม) ยิงต่อ */
let mg6Takeover = false;
try {
const md9f = (space.mapId && maps.get(space.mapId)) || space.mapData;
const sS = space.spaceShooterSession;
if (sS && sS.active && !sS.ended && md9f && md9f.gameType === 'space_shooter') {
const ts = jsTileSize(md9f);
const mh = (md9f.height || 15) * ts;
const cx = Number.isFinite(Number(leftPeerInfo.x)) ? (Number(leftPeerInfo.x) + 0.5) * ts : (md9f.width || 20) * ts * 0.5;
const cy = ssClampCy(Number.isFinite(Number(leftPeerInfo.y)) ? (Number(leftPeerInfo.y) + 0.5) * ts : mh - ts * 1.5, mh);
const st = sS.players[socket.id] || {};
sS.bots.set(socket.id, {
tier: 'avg', cx, cy, dir: 'up',
score: Math.max(0, Number(leftPeerInfo.spaceShooterScore) || 0),
eliminated: !!st.eliminated || !!leftPeerInfo.spaceShooterEliminated,
fireCd: 0.3 + Math.random() * 0.3,
wanderVx: Math.random() < 0.5 ? -1 : 1, wanderVy: Math.random() < 0.5 ? -1 : 1, wanderNext: Date.now() + 500,
takeover: true, takeoverPlayerKey: leftPlayerKey || '',
});
delete sS.players[socket.id];
mg6Takeover = true;
}
} catch (e) { /* ignore */ }
/* MG7 (balloon_boss live): หลุดกลางเกม → แปลงเป็นบอท server "ในที่เดิม" (คะแนน/ดาเมจ/ลูกโป่ง/ตายเดิม) ยิงต่อ */
let mg7Takeover = false;
try {
const md9g = (space.mapId && maps.get(space.mapId)) || space.mapData;
const sB = space.balloonBossSession;
if (sB && sB.active && !sB.ended && md9g && md9g.gameType === 'balloon_boss') {
const ts = jsTileSize(md9g);
const mw = (md9g.width || 20) * ts, mh = (md9g.height || 15) * ts;
const cx = Number.isFinite(Number(leftPeerInfo.x)) ? (Number(leftPeerInfo.x) + 0.5) * ts : mw * 0.5;
const cy = Number.isFinite(Number(leftPeerInfo.y)) ? (Number(leftPeerInfo.y) + 0.5) * ts : mh - ts * 1.4;
const cl = bbClampWorld(cx, cy, mw, mh);
sB.bots.set(socket.id, {
tier: 'avg', homeCx: cl.cx, homeCy: cl.cy, cx: cl.cx, cy: cl.cy,
score: Math.max(0, Number(leftPeerInfo.balloonBossScore) || 0),
dmg: Math.max(0, Number(leftPeerInfo.balloonBossBossDmg) || 0),
balloons: Math.max(0, Number(leftPeerInfo.balloonBossBalloons) || balloonBossBalloonsForMap(md9g)),
eliminated: !!leftPeerInfo.balloonBossEliminated,
fireCd: 0.6 + Math.random() * 0.8, phase: Math.random() * Math.PI * 2,
takeover: true, takeoverPlayerKey: leftPlayerKey || '',
});
mg7Takeover = true;
}
} catch (e) { /* ignore */ }
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);
stopStackTicker(space);
space.stackSession = null;
clearSpaceQuizTimers(space);
space.quizSession = null;
if (space.quizCarrySession) space.quizCarrySession.active = false;
space.quizCarrySession = null;
if (space.jumpSurviveSession) space.jumpSurviveSession.active = false;
space.jumpSurviveSession = null;
if (space.spaceShooterSession) space.spaceShooterSession.active = false;
space.spaceShooterSession = null;
if (space.balloonBossSession) space.balloonBossSession.active = false;
space.balloonBossSession = 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 = pickNextHostPeer(space); /* คนอยู่นานสุด (spawnJoinOrder ต่ำสุด) — แทน Map-first */
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 });
glog(sid, space, 'host-change', 'host ใหม่ → ' + ((firstPeer.nickname) || String(firstPeer.id).slice(0, 6)), leftNick);
}
}
if (mg2Takeover) {
/* MG2: ไม่ลบ avatar — แปลงเป็นบอทในที่เดิม (สี/ตำแหน่ง/คะแนนเดิม) ให้ client เก็บ entity ไว้ */
io.to(sid).emit('gauntlet-player-to-bot', { id: socket.id, nickname: leftNick });
glog(sid, space, 'leave', 'หลุด MG2 → บอทแทนในเกม (สี/ตำแหน่งเดิม) · เหลือคน ' + space.peers.size, leftNick);
} else if (mg1Takeover) {
/* MG1: ไม่ลบ avatar — แปลงเป็นบอทในที่เดิม (สี/ตำแหน่ง/สถานะเดิม) ให้ client เก็บ entity ไว้ */
io.to(sid).emit('quiz-player-to-bot', { id: socket.id, nickname: leftNick });
glog(sid, space, 'leave', 'หลุด MG1 → บอทแทนในเกม (สี/ตำแหน่งเดิม) · เหลือคน ' + space.peers.size, leftNick);
} else if (mg3Takeover) {
/* MG3: ไม่ลบ avatar — แปลง seat เป็นบอท server เล่นตาแทน (สี/ตำแหน่งเดิม) + sync ลำดับตาใหม่ */
io.to(sid).emit('stack-player-to-bot', { id: socket.id, nickname: leftNick });
emitStackStateServer(sid, space);
glog(sid, space, 'leave', 'หลุด MG3 → บอทแทนในเกม · เหลือคน ' + space.peers.size, leftNick);
} else if (mg4Takeover) {
/* MG4: ไม่ลบ avatar — แปลงเป็นบอท server (ตำแหน่ง/ป้ายที่ถือ/คะแนนเดิม) BFS เล่นต่อ */
io.to(sid).emit('quiz-carry-player-to-bot', { id: socket.id, nickname: leftNick });
glog(sid, space, 'leave', 'หลุด MG4 → บอทแทนในเกม (สี/ตำแหน่งเดิม) · เหลือคน ' + space.peers.size, leftNick);
} else if (mg5Takeover) {
/* MG5: ไม่ลบ avatar — แปลงเป็นบอท server (ตำแหน่ง/สถานะเดิม) กระโดดเอาตัวรอดต่อ */
io.to(sid).emit('jump-survive-player-to-bot', { id: socket.id, nickname: leftNick });
glog(sid, space, 'leave', 'หลุด MG5 → บอทแทนในเกม (สี/ตำแหน่งเดิม) · เหลือคน ' + space.peers.size, leftNick);
} else if (mg6Takeover) {
/* MG6: ไม่ลบ avatar — แปลงเป็นบอท server (ตำแหน่ง/คะแนนเดิม) ยิงต่อ */
io.to(sid).emit('space-shooter-player-to-bot', { id: socket.id, nickname: leftNick });
glog(sid, space, 'leave', 'หลุด MG6 → บอทแทนในเกม (สี/ตำแหน่งเดิม) · เหลือคน ' + space.peers.size, leftNick);
} else if (mg7Takeover) {
/* MG7: ไม่ลบ avatar — แปลงเป็นบอท server (ตำแหน่ง/คะแนน/ดาเมจ/ลูกโป่งเดิม) ยิงบอสต่อ */
io.to(sid).emit('balloon-boss-player-to-bot', { id: socket.id, nickname: leftNick });
glog(sid, space, 'leave', 'หลุด MG7 → บอทแทนในเกม (สี/ตำแหน่งเดิม) · เหลือคน ' + space.peers.size, leftNick);
} else {
io.to(sid).emit('user-left', { id: socket.id });
glog(sid, space, 'leave', 'ออกจากห้อง · เหลือคน ' + space.peers.size, leftNick);
}
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);
}
/* ห้องส่งหลักฐาน (testimony) — คนหลุดระหว่างเฟส → ลบ submit/ready ที่ค้าง แล้ว re-emit สถานะ
เพื่อให้ "จำนวนที่ต้องส่ง/ready" ลดตามจริง (peers.delete ไปแล้วด้านบน) ไม่งั้น host รอคนที่ไม่อยู่ = ไปต่อไม่ได้ */
if (space.testimonyActive) {
if (space.testimonySubmitted) delete space.testimonySubmitted[socket.id];
if (space.testimonyReadyNext) delete space.testimonyReadyNext[socket.id];
emitTestimonyStatus(sid, space);
if (space.testimonyRevealed) {
io.to(sid).emit('testimony-ready-update', {
ready: Object.keys(space.testimonyReadyNext || {}),
totalPlayers: testimonyTotalParticipants(space),
hostId: space.hostId,
});
}
}
/* หลุดในสาย "คดี" — ครอบทั้ง 3 จังหวะ:
(1) ระหว่างมินิเกม (2) ระหว่าง LobbyB (3) ช่วงเปลี่ยนหน้า minigame→LobbyB (detectiveMinigameActive เพิ่งเป็น false)
ทั้งหมดให้เวลา reconnect 10 วิ (กันเปลี่ยนหน้า lobbyB→play→lobbyB ที่ socket หลุดชั่วคราว)
ครบเวลายังไม่กลับ → ลบออกจาก roster คดี + เติมบอทสีตามคนที่ออก (ไม่งั้น LobbyB ค้าง "รอผู้เล่น x/y" เริ่มเกมต่อไม่ได้) */
const inCaseFlowForBot = space.detectiveMinigameActive
|| serverMapIsPostCaseLobbyB(space)
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0);
if (inCaseFlowForBot && leftPlayerKey) {
io.to(sid).emit('peer-disconnect-grace', { id: socket.id, nickname: leftNick, seconds: DISCONNECT_GRACE_SEC });
if (!space.graceReconnect || typeof space.graceReconnect !== 'object') space.graceReconnect = {};
if (!space.graceTimers || typeof space.graceTimers !== 'object') space.graceTimers = {};
if (space.graceTimers[leftPlayerKey]) clearTimeout(space.graceTimers[leftPlayerKey]);
space.graceReconnect[leftPlayerKey] = Date.now();
space.graceTimers[leftPlayerKey] = setTimeout(() => {
const sp = spaces.get(sid);
if (!sp) return;
if (sp.graceTimers) delete sp.graceTimers[leftPlayerKey];
/* กลับมาแล้ว (playerKey อยู่ใน peers) → ไม่ต้อง takeover */
let back = false;
if (sp.peers) sp.peers.forEach((p) => { if (p && p.playerKey === leftPlayerKey) back = true; });
if (sp.graceReconnect) delete sp.graceReconnect[leftPlayerKey];
if (back) return;
/* คนนี้กลายเป็นบอทถาวร → เอาออกจาก roster คดี เพื่อ LobbyB ไม่รอคนนี้อีก (เดิมยังนับ → ค้าง "รอผู้เล่น (x/y)") */
if (sp.caseParticipantNicknames && leftNick) {
try { sp.caseParticipantNicknames.delete(normalizeLobbyNickname(leftNick)); } catch (e) { /* ignore */ }
emitDetectiveLobbyBPresence(sid, sp);
}
/* คนออกถาวร → ลด expectedActive (จำนวนคนที่ต้องเข้าครบก่อนเริ่ม) + re-emit lobby-sync
แก้บั๊ก: host หลุดในหน้า how-to ก่อนกด START → host ใหม่กดไม่ได้เพราะค้าง "รอผู้เล่นครบ (x/y)" (y ไม่ลด) */
if (typeof sp.minigameExpectedActiveCount === 'number' && sp.minigameExpectedActiveCount > 0) {
sp.minigameExpectedActiveCount = Math.max(0, sp.minigameExpectedActiveCount - 1);
}
if (isCrownLobbyShellSpace(sp)) io.to(sid).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(sp));
/* (c) persist + (b) สี: เพิ่มจำนวนบอทถาวร 1 ตัว (อยู่ยาวจนจบคดี: LobbyB → เกมอื่น → vote)
แล้ว pin "สีของคนที่ออก" ให้ slot บอทใหม่ → บอทที่มาแทนสีตรงคนเดิม + ส่งสีให้ทุกเครื่องตรงกัน */
try {
const oldBotCount = effectiveBotSlotCount(sp);
if (oldBotCount < LOBBY_SLOT_TOTAL) {
sp.botSlotCount = oldBotCount + 1;
if (leftThemeIndex >= 1 && leftThemeIndex <= LOBBY_THEME_COUNT) {
if (!sp.lobbyBotThemeBySlot || typeof sp.lobbyBotThemeBySlot !== 'object') sp.lobbyBotThemeBySlot = {};
sp.lobbyBotThemeBySlot[oldBotCount] = {
themeIndex: leftThemeIndex,
skinToneIndex: (leftSkinIndex >= 1 && leftSkinIndex <= LOBBY_SKIN_COUNT) ? leftSkinIndex : pickLobbySkinIndexForSlot(oldBotCount),
};
}
emitLobbyTintSync(sid, sp);
}
} catch (e) { /* ignore */ }
io.to(sid).emit('peer-bot-takeover', { nickname: leftNick, eliminated: leftWasEliminated });
glog(sid, sp, 'bot-takeover', 'หลุดเกิน ' + DISCONNECT_GRACE_SEC + 'วิ → บอทแทน' + (leftWasEliminated ? ' (ตกรอบแล้ว ไม่ spawn)' : ' (สีตามคนออก)'), leftNick);
}, DISCONNECT_GRACE_SEC * 1000);
}
}
});
/* 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;
const gr = space.gauntletRun;
if (!gr) return;
/* server-authority: ยังไม่เริ่ม run (crownRunHeld) หรือยิงเร็วผิดปกติ → ปฏิเสธ (กัน client ปลอม/desync) */
if (gr.crownRunHeld) return;
if (gr.liveStartMs != null && (Date.now() - gr.liveStartMs) < GAUNTLET_FINISH_MIN_RUN_MS) return;
if (gr.finishPhase) return;
gr.finishPhase = true;
gr.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;
/* G2: เลื่อนเวลาเริ่มจริงไปอนาคต = ค้างที่ start (hold) → ทั้ง bg(client) + obstacle/bot(server) gate ด้วย liveStartMs เดียวกัน */
gr.liveStartMs = Date.now() + GAUNTLET_PRERUN_HOLD_MS; /* G1/G2: เวลาเริ่ม run จริง (server) — broadcast ให้ทุกเครื่อง anchor scroll เดียวกัน */
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 });
/* MG4 server-auth: เกมจริง (detective) → server เริ่ม engine เองหลัง 3-2-1 (เหมือน MG1 ใน performCrownLobbyStart) */
setTimeout(() => {
const sp = spaces.get(sid);
if (!sp || !sp.detectiveMinigameActive) return;
const md2 = (sp.mapId && maps.get(sp.mapId)) || sp.mapData;
if (md2 && md2.gameType === 'quiz_carry') startQuizCarryGame(sid, sp);
}, LIVE_COUNTDOWN_MS);
});
/* host เลือกคำถาม quiz_carry → relay index ให้คนอื่นในห้องใช้ข้อเดียวกัน (กันสุ่มไม่ตรงกัน)
server-auth (เกมจริง): server เป็นเจ้าของคำถามผ่าน quiz-carry-phase → ข้าม relay */
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 */
if (space.quizCarrySession && space.quizCarrySession.active) return;
const idx = Number(data && data.idx);
if (!Number.isInteger(idx) || idx < 0) return;
socket.to(sid).emit('quiz-carry-question', { idx });
});
/* host จบเซสชัน quiz_carry → ให้คนอื่นโชว์สรุปพร้อมกัน (server-auth ใช้ quiz-carry-ended แทน) */
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;
if (space.quizCarrySession && space.quizCarrySession.active) return;
socket.to(sid).emit('quiz-carry-session-complete', {});
});
/* MG4 server-auth: client ส่ง intent "หยิบ/ส่ง" (กด F / ปุ่ม GRAB) → server ตัดสินจากตำแหน่งล่าสุด */
socket.on('quiz-carry-grab', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return;
const s = space.quizCarrySession;
if (!s || !s.active || s.ended) return;
quizCarryServerTryInteract(sid, space, socket.id, false);
});
/* ตอบเวลา 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));
/* หมายเหตุ: host (ผู้เล่นจริง) เป็นคนกด START เอง — ไม่ auto-start (ตามที่ต้องการให้ host คุมจังหวะเริ่ม) */
});
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' });
performCrownLobbyStart(sid, space);
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;
const sSm = space.spaceShooterSession;
if (sSm && sSm.active && !sSm.ended) {
if (!sSm.players[socket.id]) sSm.players[socket.id] = {};
sSm.players[socket.id].eliminated = true;
}
}
}
if (p && md && md.gameType === 'jump_survive' && data) {
/* MG5: การตายมาจากเจ้าตัว → relay แม้ตำแหน่งไม่เปลี่ยน (ตอนตายไม่ขยับ) ไม่งั้นคนอื่นไม่รู้ = "ไม่ยอมตาย" */
if (data.jumpSurviveEliminated && !p.jumpSurviveEliminated) {
p.jumpSurviveEliminated = true;
moveForceEmit = true;
/* MG5 server-auth: บันทึกการตายของคนจริงเข้า session (server เป็นเจ้าของรอด/เกรด + เช็ค all_dead) */
const sJm = space.jumpSurviveSession;
if (sJm && sJm.active && !sJm.ended) {
if (!sJm.players[socket.id]) sJm.players[socket.id] = {};
sJm.players[socket.id].eliminated = true;
}
}
/* isWalking ของเจ้าตัว → relay ให้คนอื่นเห็น anim วิ่ง (เดิม out ไม่ใส่ jsWalking = client อื่นไม่เคยได้ → anim ไม่ขึ้น);
เปลี่ยนสถานะ (เริ่มเดิน/หยุด) → force emit แม้ตำแหน่งไม่เปลี่ยน */
const jwIn = !!data.jsWalking;
if (jwIn !== !!p.jumpSurviveWalking) moveForceEmit = true;
p.jumpSurviveWalking = jwIn;
}
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;
}
/* server-auth (เกมจริง): server เป็นเจ้าของ held/score ผ่าน quiz-carry-held/result → ไม่รับค่าจาก client (กัน spoof/desync) */
const quizCarrySrvAuth = !!(space.quizCarrySession && space.quizCarrySession.active);
if (!quizCarrySrvAuth && 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 (!quizCarrySrvAuth && p && md && md.gameType === 'quiz_carry' && data && data.quizCarryScore !== undefined) {
/* คะแนน carry ของผู้เล่น (มนุษย์) → relay ให้ทุกเครื่องเห็นคะแนนตรงกัน */
const sc = Math.max(0, Math.floor(Number(data.quizCarryScore) || 0));
if (sc !== (p.quizCarryScore | 0)) moveForceEmit = true;
p.quizCarryScore = sc;
}
/* L2 server clamp (เฉพาะ lobby): จำกัดระยะต่อ move ตามเวลาจริง → กันเครื่องเร็ว/โกงวิ่งเกินความเร็ว
เผื่อ jitter/lag burst ด้วย factor 3 + ฐาน 0.5 (move ปกติ ~12.5Hz เดินแค่ ~0.72u อยู่ใต้เพดานสบาย)
ไม่แตะมินิเกม (ฟิสิกส์ตก/กระโดดเร็วกว่านี้) */
if (p && md && md.gameType === 'lobby') {
const tnow = Date.now();
const tlast = (typeof p.lastLobbyMoveTs === 'number') ? p.lastLobbyMoveTs : 0;
let edt = tlast ? (tnow - tlast) / 1000 : 0.1;
if (!(edt > 0)) edt = 0.1;
if (edt > 0.5) edt = 0.5;
p.lastLobbyMoveTs = tnow;
const maxDist = LOBBY_MOVE_SPEED_PER_SEC * edt * 3 + 0.5;
const ddx = Number(nx) - Number(p.x);
const ddy = Number(ny) - Number(p.y);
if (Number.isFinite(ddx) && Number.isFinite(ddy)) {
const dlen = Math.hypot(ddx, ddy);
if (dlen > maxDist && dlen > 0) {
const s = maxDist / dlen;
nx = Number(p.x) + ddx * s;
ny = Number(p.y) + ddy * s;
}
}
}
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, lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, lobbySkinToneIndex: p.lobbySkinToneIndex || null };
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);
out.quizCarryScore = Math.max(0, p.quizCarryScore | 0);
}
if (md && md.gameType === 'jump_survive') {
out.jumpSurviveEliminated = !!p.jumpSurviveEliminated;
out.jsWalking = !!p.jumpSurviveWalking; /* anim วิ่งให้คนอื่นเห็น */
}
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));