diff --git a/www/html/Admin/api/achievements.php b/www/html/Admin/api/achievements.php
index 0b7efe8..f95df4f 100644
--- a/www/html/Admin/api/achievements.php
+++ b/www/html/Admin/api/achievements.php
@@ -228,6 +228,43 @@ if ($action === 'progress' && $method === 'POST') {
json_response(['ok' => true, 'id' => $id, 'value' => $val, 'unlocked' => $val >= $target]);
}
+/* streak — สำหรับ achievement แบบ "ติดต่อกัน" (a6/d4/e1): event 'hit' นับต่อ, 'miss' รีเซ็ต 0.
+ * เก็บ current streak แยกใน accounts[].achvStreak {id:count} · achievement = best streak ที่เคยถึง (ไม่ลดลงเวลารีเซ็ต) */
+if ($action === 'streak' && $method === 'POST') {
+ $secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
+ $expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
+ $secret = (string) ($body['secret'] ?? '');
+ if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
+ json_response(['ok' => false, 'error' => 'unauthorized'], 403);
+ }
+ $key = trim((string) ($body['playerKey'] ?? ''));
+ $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
+ $event = (string) ($body['event'] ?? '');
+ if (!achv_valid_key($key) || $id === '' || ($event !== 'hit' && $event !== 'miss')) {
+ json_response(['ok' => false, 'error' => 'bad params'], 400);
+ }
+ $catalog = achv_load_catalog();
+ $target = 0;
+ foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
+ if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
+
+ $store = read_store();
+ $i = achv_find_or_create($store, $key);
+ $acct = $store['accounts'][$i];
+ $streaks = (isset($acct['achvStreak']) && is_array($acct['achvStreak'])) ? $acct['achvStreak'] : [];
+ $sc = max(0, (int) ($streaks[$id] ?? 0));
+ $sc = ($event === 'hit') ? ($sc + 1) : 0;
+ $streaks[$id] = $sc;
+ $cur = achv_progress_of($acct);
+ $best = max(0, min($target, max((int) ($cur[$id] ?? 0), $sc))); /* achievement = best ไม่ลดลง */
+ $cur[$id] = $best;
+ $store['accounts'][$i]['achievements'] = (object) $cur;
+ $store['accounts'][$i]['achvStreak'] = (object) $streaks;
+ $store['accounts'][$i]['updatedAt'] = gmdate('c');
+ if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
+ json_response(['ok' => true, 'id' => $id, 'streak' => $sc, 'value' => $best, 'unlocked' => $best >= $target]);
+}
+
/* ---------- Admin ---------- */
require_login();
diff --git a/www/html/Admin/api/achievements.php.bak-20260706-014242 b/www/html/Admin/api/achievements.php.bak-20260706-014242
new file mode 100644
index 0000000..0b7efe8
--- /dev/null
+++ b/www/html/Admin/api/achievements.php.bak-20260706-014242
@@ -0,0 +1,345 @@
+ accounts[].achievements { id: count }
+ *
+ * Public (ไม่ต้องล็อกอิน):
+ * GET ?action=state&playerKey=KEY -> { ok, catalog:[...], progress:{id:count} }
+ * GET ?action=catalog -> { ok, catalog:[...] }
+ *
+ * Server-only (มี secret game-award-secret.txt) — สำหรับ auto-track ภายหลัง:
+ * POST {action:'progress', secret, playerKey, id, inc?, set?}
+ *
+ * Admin (ต้องล็อกอิน session):
+ * POST {action:'saveCatalog', catalog:[...]}
+ * GET ?action=players -> รายชื่อผู้เล่น + จำนวนที่ปลดล็อก
+ * GET ?action=player&playerKey=KEY -> progress ของผู้เล่นคนเดียว
+ * POST {action:'setProgress', playerKey, id, value}
+ * POST {action:'unlock', playerKey, id} -> set = target
+ * POST {action:'resetPlayer', playerKey} -> ล้าง progress ทั้งหมด
+ */
+require __DIR__ . '/_common.php';
+
+date_default_timezone_set('Asia/Bangkok');
+
+define('ACHV_CATALOG_FILE', ADMIN_PRIVATE_DIR . '/achievements.json');
+
+function achv_default_catalog(): array
+{
+ return [
+ ['id' => 'a1_first_deduction', 'g' => 1, 'title' => 'First Deduction', 'desc' => 'โหวตถูกตัวคนร้ายเป็นครั้งแรก', 'target' => 1, 'enabled' => true],
+ ['id' => 'a2_sharp_eye', 'g' => 1, 'title' => 'Sharp Eye', 'desc' => 'สะสมหลักฐานระดับมีน้ำหนัก (Silver) ครบ 10 ใบ', 'target' => 10, 'enabled' => true],
+ ['id' => 'a3_mind_architect', 'g' => 1, 'title' => 'Mind Architect', 'desc' => 'สะสมหลักฐานครบทุกระดับ (ทั่วไป, มีน้ำหนัก, ชี้ชัด) ใน 1 คดี', 'target' => 1, 'enabled' => true],
+ ['id' => 'a4_logic_over_luck', 'g' => 1, 'title' => 'Logic Over Luck', 'desc' => 'โหวตถูกโดยไม่พึ่งหลักฐานชี้ชัด (Legendary) เลย 3 ครั้ง', 'target' => 3, 'enabled' => true],
+ ['id' => 'a5_truth_hunter', 'g' => 1, 'title' => 'Truth Hunter', 'desc' => 'จับคนร้ายถูกตัวสะสมครบ 20 คดี', 'target' => 20, 'enabled' => true],
+ ['id' => 'a6_unbreakable_logic', 'g' => 1, 'title' => 'Unbreakable Logic', 'desc' => 'โหวตถูกตัวติดกัน 5 คดีรวด', 'target' => 5, 'enabled' => true],
+
+ ['id' => 'b1_evidence_collector', 'g' => 2, 'title' => 'Evidence Collector', 'desc' => 'สะสมหลักฐานระดับทั่วไป (Common) ครบ 20 ใบ', 'target' => 20, 'enabled' => true],
+ ['id' => 'b2_relentless_investigator', 'g' => 2, 'title' => 'Relentless Investigator', 'desc' => 'เล่น Mini Game ครบทุกรอบ ใน 1 คดี', 'target' => 1, 'enabled' => true],
+ ['id' => 'b3_hidden_fragment', 'g' => 2, 'title' => 'Hidden Fragment', 'desc' => 'ค้นพบหลักฐานระดับชี้ชัด (Legendary/Gold) เป็นครั้งแรก', 'target' => 1, 'enabled' => true],
+ ['id' => 'b4_data_miner', 'g' => 2, 'title' => 'Data Miner', 'desc' => 'สะสมการ์ดหลักฐานรวมครบ 100 ใบ', 'target' => 100, 'enabled' => true],
+ ['id' => 'b5_deep_scanner', 'g' => 2, 'title' => 'Deep Scanner', 'desc' => 'เก็บไอเทมช่วยเหลือ (ตำรวจ/ทนาย) สะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
+
+ ['id' => 'c1_early_accusation', 'g' => 3, 'title' => 'Early Accusation', 'desc' => 'ชี้ตัวคนร้ายก่อนที่จะเปิดหลักฐานครบ', 'target' => 1, 'enabled' => true],
+ ['id' => 'c2_high_stakes', 'g' => 3, 'title' => 'High Stakes', 'desc' => 'ชี้ตัวคนร้ายถูกโดยมีหลักฐานในมือไม่เกิน 3 ใบ', 'target' => 1, 'enabled' => true],
+ ['id' => 'c3_quick_draw', 'g' => 3, 'title' => 'Quick Draw', 'desc' => 'ชี้ตัวคนร้ายเร็วที่สุดในทีมสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
+ ['id' => 'c4_clutch_mind', 'g' => 3, 'title' => 'Clutch Mind', 'desc' => 'โหวตถูกในช่วง 10 วินาทีสุดท้ายก่อนหมดเวลา', 'target' => 1, 'enabled' => true],
+ ['id' => 'c5_lone_wolf', 'g' => 3, 'title' => 'Lone Wolf', 'desc' => 'โหวตสวนทางกับเสียงส่วนใหญ่ของทีม (คุณถูกคนเดียว)', 'target' => 1, 'enabled' => true],
+
+ ['id' => 'd1_minigame_solver', 'g' => 4, 'title' => 'Minigame Solver', 'desc' => 'เอาชีวิตรอด / เล่นมินิเกมสำเร็จ 20 ครั้ง', 'target' => 20, 'enabled' => true],
+ ['id' => 'd2_silent_guardian', 'g' => 4, 'title' => 'Silent Guardian', 'desc' => 'ไม่โดนโหวตใช้การ์ด Event พิเศษ เลยตลอด 5 คดี', 'target' => 5, 'enabled' => true],
+ ['id' => 'd3_the_backbone', 'g' => 4, 'title' => 'The Backbone', 'desc' => 'ส่งมอบหลักฐานให้เพื่อนวิเคราะห์ครบ 30 ใบ', 'target' => 30, 'enabled' => true],
+ ['id' => 'd4_flawless_diver', 'g' => 4, 'title' => 'Flawless Diver', 'desc' => 'ไม่โหวตจับผิดตัวเลยตลอด 15 คดี', 'target' => 15, 'enabled' => true],
+
+ ['id' => 'e1_the_observer', 'g' => 5, 'title' => 'The Observer', 'desc' => 'เล่นจบ 15 คดีโดยไม่เคยสัมผัสหลักฐานชี้ชัด (Legendary) เลยสักครั้ง', 'target' => 15, 'enabled' => true],
+ ['id' => 'e2_the_impostor', 'g' => 5, 'title' => 'The Impostor', 'desc' => 'รับบทเป็น "ตัวป่วน" ครั้งแรก', 'target' => 1, 'enabled' => true],
+ ['id' => 'e3_master_of_doubt', 'g' => 5, 'title' => 'Master of Doubt', 'desc' => 'เอาชนะคดีในฐานะตัวป่วนได้สำเร็จ', 'target' => 1, 'enabled' => true],
+ ['id' => 'e4_agent_of_chaos', 'g' => 5, 'title' => 'Agent of Chaos', 'desc' => 'รับบทเป็นตัวป่วนสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
+ ['id' => 'e5_slippery_eel', 'g' => 5, 'title' => 'Slippery Eel', 'desc' => 'เป็นตัวป่วนแต่รอดพ้นจากการถูกจับได้ (ไม่ถูกโหวตออก) จนจบเกม 5 ครั้ง', 'target' => 5, 'enabled' => true],
+ ];
+}
+
+function achv_sanitize_catalog(array $arr): array
+{
+ $out = [];
+ $seen = [];
+ foreach ($arr as $row) {
+ if (!is_array($row)) continue;
+ $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($row['id'] ?? ''));
+ if ($id === '' || isset($seen[$id])) continue;
+ $seen[$id] = true;
+ $g = (int) ($row['g'] ?? 1);
+ if ($g < 1 || $g > 5) $g = 1;
+ $title = trim((string) ($row['title'] ?? ''));
+ $desc = trim((string) ($row['desc'] ?? ''));
+ if (function_exists('mb_substr')) {
+ $title = mb_substr($title, 0, 60);
+ $desc = mb_substr($desc, 0, 200);
+ }
+ $target = (int) ($row['target'] ?? 1);
+ if ($target < 1) $target = 1;
+ if ($target > 100000) $target = 100000;
+ $out[] = [
+ 'id' => $id,
+ 'g' => $g,
+ 'title' => $title !== '' ? $title : $id,
+ 'desc' => $desc,
+ 'target' => $target,
+ 'enabled' => !isset($row['enabled']) || !empty($row['enabled']),
+ ];
+ }
+ return $out;
+}
+
+function achv_load_catalog(): array
+{
+ if (is_file(ACHV_CATALOG_FILE)) {
+ $raw = @file_get_contents(ACHV_CATALOG_FILE);
+ $j = json_decode($raw ?: '[]', true);
+ if (is_array($j) && $j) {
+ $c = achv_sanitize_catalog($j);
+ if ($c) return $c;
+ }
+ }
+ return achv_default_catalog();
+}
+
+function achv_save_catalog(array $catalog): bool
+{
+ if (!is_dir(ADMIN_PRIVATE_DIR)) {
+ if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) return false;
+ }
+ $tmp = ACHV_CATALOG_FILE . '.tmp.' . bin2hex(random_bytes(4));
+ $json = json_encode($catalog, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ if ($json === false) return false;
+ if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;
+ if (!rename($tmp, ACHV_CATALOG_FILE)) { @unlink($tmp); return false; }
+ return true;
+}
+
+function achv_valid_key(string $key): bool
+{
+ return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
+}
+
+/* หา index บัญชี guest ตาม playerKey (สร้างใหม่ถ้าไม่มี) */
+function achv_find_or_create(array &$store, string $key): int
+{
+ foreach ($store['accounts'] as $i => $a) {
+ if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
+ return $i;
+ }
+ }
+ $store['accounts'][] = [
+ 'id' => new_id(),
+ 'email' => '',
+ 'displayName' => 'Guest',
+ 'loginType' => 'guest',
+ 'providerUserId' => $key,
+ 'notes' => 'auto: achievements',
+ 'blocked' => false,
+ 'coins' => 0,
+ 'achievements' => new \stdClass(),
+ 'createdAt' => gmdate('c'),
+ 'updatedAt' => gmdate('c'),
+ ];
+ return count($store['accounts']) - 1;
+}
+
+function achv_progress_of(array $account): array
+{
+ $p = $account['achievements'] ?? [];
+ if (!is_array($p)) return [];
+ $out = [];
+ foreach ($p as $k => $v) {
+ $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $k);
+ if ($id === '') continue;
+ $out[$id] = max(0, (int) $v);
+ }
+ return $out;
+}
+
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$action = (string) ($_GET['action'] ?? '');
+if ($method === 'POST') {
+ $body = require_json_body();
+ if (!$action) $action = (string) ($body['action'] ?? '');
+} else {
+ $body = [];
+}
+
+/* ---------- Public ---------- */
+if ($action === 'catalog' && $method === 'GET') {
+ json_response(['ok' => true, 'catalog' => achv_load_catalog()]);
+}
+
+if ($action === 'state' && $method === 'GET') {
+ $key = trim((string) ($_GET['playerKey'] ?? ''));
+ if (!achv_valid_key($key)) {
+ json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
+ }
+ $catalog = achv_load_catalog();
+ $store = read_store();
+ $progress = [];
+ foreach ($store['accounts'] as $a) {
+ if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
+ $progress = achv_progress_of($a);
+ break;
+ }
+ }
+ json_response(['ok' => true, 'catalog' => $catalog, 'progress' => (object) $progress]);
+}
+
+/* ---------- Server-only (secret) : auto-track ภายหลัง ---------- */
+if ($action === 'progress' && $method === 'POST') {
+ $secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
+ $expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
+ $secret = (string) ($body['secret'] ?? '');
+ if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
+ json_response(['ok' => false, 'error' => 'unauthorized'], 403);
+ }
+ $key = trim((string) ($body['playerKey'] ?? ''));
+ $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
+ if (!achv_valid_key($key) || $id === '') {
+ json_response(['ok' => false, 'error' => 'bad params'], 400);
+ }
+ $catalog = achv_load_catalog();
+ $target = 0;
+ foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
+ if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
+
+ $store = read_store();
+ $i = achv_find_or_create($store, $key);
+ $cur = achv_progress_of($store['accounts'][$i]);
+ if (isset($body['set'])) {
+ $val = max(0, min($target, (int) $body['set']));
+ } else {
+ $inc = (int) ($body['inc'] ?? 1);
+ $val = max(0, min($target, ($cur[$id] ?? 0) + $inc));
+ }
+ $cur[$id] = $val;
+ $store['accounts'][$i]['achievements'] = (object) $cur;
+ $store['accounts'][$i]['updatedAt'] = gmdate('c');
+ if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
+ json_response(['ok' => true, 'id' => $id, 'value' => $val, 'unlocked' => $val >= $target]);
+}
+
+/* ---------- Admin ---------- */
+require_login();
+
+if ($action === 'saveCatalog' && $method === 'POST') {
+ $catIn = (isset($body['catalog']) && is_array($body['catalog'])) ? $body['catalog'] : null;
+ if ($catIn === null) json_response(['ok' => false, 'error' => 'ไม่มี catalog'], 400);
+ $clean = achv_sanitize_catalog($catIn);
+ if (!$clean) json_response(['ok' => false, 'error' => 'catalog ว่าง'], 400);
+ if (!achv_save_catalog($clean)) {
+ json_response(['ok' => false, 'error' => 'บันทึก achievements.json ไม่สำเร็จ (chown ให้ user เว็บ)'], 500);
+ }
+ json_response(['ok' => true, 'catalog' => $clean]);
+}
+
+if ($action === 'resetCatalog' && $method === 'POST') {
+ $def = achv_default_catalog();
+ if (!achv_save_catalog($def)) {
+ json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
+ }
+ json_response(['ok' => true, 'catalog' => $def]);
+}
+
+if ($action === 'players' && $method === 'GET') {
+ $catalog = achv_load_catalog();
+ $byId = [];
+ foreach ($catalog as $c) $byId[$c['id']] = (int) $c['target'];
+ $store = read_store();
+ $rows = [];
+ foreach ($store['accounts'] as $a) {
+ if (($a['loginType'] ?? '') !== 'guest') continue;
+ $key = (string) ($a['providerUserId'] ?? '');
+ if ($key === '') continue;
+ $prog = achv_progress_of($a);
+ $unlocked = 0;
+ foreach ($prog as $id => $v) {
+ if (isset($byId[$id]) && $v >= $byId[$id]) $unlocked++;
+ }
+ $rows[] = [
+ 'playerKey' => $key,
+ 'displayName' => (string) ($a['displayName'] ?? ($a['lbName'] ?? 'Guest')),
+ 'coins' => max(0, (int) ($a['coins'] ?? 0)),
+ 'blocked' => !empty($a['blocked']),
+ 'unlocked' => $unlocked,
+ 'total' => count($byId),
+ 'updatedAt' => (string) ($a['updatedAt'] ?? ''),
+ ];
+ }
+ json_response(['ok' => true, 'players' => $rows, 'total' => count($byId)]);
+}
+
+if ($action === 'player' && $method === 'GET') {
+ $key = trim((string) ($_GET['playerKey'] ?? ''));
+ if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
+ $store = read_store();
+ foreach ($store['accounts'] as $a) {
+ if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
+ json_response(['ok' => true, 'playerKey' => $key, 'displayName' => (string) ($a['displayName'] ?? 'Guest'), 'progress' => (object) achv_progress_of($a)]);
+ }
+ }
+ json_response(['ok' => true, 'playerKey' => $key, 'displayName' => 'Guest', 'progress' => (object) []]);
+}
+
+if ($action === 'setProgress' && $method === 'POST') {
+ $key = trim((string) ($body['playerKey'] ?? ''));
+ $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
+ if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
+ $catalog = achv_load_catalog();
+ $target = 0;
+ foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
+ if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
+ $value = max(0, min($target, (int) ($body['value'] ?? 0)));
+ $store = read_store();
+ $i = achv_find_or_create($store, $key);
+ $cur = achv_progress_of($store['accounts'][$i]);
+ $cur[$id] = $value;
+ $store['accounts'][$i]['achievements'] = (object) $cur;
+ $store['accounts'][$i]['updatedAt'] = gmdate('c');
+ if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
+ json_response(['ok' => true, 'id' => $id, 'value' => $value, 'unlocked' => $value >= $target]);
+}
+
+if ($action === 'unlock' && $method === 'POST') {
+ $key = trim((string) ($body['playerKey'] ?? ''));
+ $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
+ if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
+ $catalog = achv_load_catalog();
+ $target = 0;
+ foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
+ if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
+ $store = read_store();
+ $i = achv_find_or_create($store, $key);
+ $cur = achv_progress_of($store['accounts'][$i]);
+ $cur[$id] = $target;
+ $store['accounts'][$i]['achievements'] = (object) $cur;
+ $store['accounts'][$i]['updatedAt'] = gmdate('c');
+ if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
+ json_response(['ok' => true, 'id' => $id, 'value' => $target, 'unlocked' => true]);
+}
+
+if ($action === 'resetPlayer' && $method === 'POST') {
+ $key = trim((string) ($body['playerKey'] ?? ''));
+ if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
+ $store = read_store();
+ foreach ($store['accounts'] as $idx => $a) {
+ if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
+ $store['accounts'][$idx]['achievements'] = new \stdClass();
+ $store['accounts'][$idx]['updatedAt'] = gmdate('c');
+ if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
+ json_response(['ok' => true]);
+ }
+ }
+ json_response(['ok' => true]);
+}
+
+json_response(['ok' => false, 'error' => 'unknown action'], 400);
diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json
index bcea981..3abf74c 100644
--- a/www/html/Admin/private/store.json
+++ b/www/html/Admin/private/store.json
@@ -53,7 +53,7 @@
"blocked": false,
"coins": 3156,
"createdAt": "2026-04-02T05:52:21+00:00",
- "updatedAt": "2026-07-05T15:34:25+00:00",
+ "updatedAt": "2026-07-06T03:41:08+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -95,10 +95,16 @@
"e3_master_of_doubt": 1,
"e5_slippery_eel": 1,
"b4_data_miner": 1,
- "b1_evidence_collector": 1
+ "b1_evidence_collector": 1,
+ "e1_the_observer": 1,
+ "d4_flawless_diver": 0
},
"lobbyColorThemeIndex": 7,
- "lobbySkinToneIndex": 1
+ "lobbySkinToneIndex": 1,
+ "achvStreak": {
+ "e1_the_observer": 1,
+ "d4_flawless_diver": 0
+ }
},
{
"id": "1d64c56fadb64a93eae68a1d",
diff --git a/www/html/Game/server.js b/www/html/Game/server.js
index f215385..7937a2e 100644
--- a/www/html/Game/server.js
+++ b/www/html/Game/server.js
@@ -2064,6 +2064,25 @@ function submitAchievementProgress(items) {
});
}
+/* streak achievements (a6/d4/e1) — POST action=streak {playerKey,id,event:'hit'|'miss'}
+ PHP นับต่อ/รีเซ็ตเอง + set achievement = best streak (ไม่ลดลง) */
+function submitAchievementStreak(items) {
+ const secret = gameAwardSecret();
+ if (!secret || !Array.isArray(items) || !items.length) return;
+ items.forEach((it) => {
+ if (!it || !it.playerKey || !it.id || (it.event !== 'hit' && it.event !== 'miss')) return;
+ try {
+ fetch(ACHIEVEMENTS_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'streak', secret, playerKey: it.playerKey, id: it.id, event: it.event }),
+ }).then((r) => r.json()).then((d) => {
+ if (!d || !d.ok) console.error('[achv] streak failed', it.id, d);
+ }).catch((e) => console.error('[achv] streak error', e && e.message));
+ } catch (e) { console.error('[achv] streak throw', e && e.message); }
+ });
+}
+
/* แจกเหรียญตามอันดับคะแนน (เฉพาะผู้รอดชีวิต ผู้ตาย = 0) + คะแนนสะสมเข้า leaderboard — ครั้งเดียวต่อรอบ */
function awardMinigameRankCoins(sid, space) {
if (!space || space.coinAwardDoneForRun) return;
@@ -3728,6 +3747,8 @@ function assignDetectiveSuspectCardMinigames(space) {
// ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ (รวม evidenceByNick fallback — กันหลักฐานคดีเก่า leak ข้ามคดี)
space.playerEvidence = {};
space.evidenceByNick = {};
+ space.caseMinigameSuspects = {}; /* [achv b2] มินิเกมที่รันในคดีนี้ (suspectIndex) — เช็ค "เล่นครบทุกรอบ" */
+ space.caseCardTargetedKeys = {}; /* [achv d2] playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
/* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
space.suspectTeamProgress = [0, 0, 0];
@@ -3843,6 +3864,11 @@ function trackEvidenceCollectAchv(space, playerId, card) {
function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
const out = {};
+ /* [achv b2] บันทึกว่า suspect นี้มีมินิเกมรันในคดีนี้ (ใช้เช็ค "เล่นมินิเกมครบทุกรอบ") */
+ if (suspectIndex >= 0 && suspectIndex <= 2) {
+ space.caseMinigameSuspects = space.caseMinigameSuspects || {};
+ space.caseMinigameSuspects[suspectIndex] = true;
+ }
const grantedKeys = new Set(); /* แจก 1 การ์ด/playerKey ต่อเกม — กัน "หลายแท็บ/ชื่อซ้ำ" key เดียวได้เกิน */
(playerIds || []).forEach((pid) => {
if (!space.peers.has(pid)) return;
@@ -4283,6 +4309,8 @@ function computeAndEmitTrialResult(sid, space) {
const evTotal = evRows.reduce((s, row) => s + (Array.isArray(row) ? row.length : 0), 0);
/* c2 High Stakes: โหวตถูกโดยมีหลักฐาน ≤3 ใบ */
if (evTotal <= 3) achv.push({ playerKey: p.playerKey, id: 'c2_high_stakes', inc: 1 });
+ /* c1 Early Accusation: โหวตถูกโดยมีหลักฐาน ≤1 ใบ (ชี้ตัวก่อนเปิดหลักฐานครบ) */
+ if (evTotal <= 1) achv.push({ playerKey: p.playerKey, id: 'c1_early_accusation', inc: 1 });
/* a4 Logic Over Luck: โหวตถูกโดยไม่มีหลักฐาน legendary (ชี้ชัด) เลย */
const hasLegendary = evRows.some((row) => Array.isArray(row) && row.some((c) => c && c.rarity === 'legendary'));
if (!hasLegendary) achv.push({ playerKey: p.playerKey, id: 'a4_logic_over_luck', inc: 1 });
@@ -4307,7 +4335,47 @@ function computeAndEmitTrialResult(sid, space) {
achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */
}
}
+ /* ===== Phase C/D (2026-07-06) — ต่อผู้เล่นที่จบคดี (ไม่ใช่แค่ผู้โหวตถูก) ===== */
+ const streakItems = [];
+ /* c3 Quick Draw: ผู้โหวตถูกที่ "โหวตเร็วที่สุด" ในทีม (ใช้ trialVoteAt) */
+ {
+ let fastId = null, fastAt = Infinity;
+ winners.forEach((id) => {
+ const at = space.trialVoteAt && space.trialVoteAt[id];
+ const wp = space.peers.get(id);
+ if (at && wp && wp.playerKey && at < fastAt) { fastAt = at; fastId = id; }
+ });
+ if (fastId) { const fp = space.peers.get(fastId); if (fp && fp.playerKey) achv.push({ playerKey: fp.playerKey, id: 'c3_quick_draw', inc: 1 }); }
+ }
+ /* วนผู้เล่นจริงทุกคนที่จบคดี (ข้ามบอท/ผู้ถูกแบน spectator) */
+ const caseMgSuspects = Object.keys(space.caseMinigameSuspects || {}).map((n) => parseInt(n, 10));
+ const targetedKeys = space.caseCardTargetedKeys || {};
+ space.peers.forEach((p, pid) => {
+ if (!p || !p.playerKey || p.bannedSpectator) return;
+ const evRows = clonePlayerEvidence(space, pid);
+ const rarSet = {};
+ evRows.forEach((row) => { if (Array.isArray(row)) row.forEach((c) => { if (c && c.rarity) rarSet[c.rarity] = true; }); });
+ /* a3 Mind Architect: มีหลักฐานครบ 3 ระดับในคดีนี้ (common+rare+legendary) */
+ if (rarSet.common && rarSet.rare && rarSet.legendary) achv.push({ playerKey: p.playerKey, id: 'a3_mind_architect', inc: 1 });
+ /* b2 Relentless Investigator: เก็บหลักฐานครบทุก suspect ที่มีมินิเกมรันในคดีนี้ (= เล่นครบทุกรอบ) */
+ if (caseMgSuspects.length > 0 && caseMgSuspects.every((si) => Array.isArray(evRows[si]) && evRows[si].length > 0)) {
+ achv.push({ playerKey: p.playerKey, id: 'b2_relentless_investigator', inc: 1 });
+ }
+ /* d2 Silent Guardian: ไม่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ if (!targetedKeys[p.playerKey]) achv.push({ playerKey: p.playerKey, id: 'd2_silent_guardian', inc: 1 });
+ /* e1 The Observer (streak): จบคดีนี้โดยไม่แตะ legendary = hit, แตะ = รีเซ็ต */
+ streakItems.push({ playerKey: p.playerKey, id: 'e1_the_observer', event: rarSet.legendary ? 'miss' : 'hit' });
+ });
+ /* a6 Unbreakable Logic (โหวตถูกติดกัน) + d4 Flawless Diver (ไม่โหวตผิดติดกัน) — เฉพาะคนที่โหวตจริง */
+ Object.keys(votes).forEach((id) => {
+ const p = space.peers.get(id);
+ if (!p || !p.playerKey) return;
+ const ok = votes[id] === culprit;
+ streakItems.push({ playerKey: p.playerKey, id: 'a6_unbreakable_logic', event: ok ? 'hit' : 'miss' });
+ streakItems.push({ playerKey: p.playerKey, id: 'd4_flawless_diver', event: ok ? 'hit' : 'miss' });
+ });
submitAchievementProgress(achv);
+ submitAchievementStreak(streakItems);
} catch (e) { console.error('[achv] trial-result', e && e.message); }
space.silencedPlayerId = null;
@@ -4576,6 +4644,14 @@ function resolvePlayerPickVote(sid, space) {
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 });
+ /* [achv d2] บันทึก playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ (→ ไม่ได้ d2_silent_guardian) */
+ if (target && (pv.purpose === 'silence' || pv.purpose === 'ban')) {
+ const tp = space.peers.get(target);
+ if (tp && tp.playerKey) {
+ space.caseCardTargetedKeys = space.caseCardTargetedKeys || {};
+ space.caseCardTargetedKeys[tp.playerKey] = true;
+ }
+ }
const cb = pv.onResolve;
space.pickVote = null;
if (typeof cb === 'function') cb(target, targetName);
@@ -11149,6 +11225,11 @@ io.on('connection', (socket) => {
const sq = space.specialQuiz;
if (!sq || sq.triggered || sq.done) return reply({ ok: false });
const started = startSpecialQuizSession(sid, space);
+ /* [achv b5] คนที่แตะไอคอน ตำรวจ/ทนาย = คนเก็บไอเทมช่วยเหลือ (trigger ครั้งเดียว/ไอคอน) */
+ if (started) {
+ const bp = space.peers.get(socket.id);
+ if (bp && bp.playerKey) submitAchievementProgress([{ playerKey: bp.playerKey, id: 'b5_deep_scanner', inc: 1 }]);
+ }
reply({ ok: !!started });
});
@@ -11804,7 +11885,15 @@ io.on('connection', (socket) => {
return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' });
}
if (!space.testimonySubmitted) space.testimonySubmitted = {};
+ const d3AlreadySubmitted = space.testimonySubmitted[socket.id] !== undefined;
space.testimonySubmitted[socket.id] = picks.slice(0, required);
+ /* [achv d3] นับหลักฐานที่ "ส่งมอบให้ทีมวิเคราะห์" บนหน้า es3 (ห้องสรุปหลักฐาน) — นับครั้งแรกของรอบนี้เท่านั้น กันนับซ้ำ */
+ if (!d3AlreadySubmitted) {
+ const d3p = space.peers.get(socket.id);
+ if (d3p && d3p.playerKey && picks.length > 0) {
+ submitAchievementProgress([{ playerKey: d3p.playerKey, id: 'd3_the_backbone', inc: picks.length }]);
+ }
+ }
emitTestimonyStatus(sid, space);
/* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design
(UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */
diff --git a/www/html/Game/server.js.bak-20260706-014242 b/www/html/Game/server.js.bak-20260706-014242
new file mode 100644
index 0000000..f215385
--- /dev/null
+++ b/www/html/Game/server.js.bak-20260706-014242
@@ -0,0 +1,13190 @@
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { Server } = require('socket.io');
+
+const PORT = parseInt(process.env.PORT, 10) || 3001;
+/** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */
+const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0';
+const BASE_PATH = '/Game';
+const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
+const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
+const adminTokens = new Set();
+/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
+const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
+/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */
+const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
+/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */
+/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */
+const DETECTIVE_MINIGAME_POOL = [
+ { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' },
+ { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' },
+ { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' },
+ { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' },
+ { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' },
+ { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' },
+ { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' },
+];
+/** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */
+function normalizeForcedMinigameKeys(v) {
+ let arr;
+ if (Array.isArray(v)) arr = v;
+ else if (v != null && String(v).trim()) arr = [v, v, v];
+ else arr = [];
+ const one = (k) => {
+ const s = (k == null ? '' : String(k)).trim();
+ return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : '';
+ };
+ return [one(arr[0]), one(arr[1]), one(arr[2])];
+}
+const LOBBY_SLOT_TOTAL = 6;
+
+/* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้
+ ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */
+const LIVE_COUNTDOWN_MS = 3500;
+const LOBBY_THEME_COUNT = 8;
+const LOBBY_SKIN_COUNT = 3;
+
+/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
+function getTakenLobbyThemeIndices(space, excludePeerId) {
+ const taken = new Set();
+ if (!space) return taken;
+ space.peers.forEach((p, id) => {
+ if (excludePeerId && id === excludePeerId) return;
+ const ti = parseInt(p.lobbyColorThemeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ const bots = space.lobbyBotThemeBySlot;
+ if (bots && typeof bots === 'object') {
+ Object.keys(bots).forEach((k) => {
+ const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ }
+ return taken;
+}
+
+function pickFreeLobbyThemeIndex(space, excludePeerId) {
+ const taken = getTakenLobbyThemeIndices(space, excludePeerId);
+ for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
+ if (!taken.has(i)) return i;
+ }
+ const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
+ return ((n - 1) % LOBBY_THEME_COUNT) + 1;
+}
+
+function pickLobbySkinIndexForSlot(slotIndex) {
+ const s = Number(slotIndex);
+ if (!Number.isFinite(s)) return 1;
+ return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
+}
+
+/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น
+ 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;
+ /* เลือกผู้เล่น ≥ 4 คนตอนสร้างห้อง → ไม่เติมบอท (รอคนจริง) — เว้นแต่ host ตั้งบอทเองภายหลัง (n>0) */
+ if (n === 0 && maxP >= 4) return 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');
+const SOUND_SETTINGS_PATH = path.join(__dirname, 'data', 'sound-settings.json');
+const SOUND_SETTINGS_DEFAULT = { masterVol: 1.0, musicVol: 0.35, sfxVol: 0.75 };
+function loadSoundSettings() {
+ try {
+ if (fs.existsSync(SOUND_SETTINGS_PATH)) {
+ const j = JSON.parse(fs.readFileSync(SOUND_SETTINGS_PATH, 'utf8'));
+ const clamp = (v, d) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : d; };
+ return {
+ masterVol: clamp(j.masterVol, SOUND_SETTINGS_DEFAULT.masterVol),
+ musicVol: clamp(j.musicVol, SOUND_SETTINGS_DEFAULT.musicVol),
+ sfxVol: clamp(j.sfxVol, SOUND_SETTINGS_DEFAULT.sfxVol),
+ };
+ }
+ } catch (e) { console.error('loadSoundSettings', e.message); }
+ return Object.assign({}, SOUND_SETTINGS_DEFAULT);
+}
+function saveSoundSettings(s) {
+ try { fs.writeFileSync(SOUND_SETTINGS_PATH, JSON.stringify(s, null, 2)); } catch (e) { console.error('saveSoundSettings', e.message); }
+}
+
+/* ===== 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 นี้
+ ตั้ง 22 (เดิม 10) — จาก log 6 คน join ใช้เวลา 10-16 วิ (โหลดหนัก) → grace 10 หมดก่อน = bot takeover ผิด → "ตัวละครซ้ำ 2 ตัว" (บอท+คนที่กลับมาช้า) + คนหลุด */
+const DISCONNECT_GRACE_SEC = 22;
+
+/** โหมดเทสต์ (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) ต่อช่อง 1–16 */
+ carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
+ /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
+ carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
+ /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
+ carryChoicePlaqueMapScale: 1.25,
+ /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */
+ carryWalkSpeedMultForMapId: '',
+ carryWalkSpeedMult: null,
+ /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */
+ quizRoundQuestionCount: 10,
+ questions: [],
+ carryQuestions: [],
+ battleQuizMcq: [],
+ /** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */
+ specialQuizSets: sanitizeSpecialQuizSets({}),
+ };
+}
+
+function defaultCarryMapPanelTheme() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+}
+
+function sanitizeCssColorToken(input, fallback) {
+ const t = String(input == null ? '' : input).trim().slice(0, 120);
+ if (!t) return fallback;
+ if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) {
+ const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, '');
+ if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim();
+ }
+ return fallback;
+}
+
+function sanitizeCarryMapPanelTheme(raw) {
+ const d = defaultCarryMapPanelTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ let qMin = Number(raw.questionFontMinPx);
+ let qMax = Number(raw.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const t = qMin;
+ qMin = qMax;
+ qMax = t;
+ }
+ return {
+ panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg),
+ panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder),
+ borderWidthPx: (() => {
+ let w = Number(raw.borderWidthPx);
+ if (!Number.isFinite(w)) w = d.borderWidthPx;
+ return Math.max(0, Math.min(12, Math.round(w)));
+ })(),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+}
+
+/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
+function defaultCarryChoicePlaqueTheme() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ };
+}
+
+function sanitizeCarryChoicePlaqueTheme(raw) {
+ const d = defaultCarryChoicePlaqueTheme();
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
+ fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
+ };
+}
+
+const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
+
+function defaultCarryChoicePlaqueThemes() {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme({}));
+ }
+ return out;
+}
+
+/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
+function sanitizeCarryChoicePlaqueThemes(raw) {
+ if (Array.isArray(raw) && raw.length > 0) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
+ }
+ return out;
+ }
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
+ const one = sanitizeCarryChoicePlaqueTheme(raw);
+ return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
+ }
+ return defaultCarryChoicePlaqueThemes();
+}
+
+function sanitizeCarryChoiceImageUrl(input) {
+ const s = String(input == null ? '' : input).trim().slice(0, 512);
+ if (!s) return '';
+ if (/[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const u = new URL(s);
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+}
+
+function defaultCarryEmbedCountdownTheme() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+}
+
+function sanitizeCarryEmbedCountdownTheme(raw) {
+ const d = defaultCarryEmbedCountdownTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ return {
+ overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
+ innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+}
+
+/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
+const QUIZ_BATTLE_CATEGORY_IDS = new Set([
+ 'topic1', 'topic2', 'topic3', 'topic4', 'topic5',
+ 'topic6', 'topic7', 'topic8', 'topic9', 'topic10',
+]);
+
+/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
+function sanitizeBattleQuizMcq(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ let categoryId = String(q.categoryId || '').trim();
+ if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
+ categoryId = 'topic1';
+ }
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ const rawChoices = Array.isArray(q.choices) ? q.choices : [];
+ const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
+ if (choices.length !== 3 || !choices.every(Boolean)) continue;
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ out.push({ categoryId, text, choices, correctIndex: ci });
+ if (out.length >= 200) break;
+ }
+ return out;
+}
+
+/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
+function sanitizeCarryQuestions(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ let choices = Array.isArray(q.choices)
+ ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
+ : [];
+ if (choices.length < 2) continue;
+ if (choices.length > 16) choices = choices.slice(0, 16);
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
+ const row = { text, choices, correctIndex: ci };
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) row.choiceImageUrls = urls;
+ }
+ out.push(row);
+ if (out.length >= 80) break;
+ }
+ return out;
+}
+
+function clampQuizMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(1000, Math.min(300000, Math.floor(v)));
+}
+
+function clampQuizBetweenMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(300000, Math.floor(v)));
+}
+
+function clampCarrySessionLength(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+}
+
+/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
+function clampQuizRoundQuestionCount(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) {
+ const d = Number(def);
+ if (!Number.isFinite(d)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(d)));
+ }
+ return Math.max(1, Math.min(50, Math.floor(v)));
+}
+
+function clampCarryChoicePlaqueMapScale(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
+}
+
+/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
+function sanitizeCarryWalkSpeedMultForMapId(raw) {
+ const t = String(raw == null ? '' : raw).trim().slice(0, 64);
+ if (!t) return '';
+ if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
+ return t;
+}
+
+/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
+function clampCarryWalkSpeedMultSetting(n) {
+ if (n === null || n === undefined || n === '') return null;
+ const v = Number(n);
+ if (Number.isNaN(v)) return null;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+}
+
+function mergeQuizCarryFromMirrorIfSet(base) {
+ if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
+ try {
+ if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
+ const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
+ const j = JSON.parse(raw);
+ if (!j || typeof j !== 'object') return base;
+ if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
+ base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
+ }
+ if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
+ base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
+ }
+ if (j.carryReadMs != null) {
+ base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
+ }
+ if (j.carryAnswerMs != null) {
+ base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
+ }
+ if (j.carrySessionLength != null) {
+ base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
+ }
+ if (j.carryWalkSpeedMultForMapId != null) {
+ base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
+ }
+ if (j.carryWalkSpeedMult != null) {
+ const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
+ if (wm != null) base.carryWalkSpeedMult = wm;
+ }
+ if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
+ base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
+ }
+ if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ }
+ if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
+ base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
+ }
+ if (j.quizRoundQuestionCount != null) {
+ base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
+ }
+ return base;
+ } catch (e) {
+ console.error('mergeQuizCarryFromMirrorIfSet', e.message);
+ return base;
+ }
+}
+
+/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
+const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
+const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
+const EVIDENCE_CASE_COUNT = 15;
+
+/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
+const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
+const CASE_MEDIA_COUNT = 15;
+/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
+const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
+
+function caseMediaDefault(n) {
+ const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png');
+ const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png');
+ return {
+ name: 'คดีที่ ' + n,
+ art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
+ detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
+ story: [
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
+ ],
+ suspects,
+ culprits,
+ };
+}
+
+function sanitizeCaseMediaUrlList(arr, fallbackArr) {
+ const fb = Array.isArray(fallbackArr) ? fallbackArr : [];
+ const src = Array.isArray(arr) ? arr : [];
+ return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || ''));
+}
+
+/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
+function sanitizeCaseMediaUrl(v, fallback) {
+ if (typeof v !== 'string') return fallback;
+ const s = v.trim();
+ if (!s) return fallback;
+ if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
+ return s;
+}
+
+function sanitizeCaseMedia(obj) {
+ const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
+ const cases = {};
+ for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
+ const d = caseMediaDefault(n);
+ const c = src[n] || src[String(n)] || {};
+ const story = Array.isArray(c.story) ? c.story : [];
+ cases[n] = {
+ name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
+ art: sanitizeCaseMediaUrl(c.art, d.art),
+ detail: sanitizeCaseMediaUrl(c.detail, d.detail),
+ story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
+ suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
+ culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
+ };
+ }
+ return { cases };
+}
+
+function loadCaseMedia() {
+ try {
+ if (fs.existsSync(CASE_MEDIA_PATH)) {
+ return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
+ }
+ } catch (e) { /* ignore — คืนค่า default */ }
+ return sanitizeCaseMedia({});
+}
+
+function saveCaseMedia(d) {
+ try {
+ const clean = sanitizeCaseMedia(d);
+ fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
+ return { ok: true, cases: clean.cases };
+ } catch (e) {
+ let err = 'บันทึกไม่ได้';
+ if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
+ else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
+ return { ok: false, error: err };
+ }
+}
+
+/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
+function listCaseMediaImages() {
+ const out = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] };
+ ['case', 'cutscene', 'suspect-pick', 'culprit-prison'].forEach((sub) => {
+ try {
+ const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
+ out[sub] = fs.readdirSync(p)
+ .filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ });
+ return out;
+}
+const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
+/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
+const EVIDENCE_RARITY_RANGES = [
+ { rarity: 'common', min: 1, max: 22, stars: 1 },
+ { rarity: 'rare', min: 23, max: 44, stars: 2 },
+ { rarity: 'legendary', min: 45, max: 54, stars: 3 },
+];
+
+function evidenceRarityForNum(n) {
+ for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
+ return EVIDENCE_RARITY_RANGES[0];
+}
+
+function evidenceCardImageDefault(suspectIdx, cardIdx) {
+ return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
+}
+
+/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
+let _evidencePoolCache = null;
+let _evidencePoolCacheAt = 0;
+function evidenceImagePools() {
+ const now = Date.now();
+ if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
+ const pools = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const buckets = { common: [], rare: [], legendary: [] };
+ try {
+ const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
+ fs.readdirSync(p).forEach((f) => {
+ const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
+ if (!m) return;
+ const num = parseInt(m[1], 10);
+ const rr = evidenceRarityForNum(num);
+ buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
+ });
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
+ pools[dir] = buckets;
+ });
+ _evidencePoolCache = pools;
+ _evidencePoolCacheAt = now;
+ return pools;
+}
+
+function evidencePoolCounts() {
+ const pools = evidenceImagePools();
+ const out = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const b = pools[dir] || { common: [], rare: [], legendary: [] };
+ out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
+ });
+ return out;
+}
+
+/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
+function rollEvidenceRarityKey(grade) {
+ const r = Math.random();
+ if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
+ if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
+ return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
+}
+
+/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
+function evidenceImageDirForSuspect(space, suspectIndex) {
+ const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
+ try {
+ const cid = String((space && space.caseId) || '1');
+ const cfg = loadEvidenceCards();
+ const cse = cfg.cases && cfg.cases[cid];
+ const s = cse && cse.suspects && cse.suspects[suspectIndex];
+ if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
+ } catch (e) { /* ใช้ default */ }
+ return def;
+}
+
+/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
+function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
+ const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
+ const pools = evidenceImagePools();
+ const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
+ let want = rollEvidenceRarityKey(grade || 'C');
+ let pool = buckets[want];
+ if (!pool || !pool.length) {
+ const tryOrder = ['common', 'rare', 'legendary'];
+ for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
+ }
+ if (!pool || !pool.length) return null;
+ const pick = pool[Math.floor(Math.random() * pool.length)];
+ return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
+}
+
+function evidenceGradeFromPct(avgPct) {
+ return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
+}
+
+/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
+function evidencePlayerRawScore(space, p, gameType) {
+ switch (gameType) {
+ case 'gauntlet':
+ case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
+ case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
+ case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
+ default:
+ return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
+ }
+}
+
+/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
+function computeRunGradeForEvidence(space) {
+ try {
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gameType = md && md.gameType;
+ /* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C
+ quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */
+ if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
+ const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
+ const ids = Object.keys(space.quizSession.players);
+ if (total > 0 && ids.length) {
+ const avgPct = ids.reduce((s, id) => {
+ const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
+ return s + Math.min(100, (sc / total) * 100);
+ }, 0) / ids.length;
+ return evidenceGradeFromPct(avgPct);
+ }
+ }
+ /* 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_JUMP_HEIGHT_MULT = 0.52; /* MG2 ความสูงกระโดด = ×tile (จุดสูงสุดของส่วนโค้ง sin) — ปรับได้ใน Admin */
+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,
+ gauntletJumpHeightMult: GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT,
+ gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
+ troublesomeOfferSec: 15,
+ ...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,
+ troublesomeRollMaxSec: 8,
+ /** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */
+ specialQuizIconExpireSec: 10,
+ /** เวลาโหวตเลือกผู้เล่น (การ์ด Silence/Ban) วินาที */
+ pickVoteSec: 25,
+ /** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */
+ trialVoteSec: 30,
+ /** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
+ trialResultRevealMs: 7000,
+ };
+}
+
+/** อ่านเวลา 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; ค่าอื่น = 10–7200 */
+function clampJumpSurviveMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */
+function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = [];
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : '';
+ const out = [];
+ for (let i = 0; i < 3; i++) {
+ const o = arr[i];
+ const obj = o && typeof o === 'object' ? o : {};
+ let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : '');
+ if (i === 0 && !url && legacy) url = legacy;
+ let ww = Number(obj.w);
+ let hh = Number(obj.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ out.push({ url, w: ww, h: hh });
+ }
+ return out;
+}
+
+function clampSpaceShooterMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */
+function normalizeSpaceShooterShipImageUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < 6; i++) {
+ out.push(sanitizeGauntletAssetUrl(arr[i]));
+ }
+ return out;
+}
+
+/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */
+function normalizeSpaceShooterAsteroidSpriteUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < arr.length && out.length < 32; i++) {
+ const u = sanitizeGauntletAssetUrl(arr[i]);
+ if (u) out.push(u);
+ }
+ return out;
+}
+
+function clampSpaceShooterAsteroidExplodeFrameMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return 70;
+ return Math.max(30, Math.min(500, Math.round(v)));
+}
+
+const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040;
+function clampSpaceShooterAsteroidIntervalMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
+ return Math.max(200, Math.min(10000, Math.floor(v)));
+}
+
+/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */
+function normalizeSpaceShooterShipDamageOverlayUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = ['', '', ''];
+ for (let i = 0; i < 3; i++) {
+ out[i] = sanitizeGauntletAssetUrl(arr[i]);
+ }
+ return out;
+}
+
+function clampGauntletTickMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
+ return Math.max(80, Math.min(800, Math.floor(v)));
+}
+
+function clampGauntletJumpTicks(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
+ return Math.max(1, Math.min(40, Math.floor(v)));
+}
+function clampGauntletJumpHeightMult(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT;
+ return Math.max(0.2, Math.min(1.5, v));
+}
+/** เวลารับข้อเสนอ "ตัวป่วน" (วินาที) — admin ปรับได้ใน game-timing; 5–120, default 15 */
+function clampTroublesomeOfferSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 15;
+ return Math.max(5, Math.min(120, Math.floor(v)));
+}
+/* เวลาสูงสุด (วินาที) ที่ระบบรอให้ทุกคนรายงานพร้อม ก่อน "บังคับสุ่ม" ผู้รับบทตัวป่วน (เดิม hardcode 8000ms) */
+function clampTroublesomeRollMaxSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 8;
+ return Math.max(1, Math.min(30, 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(j.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(j, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(j.troublesomeOfferSec) : 15,
+ ...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,
+ troublesomeRollMaxSec: clampTroublesomeRollMaxSec(j.troublesomeRollMaxSec),
+ 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,
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
+ : 7000,
+ };
+ }
+ } 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(d.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(d.troublesomeOfferSec) : ((runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) || 15),
+ ...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,
+ troublesomeRollMaxSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeRollMaxSec')
+ ? clampTroublesomeRollMaxSec(d.troublesomeRollMaxSec)
+ : clampTroublesomeRollMaxSec(prev.troublesomeRollMaxSec),
+ 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),
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
+ : (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
+ /* 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,
+ nr: p.netReady !== false, /* net-ready: false = เพิ่ง join กำลังโหลด (client โชว์ badge) */
+ };
+ 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) {
+ const payload = {
+ readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady),
+ expectedActive: (space && space.minigameExpectedActiveCount) || 0,
+ peers: buildPeersSnapForSpace(space),
+ };
+ /* MG2: ส่งตำแหน่ง spawn ของบอทมาด้วยตอน pregame → client วางบอทที่จุดเกิด (เดิมไม่ส่ง = บอทค้าง (1,1) มุมบน) */
+ const gr = space && space.gauntletRun;
+ if (gr && gr.bots && gr.bots.size) {
+ const bots = [];
+ gr.bots.forEach((b, id) => {
+ if (!b) return;
+ bots.push({ id, x: b.x, y: b.y, direction: b.dir || 'right', gauntletScore: b.gauntletScore || 0, gauntletEliminated: !!b.gauntletEliminated });
+ });
+ if (bots.length) payload.bots = bots;
+ }
+ return payload;
+}
+
+/* เช็คว่าพร้อมเริ่มมินิเกม 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;
+}
+
+/* key ผู้เล่นในคดี — ใช้ playerKey (คงที่ต่อ browser ข้าม reconnect) กันเคส "2 คนตั้งชื่อซ้ำ = ถูกนับเป็นคนเดียว"
+ fallback เป็นชื่อ (n:) ถ้า peer ไม่มี playerKey ที่ valid */
+function detectiveParticipantKey(p) {
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ const n = normalizeLobbyNickname(p.nickname || '');
+ return n ? 'n:' + n : '';
+}
+
+/* นับผู้เล่นในคดี (caseParticipantKeys = playerKey) ที่กลับเข้า LobbyB + โหลดเสร็จ เทียบกับทั้งหมด
+ ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */
+function detectiveLobbyBMissingParticipants(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 };
+ /* present = participantKey ที่ "มี peer" และ "โหลด LobbyB เสร็จจริง" (lobbyb-loaded)
+ — กันเคส peer มีอยู่ (socket re-join ช่วงเปลี่ยนหน้า) แต่ client ยังค้างฉากเกมเก่า = นับครบผิด
+ fallback: ก่อนมีใครส่ง lobbyb-loaded (loadedSet ว่าง) ใช้ peer-count (กัน deadlock ช่วงแรกสุด) */
+ const loadedSet = space.lobbyBLoadedKeys;
+ const useLoaded = !!(loadedSet && loadedSet.size > 0);
+ const present = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (!k || !roster.has(k)) return;
+ if (useLoaded) { if (loadedSet.has(k)) present.add(k); }
+ else present.add(k);
+ });
+ 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,
+ });
+}
+
+/* TC166 safety: กัน lobbyb-loaded gate deadlock — ถ้า 25s แล้วยังไม่ครบ ให้ force นับ present จาก peer ที่อยู่จริงใน LobbyB */
+function armLobbyBLoadedSafety(sid, space) {
+ if (!sid || !space) return;
+ if (space.lobbyBLoadedTimer) { clearTimeout(space.lobbyBLoadedTimer); space.lobbyBLoadedTimer = null; }
+ space.lobbyBLoadedTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (!sp || !serverMapIsPostCaseLobbyB(sp)) return;
+ const roster = sp.caseParticipantKeys;
+ if (!roster || roster.size === 0) return;
+ if (!sp.lobbyBLoadedKeys) sp.lobbyBLoadedKeys = new Set();
+ let added = 0;
+ sp.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k) && !sp.lobbyBLoadedKeys.has(k)) { sp.lobbyBLoadedKeys.add(k); added++; }
+ });
+ if (added) {
+ try { glog(sid, sp, 'lobbyb-gate', '[TC166] safety 25s → force นับ present จาก peer ที่อยู่จริง (+' + added + ')'); } catch (e) { /* ignore */ }
+ try { emitDetectiveLobbyBPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 25000);
+}
+
+/* TC156 story-gate: นับผู้เล่นในคดี "ที่อ่าน story (cutscene) จบแล้ว" เทียบกับคนที่อยู่ในห้องตอนนี้
+ allRead = ทุก roster-nick ที่ยัง present ได้ส่ง story-read-done แล้ว (คนที่ left ไม่นับ — presence gate คุมแยก) */
+function detectiveStoryReadStatus(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { present: 0, total: 0, allRead: true };
+ const read = space.storyReadKeys || new Set();
+ const presentRosterKeys = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k)) presentRosterKeys.add(k);
+ });
+ let readCount = 0;
+ presentRosterKeys.forEach((k) => { if (read.has(k)) readCount++; });
+ const allRead = !!space.storyReadComplete || (presentRosterKeys.size > 0 && readCount >= presentRosterKeys.size);
+ return { present: readCount, total: presentRosterKeys.size, allRead };
+}
+function emitDetectiveStoryReadPresence(sid, space) {
+ if (!sid || !space) return;
+ if (!serverMapIsPostCaseLobbyB(space)) return;
+ const st = detectiveStoryReadStatus(space);
+ io.to(sid).emit('detective-story-read-presence', { present: st.present, total: st.total, allRead: st.allRead });
+}
+
+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) || '',
+ };
+ });
+ // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ (รวม evidenceByNick fallback — กันหลักฐานคดีเก่า leak ข้ามคดี)
+ space.playerEvidence = {};
+ space.evidenceByNick = {};
+ /* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
+ ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
+ space.suspectTeamProgress = [0, 0, 0];
+ // สุ่มคนร้ายตัวจริง 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 */
+/* คีย์เก็บหลักฐานต่อผู้เล่น — ใช้ playerKey (unique ต่อ browser) เป็นหลัก กัน "ชื่อซ้ำ"
+ (เช่น 2-3 คนชื่อ MONE) ใช้ evidence ก้อนเดียวกัน = ถูกแจกซ้ำ/เกิน. fallback ชื่อเฉพาะกรณีไม่มี key */
+function peerNickKey(space, playerId) {
+ if (!space || !playerId) return '';
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ if (!p.nickname) return '';
+ return 'n:' + 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;
+}
+
+/* [achv Phase A 2026-07-05] นับหลักฐานที่ผู้เล่นเก็บได้ → achievements
+ b4_data_miner(รวมทุกใบ ×100) · b1_evidence_collector(common ×20) · a2_sharp_eye(rare/Silver ×10) · b3_hidden_fragment(legendary ครั้งแรก)
+ เฉพาะผู้เล่นจริง (มี playerKey) — ข้ามบอท + เรียกจาก "จุดแจกจริง" 2 ที่เท่านั้น (ไม่รวม debug hotkey) */
+function trackEvidenceCollectAchv(space, playerId, card) {
+ if (!card || !card.rarity) return;
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ const pkey = p && typeof p.playerKey === 'string' ? p.playerKey : null;
+ if (!pkey) return;
+ const achv = [{ playerKey: pkey, id: 'b4_data_miner', inc: 1 }];
+ if (card.rarity === 'common') achv.push({ playerKey: pkey, id: 'b1_evidence_collector', inc: 1 });
+ else if (card.rarity === 'rare') achv.push({ playerKey: pkey, id: 'a2_sharp_eye', inc: 1 });
+ else if (card.rarity === 'legendary') achv.push({ playerKey: pkey, id: 'b3_hidden_fragment', inc: 1 });
+ submitAchievementProgress(achv);
+}
+
+function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
+ const out = {};
+ const grantedKeys = new Set(); /* แจก 1 การ์ด/playerKey ต่อเกม — กัน "หลายแท็บ/ชื่อซ้ำ" key เดียวได้เกิน */
+ (playerIds || []).forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && grantedKeys.has(k)) return; /* key นี้ได้การ์ดรอบนี้แล้ว (evidence เก็บก้อนเดียวกันตาม key) */
+ const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
+ if (card != null) { out[pid] = card; if (k) grantedKeys.add(k); trackEvidenceCollectAchv(space, 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++) {
+ const freeKeys = new Set(); /* 1 ใบ/playerKey ต่อรอบ (กันชื่อซ้ำ/หลายแท็บได้เกิน) */
+ ids.forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && freeKeys.has(k)) return;
+ const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
+ if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; if (k) freeKeys.add(k); trackEvidenceCollectAchv(space, pid, extra); }
+ });
+ }
+ pend.consumed = true;
+ }
+ space.detectiveAwardDoneForRun = true;
+ space.lastDetectiveInvestigatedSuspect = suspectIdx;
+ space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
+ /* [TC182] verify: จำนวนใบต่อ playerKey ที่แจกรอบนี้ — ควรได้ 1 ใบ/key/เกม (กันชื่อซ้ำแจกเกิน) */
+ try {
+ const keyCounts = {};
+ Object.keys(awardedList).forEach((pid) => {
+ const k = peerNickKey(space, pid) || String(pid);
+ keyCounts[k] = (keyCounts[k] || 0) + (awardedList[pid] ? awardedList[pid].length : 0);
+ });
+ const vals = Object.values(keyCounts);
+ const over = vals.some((n) => n > 1);
+ glog(sid, space, 'evidence-grant', '[TC182] แจกหลักฐาน suspect#' + (suspectIdx + 1) + ' → ' + vals.length + ' key · ใบ/key=' + JSON.stringify(vals) + (over ? ' ⚠️เกิน' : ' (1/key · pass)'));
+ } catch (e) { /* ignore */ }
+ 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.lobbyBLoadedKeys = new Set(); /* ออกไปเล่นมินิเกม → ล้าง loaded; ตอนกลับ LobbyB ทุกคนต้องส่ง lobbyb-loaded ใหม่ */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock ถ้า signal ไม่ครบ */
+ 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;
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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;
+ /* #6: นับความคืบหน้าการสืบ (shared) +1 ต่อการเล่นมินิเกม 1 รอบของผู้ต้องสงสัยนั้น (สูงสุด 3) */
+ if (!Array.isArray(space.suspectTeamProgress)) space.suspectTeamProgress = [0, 0, 0];
+ if (suspectIdx >= 0 && suspectIdx <= 2) {
+ space.suspectTeamProgress[suspectIdx] = Math.min(3, (space.suspectTeamProgress[suspectIdx] || 0) + 1);
+ try { glog(sid, space, 'suspect-progress', '[TC179] สืบผู้ต้องสงสัย #' + (suspectIdx + 1) + ' → team progress ' + space.suspectTeamProgress.join('/') + ' (shared เท่ากันทุกจอ · pass)'); } catch (e) { /* ignore */ }
+ }
+ 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 || [],
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ 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 = [];
+ /* Phase B: majority vote (เช็ค lone-wolf) + มีคนโหวตไหม */
+ let mvAchv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mvAchv]) mvAchv = k; }
+ const anyVotesAchv = (counts[0] + counts[1] + counts[2]) > 0;
+ /* 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 });
+ /* ===== Phase B (2026-07-05) — เงื่อนไขต่อผู้โหวตถูก ===== */
+ /* c5 Lone Wolf: โหวตถูกแต่เสียงส่วนใหญ่ผิด (สวนทางทีม) */
+ if (anyVotesAchv && mvAchv !== culprit) achv.push({ playerKey: p.playerKey, id: 'c5_lone_wolf', inc: 1 });
+ /* หลักฐานในมือคดีนี้ (ต่อผู้ต้องสงสัย 3 กอง) */
+ const evRows = clonePlayerEvidence(space, id);
+ const evTotal = evRows.reduce((s, row) => s + (Array.isArray(row) ? row.length : 0), 0);
+ /* c2 High Stakes: โหวตถูกโดยมีหลักฐาน ≤3 ใบ */
+ if (evTotal <= 3) achv.push({ playerKey: p.playerKey, id: 'c2_high_stakes', inc: 1 });
+ /* a4 Logic Over Luck: โหวตถูกโดยไม่มีหลักฐาน legendary (ชี้ชัด) เลย */
+ const hasLegendary = evRows.some((row) => Array.isArray(row) && row.some((c) => c && c.rarity === 'legendary'));
+ if (!hasLegendary) achv.push({ playerKey: p.playerKey, id: 'a4_logic_over_luck', inc: 1 });
+ /* c4 Clutch Mind: โหวตถูกใน 10 วิสุดท้ายก่อนหมดเวลา */
+ const voteAt = space.trialVoteAt && space.trialVoteAt[id];
+ if (voteAt && space.trialVoteEndsAt) {
+ const remain = space.trialVoteEndsAt - voteAt;
+ if (remain >= 0 && remain <= 10000) achv.push({ playerKey: p.playerKey, id: 'c4_clutch_mind', 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.trialVoteAt = {}; /* [achv c4] รีเซ็ตเวลาโหวตทุกครั้งที่เปิดโหวต (รวม revote) */
+ 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 = [];
+ const tc191Rows = []; /* [TC191] verify: เทียบผลตัดสิน "แถวเท้า" vs "หัว(เดิม)" ต่อคน */
+
+ space.peers.forEach((p, peerId) => {
+ const st = sess.players[peerId];
+ if (!st) return;
+ eligibleCount++;
+ /* จับที่ "แถวเท้า" (bottom row ของ footprint) เท่านั้น — หัว/ตัวโผล่เข้าโซนแต่เท้ายังไม่ถึง = ไม่นับ
+ (ต้องตรงกับ client quizResolveChoiceFromStanding) */
+ const { cw: qCw, ch: qCh } = getCharacterFootprintWHForMove(mdLive);
+ const qBaseTx = Math.floor(Number(p.x));
+ const qFeetTy = Math.floor(Number(p.y)) + qCh - 1;
+ let inT = false, inF = false;
+ for (let qdx = 0; qdx < qCw; qdx++) {
+ const qftx = qBaseTx + qdx;
+ if (!inT && quizCellOn(tGrid, qftx, qFeetTy)) inT = true;
+ if (!inF && quizCellOn(fGrid, qftx, qFeetTy)) inF = true;
+ }
+ 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,
+ });
+ }
+
+ /* [TC191] เก็บบรรทัดเทียบ: เท้า(ที่ใช้จริง) vs หัว(สูตรเดิม single-tile) → พิสูจน์ว่าตัดสินที่แถวเท้า */
+ {
+ const headY = Math.floor(Number(p.y));
+ tc191Rows.push((p.nickname || '?').slice(0, 8) + ' fY=' + qFeetTy + ' hY=' + headY
+ + ' เท้า[T' + (inT ? 1 : 0) + 'F' + (inF ? 1 : 0) + ']'
+ + ' หัวเดิม[T' + (quizCellOn(tGrid, qBaseTx, headY) ? 1 : 0) + 'F' + (quizCellOn(fGrid, qBaseTx, headY) ? 1 : 0) + ']'
+ + ' → ' + (choice === true ? 'จริง' : choice === false ? 'เท็จ' : '-') + (right ? '✓' : '✗'));
+ }
+ results.push({
+ id: peerId,
+ nickname: p.nickname || '',
+ right,
+ choice,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : 0,
+ });
+ });
+
+ if (tc191Rows.length) {
+ try { glog(sid, space, 'mg1-feet', '[TC191] Q' + (idx + 1) + ' ตัดสินแถวเท้า: ' + tc191Rows.join(' | ')); } catch (e) { /* ignore */ }
+ }
+
+ 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_` 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;
+ /* โต๊ะ submit กลาง (interactive/เขียว) = สิ่งกีดขวาง ห้ามยืนทับ/เดินทะลุ (ส่งคำตอบจากช่องข้างๆ ได้ — quizCarryCanSubmitAtServer รับ adjacent) */
+ if (quizCellOn(md.interactive || [], 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;
+}
+
+/* ช่องที่บอท "เดินถึงได้จริง" (footprint-aware BFS) จากโซน option/spawn — กันบอทสุ่มเกิด/ติดใน
+ "คอนโซลกลาง" ที่ยืนได้แต่ออกไม่ได้ (footprint 3×4 ลอดช่องแคบ/ทางที่ติดกำแพง+hub+interactive ไม่ได้
+ = อาการ "บอทยืนบนโต๊ะ"). cache ต่อ md (โครงสร้างแมปคงที่ต่อรอบเกม) */
+function quizCarryReachableTilesServer(md) {
+ if (md.__qcReach) return md.__qcReach;
+ const w = md.width || 20, h = md.height || 15;
+ const oa = md.quizCarryOptionArea, sa = md.spawnArea;
+ const seeds = [];
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const isSeed = (Array.isArray(oa) && oa[y] && Number(oa[y][x]) >= 1) || (Array.isArray(sa) && quizCellOn(sa, x, y));
+ if (isSeed && quizCarryEntityCanStandServer(md, x, y)) seeds.push([x, y]);
+ }
+ }
+ const reach = new Set();
+ if (!seeds.length) {
+ /* ไม่มี seed (แมปไม่มี option/spawn) → ไม่กรอง: ทุก standable นับว่าถึงได้ */
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (quizCarryEntityCanStandServer(md, x, y)) reach.add(x + ',' + y);
+ md.__qcReach = reach;
+ return reach;
+ }
+ const q = [];
+ for (const s of seeds) { const k = s[0] + ',' + s[1]; if (!reach.has(k)) { reach.add(k); q.push(s); } }
+ while (q.length) {
+ const cur = q.shift();
+ const nb = [[cur[0] + 1, cur[1]], [cur[0] - 1, cur[1]], [cur[0], cur[1] + 1], [cur[0], cur[1] - 1]];
+ for (const n of nb) {
+ const k = n[0] + ',' + n[1];
+ if (!reach.has(k) && quizCarryEntityCanStandServer(md, n[0], n[1])) { reach.add(k); q.push(n); }
+ }
+ }
+ md.__qcReach = reach;
+ return reach;
+}
+function quizCarryTileReachableServer(md, x, y) {
+ return quizCarryReachableTilesServer(md).has(Math.floor(x) + ',' + Math.floor(y));
+}
+
+/** รวมรายชื่อช่องว่าง (เดินยืนได้ + ถึงได้จริง + ไม่ใช่ 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;
+ if (!quizCarryTileReachableServer(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 ถ้ามีการเปลี่ยนสถานะ · clientPos = ตำแหน่งที่ client รายงานตอนกด grab (ใช้ถ้าใกล้ server ≤1.25 tile) */
+function quizCarryServerTryInteract(sid, space, actorId, isBot, clientPos) {
+ 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);
+ /* client รายงานตำแหน่งตอนกด F — ถ้าใกล้ server พอ ใช้ client เพื่อลด desync หยิบไม่ติด/เด้ง */
+ if (clientPos && typeof clientPos === 'object') {
+ const cx = Number(clientPos.x), cy = Number(clientPos.y);
+ if (Number.isFinite(cx) && Number.isFinite(cy) && Number.isFinite(px) && Number.isFinite(py)) {
+ if (Math.hypot(cx - px, cy - py) <= 1.25) { px = cx; py = cy; }
+ }
+ }
+ }
+ 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 });
+ /* แจ้งผลถูก/ผิด "บรอดแคสต์ให้ทุกคน" → โชว์ไอคอน ✓/✗ เหนือหัวทั้งคนจริงและบอท (ทุกจอเห็นพร้อมกัน) */
+ io.to(sid).emit('quiz-carry-answer-feedback', { id: actorId, correct: ok });
+ 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;
+ /* resolve ชื่อผู้ชนะ (ให้ client โชว์ชื่อจริง ไม่ใช่ 'ผู้เล่น' ตอน churn) + diag: ใครตอบถูก (คน/บอท) */
+ let winnerNick = '', winnerIsBot = false;
+ if (winnerId) {
+ if (s.bots && s.bots.has(winnerId)) { winnerIsBot = true; }
+ else { const wp = space.peers.get(winnerId); if (wp && wp.nickname) winnerNick = wp.nickname; }
+ }
+ try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId ? ('[TC169] ' + (winnerNick || (winnerIsBot ? 'BOT ' + winnerId : winnerId))) : '-(หมดเวลา ไม่มีคนถูก)') + ' correctIdx=' + (s.current ? s.current.correctIndex : '?') + ' willEnd=' + willEnd); } catch (e) { /* ignore */ }
+ io.to(sid).emit('quiz-carry-result', {
+ questionIndex: s.qIndex + 1,
+ correctIndex: s.current ? s.current.correctIndex : null,
+ winnerId: winnerId || null,
+ winnerNick,
+ winnerIsBot,
+ 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.lastAdvanceAt = Date.now();
+ try { glog(sid, space, 'mg4-read', 'q=' + s.qIndex + ' deck=' + (s.deck ? s.deck.length : 0) + ' rounds=' + s.roundsCompleted + '/' + s.sessionLength); } catch (e) { /* ignore */ }
+ 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';
+ s.lastAdvanceAt = Date.now();
+ 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);
+ /* 0 = ไม่ได้ตั้งค่า → จบเมื่อ "ถามครบทุกข้อในชุด" รอบเดียว (ไม่วนซ้ำ) ตรงกับที่ผู้เล่นคาดหวังว่า
+ "พอจบทุกคำถาม เกมต้องจบ"; ถ้า pool ว่างค่อย fallback 10 (กันหารด้วยศูนย์/ค้าง) */
+ if (!(sessionLength > 0)) sessionLength = Math.max(1, pool.length || 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover (BFS เล่นต่อ) — แทนคนที่ออก ใช้ socket.id เดิม ไม่ชน id บอท AI */
+ {
+ const sC = space.quizCarrySession;
+ drainPendingMissionTakeovers(space, 'quiz_carry', (rec) => {
+ sC.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ dir: rec.direction || 'down', isWalking: false,
+ held: null, score: 0,
+ botPath: [], pathfindAfter: 0, wantIdx: null,
+ wanderTX: null, wanderTY: null, retargetAt: 0,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+ }
+ if (!pool.length) { endQuizCarryGame(sid, space); return; }
+ /* watchdog กันเกมค้างไม่จบ: เก็บนอก space.quizTimers (เพราะ resolveQuizCarryRound เคลียร์ array นั้นทุกข้อ)
+ ถ้าไม่มีความคืบหน้า (lastAdvanceAt) นานเกิน → force resolve/จบ — ป้องกัน "เล่นจนครบแต่เกมไม่ยอมจบ" */
+ {
+ const sC = space.quizCarrySession;
+ sC.lastAdvanceAt = Date.now();
+ const perRound = Math.max(0, sC.carryReadMs) + Math.max(1000, sC.carryAnswerMs) + QUIZ_CARRY_BETWEEN_MS + 1500;
+ const stallMs = perRound + 20000; /* ช่วงเวลาที่ "ยอมรับได้" ต่อข้อ ก่อนถือว่าค้าง */
+ if (space.quizCarryWatchdog) clearInterval(space.quizCarryWatchdog);
+ space.quizCarryWatchdog = setInterval(() => {
+ const sp = spaces.get(sid);
+ const ss = sp && sp.quizCarrySession;
+ if (!sp || !ss || !ss.active || ss.ended) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; return; }
+ const idle = Date.now() - (ss.lastAdvanceAt || 0);
+ if (idle > stallMs) {
+ try { glog(sid, sp, 'mg4-watchdog', 'STALL idle=' + idle + 'ms phase=' + ss.phase + ' round=' + ss.roundsCompleted + '/' + ss.sessionLength); } catch (e) { /* ignore */ }
+ ss.lastAdvanceAt = Date.now();
+ /* ดันให้ไปต่อ: ถ้ายังตอบ/อ่านค้าง → จบรอบนี้ (ไม่ให้คะแนน) แล้วระบบจะเลื่อนข้อหรือจบเองตาม willEnd */
+ if (!ss.roundResolved) { try { resolveQuizCarryRound(sid, sp, null); } catch (e) { /* ignore */ } }
+ else { try { scheduleQuizCarryReadPhase(sid, sp); } catch (e) { /* ignore */ } }
+ }
+ }, 5000);
+ }
+ scheduleQuizCarryReadPhase(sid, space);
+}
+
+function endQuizCarryGame(sid, space) {
+ const s = space.quizCarrySession;
+ if (s) { s.active = false; s.ended = true; }
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; }
+ try { glog(sid, space, 'mg4-end', 'END rounds=' + (s ? s.roundsCompleted : '?') + '/' + (s ? s.sessionLength : '?')); } catch (e) { /* ignore */ }
+ 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;
+
+ /* ก้าวไป (x,y) ได้ไหม: ต้อง "ยืนได้ทั้ง footprint" (canStand = ทุกช่องของตัวละครไม่ใช่กำแพง/ไม่ทับ hub/ไม่ทับโต๊ะ interactive)
+ — เดิมเช็คแค่ quizTileWalkableServer ที่ "ช่อง anchor จุดเดียว" ทำให้ footprint ขอบตัวเลื่อนคร่อมกำแพงได้ →
+ ตกไปช่องที่ reachability-BFS (footprint-aware) ถือว่า "ออกไม่ได้" → โดน push-out teleport ทุก tick = อาการ "บอทว๊าป".
+ ใช้ canStand ให้ตรงกับ pathfind (ที่วางเส้นบน canStand อยู่แล้ว) → บอทไม่เดินเข้าช่องที่จะถูกดันออก */
+ const canStepTo = (x, y) => quizCarryEntityCanStandServer(md, Math.floor(x), Math.floor(y));
+
+ 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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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) => {
+ /* ช่วงอ่านคำถาม (read) → บอท freeze อยู่ตรงไหนตรงนั้น (ตรงกับผู้เล่นที่ freeze ฝั่ง client) */
+ if (s.phase === 'read') { b.isWalking = false; return; }
+ /* ทับ hub / โต๊ะ submit กลาง (interactive) / หรือ "ติดในคอนโซลกลาง" (ยืนได้แต่เดินออกไม่ได้) → ดันออกไปช่องว่างที่ถึงได้
+ หมายเหตุ: หลังแก้ canStepTo → canStand แล้ว บอทไม่ควรเดินเข้าช่องแบบนี้เอง teleport นี้จึงเป็น safety-net (ควรยิงน้อยมาก)
+ — diag: glog ทุกครั้งที่ teleport จริง เพื่อยืนยันว่า "ว๊าป" ลดลง (ถอด log ออกเมื่อยืนยันแล้ว) */
+ if (quizCarryFootprintOverlapsHubServer(md, b.x, b.y) || quizCarryFootprintOverlapsInteractiveServer(md, b.x, b.y) || !quizCarryTileReachableServer(md, b.x, b.y)) {
+ const tiles = quizCarrySpawnTilesServer(md);
+ if (tiles.length) {
+ const fromX = b.x, fromY = b.y;
+ const t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = [];
+ try { glog(sid, space, 'mg4-warp', '[TC158] push-out teleport ' + bid + ' (' + fromX.toFixed(2) + ',' + fromY.toFixed(2) + ')->(' + b.x.toFixed(2) + ',' + b.y.toFixed(2) + ') — event นี้ = ยังว๊าป (fail); ไม่มี event ตลอดเกม = pass'); } catch (e) { /* ignore */ }
+ }
+ }
+ /* ยังไม่มีเส้นทาง → ลองหยิบ/ส่งที่จุดยืนก่อน */
+ 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)) {
+ /* ช่องที่ "ยืนได้จริง + ชิดโต๊ะ interactive" (ไม่ใช่ยืนทับโต๊ะ — โต๊ะเป็นสิ่งกีดขวางแล้ว) → ส่งจากข้างๆ */
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!quizCarryEntityCanStandServer(md, x, y)) continue;
+ if (quizCarryFootprintAdjacentInteractiveServer(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 — เลข 2–16หาย) */
+function normalizeQuizCarryLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_carry') return;
+ const w = m.width || 20, h = m.height || 15;
+ const normHub = () => {
+ const src = m.quizCarryHubArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizCarryHubArea = rows;
+ };
+ const normOptions = () => {
+ const src = m.quizCarryOptionArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ m.quizCarryOptionArea = rows;
+ };
+ const normCountdown = () => {
+ const src = m.carryEmbedCountdownArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ rows.push(row);
+ }
+ m.carryEmbedCountdownArea = rows;
+ };
+ normHub();
+ normOptions();
+ normCountdown();
+}
+
+/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
+function normalizeQuizBattleDomeAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattleDomeArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattleDomeArea = rows;
+}
+
+/** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */
+function normalizeQuizBattleDomeCompOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const grid = m.quizBattleDomeArea;
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ let nextId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue;
+ nextId++;
+ const stack = [[x, y]];
+ comp[y][x] = nextId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue;
+ comp[ny][nx] = nextId;
+ stack.push([nx, ny]);
+ }
+ }
+ }
+ }
+ m.quizBattleDomeComp = comp;
+}
+
+/**
+ * ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน
+ * ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน
+ */
+function hasQuizBattleManualPathGates(m) {
+ const mg = m && m.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+}
+
+function buildQuizBattlePathGateFromManualOnMap(m) {
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const manual = m.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ /* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function buildQuizBattlePathGateRequiredOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) {
+ if (m) m.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGates(m)) {
+ buildQuizBattlePathGateFromManualOnMap(m);
+ return;
+ }
+ normalizeQuizBattleDomeCompOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const domeComp = m.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function finalizeQuizBattleMapLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ normalizeQuizBattlePathAreaOnMap(m);
+ normalizeQuizBattleDomeCompOnMap(m);
+ buildQuizBattlePathGateRequiredOnMap(m);
+}
+
+function normalizeQuizBattlePathAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattlePathArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattlePathArea = rows;
+}
+
+function quizBattlePathModeActiveServer(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
+ const g = m.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattleFootprintFullyOnPathServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ const g = m.quizBattlePathArea;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+/** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */
+function quizBattlePositionAllowedServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true;
+ const sa = m.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+function quizBattlePlayerSolvedCompsServer(space, socketId) {
+ if (!space || !space.qbwSolved) return new Set();
+ const set = space.qbwSolved.get(socketId);
+ return set || new Set();
+}
+
+function quizBattlePathGateRequiredAtServer(m, px, py) {
+ const gate = m && m.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActiveServer(m)) return 0;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ const path = m.quizBattlePathArea;
+ let maxReq = 0;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+}
+
+function quizBattleFootprintOnDomeServer(m, px, py) {
+ const dome = m && m.quizBattleDomeArea;
+ if (!dome) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattlePathGateAllowsServer(m, space, socketId, px, py) {
+ if (!m || !quizBattlePathModeActiveServer(m)) return true;
+ if (quizBattleFootprintOnDomeServer(m, px, py)) return true;
+ const maxReq = quizBattlePathGateRequiredAtServer(m, px, py);
+ if (maxReq <= 0) return true;
+ const solved = quizBattlePlayerSolvedCompsServer(space, socketId);
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solved.has(c)) return false;
+ }
+ return true;
+}
+
+function serverFootprintClearOfWalls(md, px, py) {
+ const w = md.width || 20, h = md.height || 15;
+ const keys = serverWallCollisionTileKeys(md, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects && md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+}
+
+/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
+function snapPositionOntoQuizBattlePathServer(md, px, py) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidServer(md, px, py)) return { x: px, y: py };
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidServer(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+}
+
+function collectQuizBattleValidSpawnCentersServer(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+}
+
+function quizBattleSnapTargetValidServer(md, x, y) {
+ if (!md || !md.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ if (!serverFootprintClearOfWalls(md, x, y)) return false;
+ if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false;
+ return true;
+}
+
+function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersServer(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+}
+
+function collectQuizBattleSpawnPoolServer(md) {
+ const out = [];
+ if (!md) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActiveServer(md)) {
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ } else if (!isMapTileWalkableForSpawn(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActiveServer(md)) {
+ return collectQuizBattleValidSpawnCentersServer(md);
+ }
+ return out;
+}
+
+function pickQuizBattleRandomSpawnWorldServer(md) {
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y);
+ }
+ const idx = typeof crypto.randomInt === 'function'
+ ? crypto.randomInt(0, pool.length)
+ : Math.floor(Math.random() * pool.length);
+ const pick = pool[idx];
+ return { x: pick.x, y: pick.y };
+}
+
+function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMap(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldServer(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldServer(md, sx, sy);
+ }
+ return pickQuizBattleRandomSpawnWorldServer(md);
+}
+
+function pickQuizBattleSpawnFromMap(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+}
+
+function normalizeJumpSurvivePlatformAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformArea = rows;
+}
+
+function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const pa = m.jumpSurvivePlatformArea || [];
+ const src = m.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformVariantArea = rows;
+}
+
+function normalizeJumpSurviveHazardAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurviveHazardArea = rows;
+}
+
+/**
+ * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
+ */
+function applyStackTowerMissionMapPlayPolicy(safeId, m) {
+ if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
+ const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
+ const spNum = Number(raw.speedPxPerSec);
+ const speedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ const sel = Math.floor(Number(raw.stepEveryLayers));
+ const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
+ const ssp = Math.floor(Number(raw.stepScrollPx));
+ const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
+ const sam = Math.floor(Number(raw.stepAnimMs));
+ const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
+ const st = {
+ enabled: raw.enabled === true,
+ speedPxPerSec,
+ scrollDirection,
+ stepEveryLayers,
+ stepScrollPx,
+ stepAnimMs,
+ };
+ if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
+ if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
+ const rg = Number(raw.releaseGapWorldPx);
+ if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
+ m.stackTowerBgScroll = st;
+ const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
+ const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
+ const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
+ m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
+}
+
+function loadMaps() {
+ try {
+ if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(MAPS_DIR);
+ for (const f of files) {
+ if (!f.endsWith('.json')) continue;
+ const id = f.slice(0, -5);
+ if (!safeMapId(id)) continue;
+ try {
+ const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(id, data);
+ maps.set(id, data);
+ } catch (e) { console.error('load map', f, e.message); }
+ }
+ } catch (e) { console.error('loadMaps', e.message); }
+}
+
+/**
+ * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
+ * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
+ */
+function rehydrateSingleMapFromDisk(safeId) {
+ if (!safeId) return false;
+ const fp = path.join(MAPS_DIR, safeId + '.json');
+ if (!fs.existsSync(fp)) return false;
+ try {
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(safeId, data);
+ maps.set(safeId, data);
+ return true;
+ } catch (e) {
+ console.error('rehydrate map', safeId, e.message);
+ return false;
+ }
+}
+
+/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
+function ensurePostCaseLobbyMapLoaded() {
+ if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
+ try {
+ const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
+ if (!fs.existsSync(fp)) return false;
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ maps.set(POST_CASE_LOBBY_SPACE_ID, data);
+ return true;
+ } catch (e) {
+ console.error('ensurePostCaseLobbyMapLoaded', e.message);
+ return false;
+ }
+}
+
+function saveMap(id, m) {
+ const safe = safeMapId(id);
+ if (!safe) return;
+ try {
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
+ } catch (e) { console.error('saveMap', id, e.message); }
+}
+
+function isMapNameTaken(name, excludeId) {
+ const n = (name || '').trim().toLowerCase();
+ for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
+ return false;
+}
+
+function applyDevCors(req, res) {
+ const origin = req.headers.origin;
+ if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.setHeader('Vary', 'Origin');
+ }
+}
+
+const server = http.createServer((req, res) => {
+ applyDevCors(req, res);
+ if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ let url = req.url;
+ if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
+ if (url.startsWith(BASE_PATH + '/api/maps')) {
+ let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
+ if (id.includes('?')) id = id.split('?')[0];
+ if (req.method === 'GET' && id) {
+ const sid = safeMapId(decodeURIComponent(id));
+ if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ rehydrateSingleMapFromDisk(sid);
+ const m = maps.get(sid);
+ if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
+ }
+ if (req.method === 'GET' && !id) {
+ const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
+ }
+ if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || 'ฉากใหม่';
+ if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const id = Date.now().toString(36);
+ const m = { ...defaultMap(), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ if (req.method === 'PUT' && id && maps.has(id)) {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || maps.get(id).name;
+ if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const m = { ...maps.get(id), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ applyStackTowerMissionMapPlayPolicy(id, m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ }
+ const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ const quizCarryDiskPaths = new Set([
+ BASE_PATH + '/api/quiz-carry-from-disk',
+ BASE_PATH + '/api-quiz-carry-from-disk.php',
+ ]);
+ if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
+ if (req.method === 'GET') {
+ const g = loadQuizSettings();
+ const slice = {
+ carryMapPanelTheme: g.carryMapPanelTheme,
+ carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
+ carryReadMs: g.carryReadMs,
+ carryAnswerMs: g.carryAnswerMs,
+ carrySessionLength: g.carrySessionLength,
+ carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
+ carryWalkSpeedMult: g.carryWalkSpeedMult,
+ carryQuestions: g.carryQuestions,
+ };
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(slice));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(getSystemStats()));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadQuizSettings()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveQuizSettings(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ battleQuizMcqSaved: saved.battleQuizMcqSaved,
+ carryQuestionsSaved: saved.carryQuestionsSaved,
+ specialQuizSets: saved.specialQuizSets,
+ carryMapPanelTheme: saved.carryMapPanelTheme,
+ carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadEvidenceCards()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveEvidenceCards(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadCaseMedia()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveCaseMedia(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ });
+ return res.end(JSON.stringify({ ...runtimeGameTiming }));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveGameTimingToFile(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ const { ok: _ok, error: _err, ...timingRest } = saved;
+ return res.end(JSON.stringify({ ok: true, ...timingRest }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureGauntletAssetsDir();
+ const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
+ let fname;
+ if (replaceFilename) {
+ const s = safeGauntletStoredFilename(replaceFilename);
+ if (!s) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
+ }
+ const cur = path.join(GAUNTLET_ASSETS_DIR, s);
+ if (!fs.existsSync(cur)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
+ }
+ const norm = (e) => {
+ let x = String(e || '').toLowerCase();
+ if (x.startsWith('.')) x = x.slice(1);
+ return x === 'jpeg' ? 'jpg' : x;
+ };
+ if (norm(path.extname(s)) !== norm(ext)) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
+ }
+ fname = s;
+ } else {
+ fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ }
+ fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
+ const meta = loadGauntletAssetsMeta();
+ const prev = meta[fname] || {};
+ meta[fname] = {
+ ...prev,
+ label: labelIn || prev.label || fname,
+ updatedAt: Date.now(),
+ };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/gauntlet-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureQuizCarryPlaqueAssetsDir();
+ const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const fname = safeGauntletStoredFilename(d.filename);
+ if (!fname) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
+ }
+ const meta = loadGauntletAssetsMeta();
+ const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
+ try {
+ const q = url.includes('?') ? url.split('?')[1] : '';
+ const sp = new URLSearchParams(q);
+ const rawFn = sp.get('file');
+ const fname = safeGauntletStoredFilename(rawFn);
+ if (!fname) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
+ const meta = loadGauntletAssetsMeta();
+ delete meta[fname];
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
+ }
+ }
+ if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const ids = new Set();
+ const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
+ files.forEach(f => {
+ let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ });
+ const idArr = [...ids];
+ const oldestMsById = {};
+ idArr.forEach((id) => {
+ let oldest = Infinity;
+ const prefix = id + '_';
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ try {
+ const st = fs.statSync(path.join(CHARACTERS_DIR, f));
+ const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
+ if (t < oldest) oldest = t;
+ } catch (e) { /* ignore */ }
+ });
+ oldestMsById[id] = oldest === Infinity ? 0 : oldest;
+ });
+ idArr.sort((a, b) => {
+ const ta = oldestMsById[a] || 0;
+ const tb = oldestMsById[b] || 0;
+ if (ta !== tb) return ta - tb;
+ return String(a).localeCompare(String(b));
+ });
+ const list = idArr.map((id) => {
+ const prefix = id + '_';
+ const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
+ const layerNameSet = new Set();
+ if (hasLayerFiles) {
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) {
+ layerNameSet.add(m[1]);
+ return;
+ }
+ m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) layerNameSet.add(m[1]);
+ });
+ }
+ const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
+ const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
+ return { id, name: id, hasLayerFiles, layers, layerManifest };
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(list));
+ } catch (e) {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify([]));
+ }
+ }
+ const urlPath = url.split('?')[0];
+ {
+ const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
+ if (lm && req.method === 'GET') {
+ const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const manifest = buildCharacterLayerManifest(id, files);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(manifest));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
+ }
+ }
+ }
+ if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
+ if (/\/layer-manifest$/i.test(urlPath)) {
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
+ }
+ const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
+ const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ let removed = 0;
+ files.forEach(f => {
+ if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
+ try {
+ fs.unlinkSync(path.join(CHARACTERS_DIR, f));
+ removed++;
+ } catch (e) {
+ // ignore single file errors
+ }
+ }
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, removed }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ return;
+ }
+ if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ if (!body || body.length < 2) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
+ }
+ const d = JSON.parse(body);
+ const dirs = ['up', 'down', 'left', 'right'];
+ const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
+ let walkWritten = 0;
+ for (const dir of dirs) {
+ let data = d[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ // เขียนไฟล์เฟรม (สำหรับ animation)
+ const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ walkWritten++;
+ frameIndex++;
+ }
+ }
+ let layerWritten = 0;
+ // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
+ const lf = d.layerFrames;
+ if (lf && typeof lf === 'object') {
+ for (const dir of dirs) {
+ const arr = lf[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const lfIdle = d.layerFramesIdle;
+ if (lfIdle && typeof lfIdle === 'object') {
+ for (const dir of dirs) {
+ const arr = lfIdle[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_idle_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
+ if (idleObj) {
+ for (const dir of dirs) {
+ let data = idleObj[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameFrame = list.length > 1
+ ? (id + '_' + dir + '_idle_' + frameIndex + '.png')
+ : (id + '_' + dir + '_idle.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '_idle.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ frameIndex++;
+ }
+ }
+ }
+ if (walkWritten === 0 && layerWritten === 0) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, characterId: id }));
+ } catch (e) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/api/spaces')) {
+ const parts = url.slice((BASE_PATH + '/api/spaces').length).split('?')[0].split('#')[0].split('/').filter(Boolean);
+ const spaceId = parts[0];
+ if (req.method === 'GET' && !spaceId) {
+ const now = Date.now();
+ for (const [id, s] of [...spaces.entries()]) {
+ if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
+ const n = s.peers ? s.peers.size : 0;
+ if (n !== 0) continue;
+ /* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */
+ const since = s.emptySince || s.createdAt;
+ const stale = since == null || now - since > SPACE_EMPTY_TTL_MS;
+ if (stale) spaces.delete(id);
+ }
+ const list = [...spaces]
+ .filter(([, s]) => !s.isPrivate)
+ .filter(([, s]) => (s.peers && s.peers.size > 0))
+ .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;
+ }
+ /* เสียง: volume master/music/sfx — GET เปิดสาธารณะ (sound.js ทุก client อ่าน) · PUT เฉพาะแอดมิน */
+ if (url === BASE_PATH + '/api/sound-settings' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(loadSoundSettings()));
+ return;
+ }
+ if (url === BASE_PATH + '/api/sound-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 = loadSoundSettings();
+ const clamp = (v, cur) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : cur; };
+ if (d.masterVol !== undefined) s.masterVol = clamp(d.masterVol, s.masterVol);
+ if (d.musicVol !== undefined) s.musicVol = clamp(d.musicVol, s.musicVol);
+ if (d.sfxVol !== undefined) s.sfxVol = clamp(d.sfxVol, s.sfxVol);
+ saveSoundSettings(s);
+ res.writeHead(200);
+ res.end(JSON.stringify({ ok: true, settings: s }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const message = (d.message || d.text || '').trim();
+ if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
+ const s = loadAiSettings();
+ if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
+ const model = s.model || 'gpt-4o-mini';
+ const messages = [];
+ const systemParts = [];
+ if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
+ if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
+ systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
+ }
+ if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
+ messages.push({ role: 'user', content: message });
+ const payload = JSON.stringify({
+ model: model,
+ messages: messages,
+ max_tokens: 500,
+ });
+ const urlObj = new URL('https://api.openai.com/v1/chat/completions');
+ const opts = {
+ hostname: urlObj.hostname,
+ path: urlObj.pathname,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + s.openai_api_key,
+ 'Content-Length': Buffer.byteLength(payload, 'utf8'),
+ },
+ };
+ const reqOut = require('https').request(opts, (resOut) => {
+ let data = '';
+ resOut.on('data', chunk => data += chunk);
+ resOut.on('end', () => {
+ try {
+ const parsed = JSON.parse(data);
+ const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
+ if (text != null) {
+ res.writeHead(200);
+ res.end(JSON.stringify({ response: String(text).trim() }));
+ } else {
+ res.writeHead(502);
+ res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
+ }
+ } catch (e) {
+ res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
+ }
+ });
+ });
+ reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
+ reqOut.write(payload);
+ reqOut.end();
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/img/characters/') && (req.method === 'GET' || req.method === 'HEAD')) {
+ const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
+ const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
+ if (fname && fname.length > 0) {
+ const fp = path.join(CHARACTERS_DIR, fname);
+ if (fs.existsSync(fp)) {
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': 'image/png' });
+ if (req.method === 'HEAD') return res.end();
+ res.end(data);
+ });
+ }
+ }
+ }
+ if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
+ const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
+ // production: nginx เสิร์ฟ /Game/img/* เป็น static ทั้งหมด — local node จึงต้องเสิร์ฟไฟล์ที่มีอยู่จริงในโฟลเดอร์ด้วย
+ // (ไม่จำกัดเฉพาะไฟล์อัปโหลด gauntlet-) ไม่งั้น asset ดีไซน์ (popup-Howto, Avartar, btn-ready…) จะ 400 เฉพาะตอนรันผ่าน node ตรง ๆ
+ const fname = safeGauntletStoredFilename(sub) || path.basename(sub); // basename กัน path traversal
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fname || !fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end();
+ }
+ const ext = path.extname(fname);
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
+ res.end(data);
+ });
+ }
+ if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
+ // ตัด query string / hash ออกก่อน resolve เป็น path ไฟล์ (เช่น /play.html?space=...&map=... → /play.html)
+ // ไม่ตัด จะหาไฟล์ชื่อ "play.html?space=..." ไม่เจอ → 404 (เข้า node ตรง ๆ พร้อม query)
+ url = url.split('?')[0].split('#')[0] || '/index.html';
+ const filePath = path.join(__dirname, 'public', url);
+ const ext = path.extname(filePath);
+ const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
+ fs.readFile(filePath, (err, data) => {
+ if (err) return res.writeHead(404), res.end('Not Found');
+ res.writeHead(200, {
+ 'Content-Type': types[ext] || 'application/octet-stream',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+ res.end(data);
+ });
+});
+
+const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
+const spaces = new Map();
+
+const GAUNTLET_MAX_PLAYERS = 6;
+/** เข้าเส้นชัยแล้ว — หยุด 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);
+}
+
+/** มินิเกม "live แล้ว" (พ้น how-to/นับถอยหลัง — session engine เริ่มแล้ว หรือ gauntlet ปล่อยวิ่งแล้ว)?
+ ใช้ตอน rejoin: ถ้า live แล้ว client ต้องข้าม how-to แล้วเข้าเล่นต่อทันที (เดิมโชว์ how-to เสมอ → คน reconnect ค้าง)
+ signal = session.active (สร้างตอน 3-2-1 จบ, ล้างตอนกลับ LobbyB) — เชื่อถือได้กว่า missionLiveBeginsAt (ไม่เคย reset ต่อเกม) */
+function isMinigameLiveForRejoinResume(space) {
+ if (!space) return false;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (!md) return false;
+ switch (md.gameType) {
+ case 'gauntlet': return !!(space.gauntletRun && !space.gauntletRun.crownRunHeld);
+ case 'stack': return !!(space.stackSession && space.stackSession.active);
+ case 'jump_survive': return !!(space.jumpSurviveSession && space.jumpSurviveSession.active);
+ case 'space_shooter': return !!(space.spaceShooterSession && space.spaceShooterSession.active);
+ case 'balloon_boss': return !!(space.balloonBossSession && space.balloonBossSession.active);
+ case 'quiz': return !!(space.quizSession && space.quizSession.active);
+ case 'quiz_carry': return !!(space.quizCarrySession && space.quizCarrySession.active);
+ default: return false;
+ }
+}
+
+/* ── How-to / pre-live disconnect takeover (MG3–MG7) ───────────────────────────
+ มินิเกมเหล่านี้สร้าง session (bots/turnOrder) ตอน 3-2-1 จบเท่านั้น → ถ้าคนหลุดช่วง how-to /
+ นับถอยหลัง ยังไม่มี session ให้แปลงเป็นบอท เดิม avatar เลยหายไปเลย (ตกไป user-left).
+ แก้: เก็บ "pending takeover" ไว้บน space + แจ้ง client เก็บ avatar (*-player-to-bot) แล้ว
+ seed เป็นบอท server ตอน startXxxGame → บอทรับช่วงเล่นต่อทันทีเมื่อเกมเริ่ม เหมือน MG1/MG2 */
+function missionShellGameTypeForSpace(space) {
+ if (!space) return null;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gt = md && md.gameType;
+ if (!gt) return null;
+ if (gt === 'space_shooter' && isSpaceShooterMissionShellSpace(space)) return 'space_shooter';
+ if (gt === 'jump_survive' && isJumpSurviveMissionShellSpace(space)) return 'jump_survive';
+ if (gt === 'balloon_boss' && isBalloonBossMissionShellSpace(space)) return 'balloon_boss';
+ if (gt === 'stack' && isStackTowerMissionShellSpace(space)) return 'stack';
+ if (gt === 'quiz_carry') return 'quiz_carry';
+ return null;
+}
+function recordPendingMissionTakeover(space, gameType, socketId, info, playerKey, nick, wasElim) {
+ if (!space || !gameType || !socketId || !info) return;
+ if (!Array.isArray(space.pendingMissionTakeovers)) space.pendingMissionTakeovers = [];
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => r.id !== socketId);
+ const themeIdx = parseInt(info.lobbyColorThemeIndex, 10);
+ const skinIdx = parseInt(info.lobbySkinToneIndex, 10);
+ space.pendingMissionTakeovers.push({
+ gameType, id: socketId, playerKey: playerKey || '', nick: (nick || 'ผู้เล่น'), wasEliminated: !!wasElim,
+ x: Number(info.x), y: Number(info.y), direction: info.direction || 'down',
+ characterId: info.characterId != null ? info.characterId : null,
+ themeIdx: Number.isFinite(themeIdx) ? themeIdx : null,
+ skinIdx: Number.isFinite(skinIdx) ? skinIdx : null,
+ spaceShooterScore: Math.max(0, Number(info.spaceShooterScore) || 0),
+ spaceShooterEliminated: !!info.spaceShooterEliminated,
+ balloonBossScore: Math.max(0, Number(info.balloonBossScore) || 0),
+ balloonBossBossDmg: Math.max(0, Number(info.balloonBossBossDmg) || 0),
+ balloonBossBalloons: Math.max(0, Number(info.balloonBossBalloons) || 0),
+ balloonBossEliminated: !!info.balloonBossEliminated,
+ jumpSurviveEliminated: !!info.jumpSurviveEliminated,
+ });
+}
+/** seed บอท takeover จาก pending (เฉพาะ gameType นี้) — makeBot(rec) สร้างบอทใน session แล้วคืน true ถ้าสำเร็จ */
+function drainPendingMissionTakeovers(space, gameType, makeBot) {
+ if (!space || !Array.isArray(space.pendingMissionTakeovers) || typeof makeBot !== 'function') return 0;
+ const keep = [];
+ let n = 0;
+ for (const rec of space.pendingMissionTakeovers) {
+ if (rec.gameType !== gameType) { keep.push(rec); continue; }
+ try { if (makeBot(rec) !== false) n++; } catch (e) { /* ignore */ }
+ }
+ space.pendingMissionTakeovers = keep;
+ return n;
+}
+
+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_` 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,
+ gameoverAt: null, /* != null = อยู่ในช่วง grace "ตายหมด" (โชว์ game over ก่อนกลับ LobbyB) */
+ };
+}
+
+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.5; /* miss เมื่อ overlap < 50% บล็อก (เดิม 30% หลวมไป = ลงเยื้อง 3 ช่องยังไม่ miss หัวใจไม่ลด) — ต้องตรงกับ client */
+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 = 1200; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป (ลดจาก 1500 + swing เดินต่อระหว่าง busy → ไม่ค้าง) */
+const STACK_BOT_THINK_MIN_MS = 560;
+const STACK_BOT_THINK_MAX_MS = 1180;
+const STACK_SEATS = 6;
+/* เฟส client (ตอนกดวาง) ต้องใกล้เฟส server ไม่เกินนี้ (rad) ถึงจะใช้.
+ เดิม 1.2rad (~0.35s@0.55Hz) → latency/desync เกินนั้น server ทิ้งเฟส client ใช้ s.phase (เลื่อนไปแล้ว)
+ = "เล็งกลางแต่ลงเยื้อง/ร่วง" (intermittent ตาม latency). ขยายเป็น ~1รอบ (2π ≈ 1.8s@0.55Hz)
+ = ครอบ latency เล่นได้จริงทั้งหมด → ใช้เฟสที่ผู้เล่นเห็นเสมอ (ลงตรงจุดที่เห็น) เหลือไว้แค่กันค่า garbage.
+ เกม co-op สร้างตึกร่วมกัน ไม่ต้องกันโกงเฟส */
+const STACK_DROP_PHASE_TOL = Math.PI * 2;
+
+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));
+ }
+ /* rel-boost: ความกว้างสวิงตาม stackReleaseArea (ต้องตรงกับ client play.js ~12177-12180 เป๊ะ)
+ เดิม server ใช้แค่ land span → client (มี rel-boost) แกว่ง "กว้างกว่า" server: บล็อกห้อยกว้างแต่ตกแคบ
+ = ไถลเฉียงตอนปล่อย + logical ตกใกล้กลางเสมอจน "ทับ support ตลอด ไม่นับ miss → หัวใจไม่ลด".
+ ทำให้ 2 ฝั่งใช้สวิงเดียวกัน → ตกตรง (straight) + miss/หัวใจ ทำงานถูก */
+ const rel = stackBoundsServer(md.stackReleaseArea, w, h);
+ if (rel) {
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.32, 3.0)); /* ต้องตรงกับ client play.js — ลดจาก 0.42/4.2 (สวิงกว้างเกิน = หน่วงนิ้วนิดเดียวเยื้องเยอะ "ต่อตรงแต่ลงเยื้อง") แต่ยังกว้างพอให้ miss ได้เมื่อเยื้องจริง */
+ }
+ 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)
+ phaseOverride: ตา "คนจริง" ส่งเฟสที่เห็นตอนกดมาด้วย (client phase) → คำนวณจุดลงตามที่ผู้เล่นเห็น
+ ไม่ใช่เฟส server ณ ตอน req มาถึง (ช้ากว่า half-RTT = บล็อกลงเยื้อง). บอท/fallback ใช้ s.phase */
+function stackComputeDropServer(s, md, phaseOverride) {
+ const dropPhase = (typeof phaseOverride === 'number' && Number.isFinite(phaseOverride)) ? phaseOverride : s.phase;
+ const raw = s.swingAmp * Math.sin(dropPhase);
+ 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;
+ /* MISS = แค่ overlapMiss (บล็อกไม่ทับบล็อกล่างพอ) เท่านั้น.
+ เดิมมี driftMiss เทียบ placedCx กับ "กลางพื้นดินตายตัว" (anchorCx=land.cx) → ต่อเอียงเป็นบันได
+ (แต่ละชั้นทับชั้นล่างพอดี) พอ center สะสมห่างจากกลางพื้น >38% ฐาน บล็อกที่วางดีๆ ก็ถูกนับ miss
+ = อาการ "ตึกโยกตกทั้งที่ไม่ได้อยู่ริมๆ". ตัดออก — overlapMiss คือเงื่อนไขตกที่ถูกต้องอยู่แล้ว. */
+ const miss = overlapMiss;
+ 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;
+}
+/* สี block ของ "คนจริง" = lobbyColorThemeIndex (1-8) → ตรงกับสี avatar ทุกจอ
+ (เดิมโค้ด stack ใช้ p.lobbyThemeIndex ที่ไม่เคยถูกเซ็ต → null เสมอ; และ (x%8)+1 บนค่า 1-based = เลื่อนสีผิด 1 ช่อง) */
+function normStackTheme8(n) {
+ const v = Math.floor(Number(n));
+ return Number.isFinite(v) ? ((v - 1) % 8 + 8) % 8 + 1 : null;
+}
+function stackHumanThemeServer(p) {
+ return p ? normStackTheme8(p.lobbyColorThemeIndex) : null;
+}
+/* สี block ของบอท AI = สี avatar บอท slot นั้น (lobbyBotThemeBySlot[slot].themeIndex) ไม่ใช่ (slot%8)+1 */
+function stackBotThemeServer(space, slot) {
+ const m = space && space.lobbyBotThemeBySlot;
+ const t = m && m[slot] ? normStackTheme8(m[slot].themeIndex) : null;
+ return t || (((Math.floor(Number(slot)) % 8) + 8) % 8 + 1);
+}
+/* สร้างลำดับตา: humans (เรียง spawnJoinOrder) + bots เติมที่ว่าง ≤ 6 ที่นั่ง */
+function buildStackTurnOrderServer(space, s) {
+ try { syncLobbyBotThemeSlots(space); } catch (_e) { /* ให้ lobbyBotThemeBySlot ตรงกับสีที่ client ได้รับ → สี block บอทตรง avatar */ }
+ 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: stackHumanThemeServer(p) });
+ seat++;
+ }
+ /* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้ */
+ for (const [botId, b] of s.bots) {
+ if (!b || !b.takeover) continue;
+ if (seat > STACK_SEATS) break;
+ order.push({ kind: 'bot', seat, id: null, botId, theme: (b.theme != null ? b.theme : ((seat % 8) + 1)) });
+ 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: stackBotThemeServer(space, slot) });
+ 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,
+ /* full-sync หัวใจ + decrypt% → client ที่พลาด packet stack-drop (ช้า/churn) self-correct
+ (เดิมส่งเฉพาะตอน drop → พลาดแล้ว "หัวใจไม่ลด/% ไม่ตรง" ค้าง) */
+ teamMissesLeft: s.teamMissesLeft,
+ progressPct: s.progressPct,
+ });
+}
+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; }
+ }
+ });
+ /* [TC183] verify คะแนน MG3: team score + คะแนนต่อคน (ดูว่าสูงผิดปกติ/ซ้ำจากชื่อซ้ำไหม) */
+ try {
+ const per = s.turnOrder.map((e) => {
+ if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); return 'bot=' + Math.max(0, (b && b.score) | 0); }
+ if (e.id != null) { const p = space.peers.get(e.id); return ((p && p.nickname) || e.id).toString().slice(0, 6) + '=' + Math.max(0, s.humanPts[e.id] | 0); }
+ return '?';
+ });
+ glog(sid, space, 'stack-score', '[TC183] จบ MG3 outcome=' + outcome + ' team=' + (s.score | 0) + ' layers=' + s.layers.length + ' progress=' + Math.floor(s.progressPct || 0) + '% · ต่อคน: ' + per.join(' '));
+ } catch (e) { /* ignore */ }
+ 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) — ยังดรอปไม่ได้ (drop handler เช็ค busyUntil) แต่ให้ "บล็อกถัดไปแกว่งต่อ"
+ (advance phase + emit swing) → ตาถัดไปไม่เห็นบล็อกห้อยนิ่ง = ไม่ค้าง · client มี guard (!stackFall) ไม่ให้กระตุกตอนยังร่วง */
+ if (now < s.busyUntil) {
+ 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, t: now });
+ }
+ 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, t: now }); }
+ if (now >= s.botThinkUntil) {
+ /* คิดเสร็จ → ล็อกเฟสเป้าหมาย "ข้างหน้า" ครั้งเดียว แล้วรอแกว่งวิ่งมาถึงเองธรรมชาติ
+ (ไม่สแนปเฟส = ไม่กระตุก) ปล่อยเมื่อถึงเป้า หรือเลย deadline กันค้าง */
+ if (s.botAimPhase == null) {
+ s.botAimPhase = stackBotComputeAimPhaseServer(s, md, tier);
+ /* deadline ต้องยาวพอให้สวิง "ถึงเป้า" จริง = เวลาแกว่งจาก phase ปัจจุบันไปถึง aimPhase + buffer.
+ เดิม 2600ms ตายตัว → ตอน hz ต่ำ (สวิงช้า) กว่าจะถึงเป้าใช้ >2.6s → deadline เตะก่อน = บอทปล่อยผิด phase (เฉียง/miss) */
+ const _gap = Math.max(0, s.botAimPhase - s.phase);
+ const _radPerSec = Math.max(1e-4, s.phaseSpeed * Math.PI * 2);
+ s.botAimDeadline = now + Math.min(15000, Math.ceil((_gap / _radPerSec) * 1000) + 1500);
+ }
+ 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,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = now + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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, t: now }); }
+ }
+ /* 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);
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนจัดลำดับตา (buildStackTurnOrderServer จะใส่เป็น seat ให้) */
+ {
+ const s3 = space.stackSession;
+ drainPendingMissionTakeovers(space, 'stack', (rec) => {
+ s3.bots.set(rec.id, {
+ tier: 'avg', score: 0, takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ theme: normStackTheme8(rec.themeIdx),
+ });
+ });
+ }
+ 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 6), effectiveBotSlotCount(space)));
+ const bannedSlot = jsBannedBotSlot(space);
+ let poolIdx = 1 + humanCount + existing;
+ 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedJumpSurviveTakeoverBots(space, md);
+ createJumpSurviveBots(space, md);
+}
+function seedJumpSurviveTakeoverBots(space, md) {
+ const s = space.jumpSurviveSession;
+ if (!s) return;
+ const now = Date.now();
+ drainPendingMissionTakeovers(space, 'jump_survive', (rec) => {
+ s.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ vy: 0, onGround: false, dir: rec.direction || 'down', walking: false,
+ eliminated: !!rec.jumpSurviveEliminated,
+ wishX: 0, wishNext: now + 200, nextJumpAi: now + 200,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ยัง emit elim:true ต่อทุก tick (เดิม return ข้าม = ส่ง elim ครั้งเดียว → client พลาด = ค้างนิ่ง) */
+ if (b.eliminated) { outBots.push({ id, x: b.x, y: b.y, dir: b.dir || 'down', walking: false, elim: true }); 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 });
+ });
+ /* ผู้เล่นจริงที่สลับแท็บ (hidden) → จำลองการตกแทน timer ฝั่ง client ที่ถูก throttle → ส่งตำแหน่ง full-rate (คนอื่นเห็นลื่น).
+ ไม่มี input (ตก/ไหลตามแท่นเฉย ๆ) เหมือนคนปล่อยจอ. ตาย → mark eliminated + ตั้ง p.jumpSurviveEliminated (เข้าเช็ค all_dead/เกรด) */
+ if (s.players) {
+ for (const pid in s.players) {
+ const st = s.players[pid];
+ if (!st || !st.hidden || st.eliminated) continue;
+ if (!space.peers.has(pid)) { st.hidden = false; continue; } /* หลุดห้องแล้ว → ปล่อยให้ disconnect logic จัดการ */
+ const pxc = ((Number.isFinite(st.x) ? st.x : 1) + cw * 0.5) * ts;
+ const feetY = ((Number.isFinite(st.y) ? st.y : 1) + ch) * ts;
+ const r = jsPhysicsIntegrate(md, ts, sc, {
+ dt, ch, pxc0: pxc, feetY0: feetY, vy0: st.vy || 0, wasOnGround: !!st.onGround,
+ jumpQueued: false, moveLeft: false, moveRight: false,
+ });
+ st.x = r.pxc / ts - cw * 0.5;
+ st.y = r.feetY / ts - ch;
+ st.vy = r.vy; st.onGround = r.onGround;
+ if (r.died) {
+ st.eliminated = true; st.hidden = false; st.vy = 0;
+ const p = space.peers.get(pid); if (p) p.jumpSurviveEliminated = true;
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: false, elim: true });
+ } else {
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: Math.abs(r.vy) > 40, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" ในการจำกัด 6 ช่อง กันรวมเกิน 6 */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนสร้างบอท AI (createSpaceShooterBots นับ s.bots.size เป็น human-like) */
+ seedSpaceShooterTakeoverBots(space, md);
+ createSpaceShooterBots(space, md);
+}
+function seedSpaceShooterTakeoverBots(space, md) {
+ const s = space.spaceShooterSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ drainPendingMissionTakeovers(space, 'space_shooter', (rec) => {
+ const cx = Number.isFinite(rec.x) ? Math.max(ts * 0.5, Math.min(mw - ts * 0.5, (rec.x + 0.5) * ts)) : mw * 0.5;
+ const cy = ssClampCy(Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.5, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', cx, cy, dir: 'up',
+ score: rec.spaceShooterScore || 0, eliminated: !!rec.spaceShooterEliminated, 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() + 500,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ไม่เดิน/ไม่ยิง แต่ "ยัง emit elim:true ต่อ" — เดิม return ข้ามเลย (ส่ง elim ครั้งเดียวตอนตาย)
+ ถ้า client พลาด packet นั้น = ไม่รู้ว่าตาย → ยานค้างนิ่งบนจอ. ส่งซ้ำทุก tick → client ซ่อนได้แน่นอน */
+ if (!b.eliminated) {
+ 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: !!b.eliminated, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedBalloonBossTakeoverBots(space, md);
+ createBalloonBossBots(space, md);
+}
+function seedBalloonBossTakeoverBots(space, md) {
+ const s = space.balloonBossSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ const startBalloons = balloonBossBalloonsForMap(md);
+ drainPendingMissionTakeovers(space, 'balloon_boss', (rec) => {
+ const cx = Number.isFinite(rec.x) ? (rec.x + 0.5) * ts : mw * 0.5;
+ const cy = Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.4;
+ const cl = bbClampWorld(cx, cy, mw, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', homeCx: cl.cx, homeCy: cl.cy, cx: cl.cx, cy: cl.cy,
+ score: rec.balloonBossScore || 0, dmg: rec.balloonBossBossDmg || 0,
+ balloons: rec.balloonBossBalloons || startBalloons, eliminated: !!rec.balloonBossEliminated,
+ fireCd: 0.6 + Math.random() * 0.8, phase: Math.random() * Math.PI * 2,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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 });
+}
+/* ตำแหน่งบอส "เคลื่อนที่จริง" — port จาก client getBalloonBossBossLiveCenterPlay (anchor=liveBeginsAt เดียวกัน → ตรงกันทุกเครื่อง)
+ server เดิมคิดบอสเป็นจุดนิ่ง → บอทไม่รู้ว่าบอสขยับมาทับ. มีตัวนี้ บอทหลบบอสที่เคลื่อนที่ได้ + เล็งยิงตรงบอสจริง */
+function bbBossLiveCenter(md, ts, s, now) {
+ const base = bbBossCenter(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const anchor = (s && s.liveBeginsAt) ? s.liveBeginsAt : now;
+ const tSec = Math.max(0, (now - anchor) / 1000);
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return bbClampWorld(cx, cy, mw, mh);
+}
+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 = bbBossLiveCenter(md, ts, s, now); /* บอสเคลื่อนที่จริง (หลบ+เล็งตามตัวนี้) */
+ const fleeR = Math.max(ts * 3.4, 96); /* หลบบอสเมื่อ home เข้าใกล้ ~3 ช่อง */
+ const fleeSpd = 165; /* px/s ดริฟต์ home หนีบอส (เร็วกว่าบอส → ไม่ติด) */
+ const sepMin = ts * 2.4; /* ระยะ home ขั้นต่ำระหว่างบอท (หลบกันเอง) */
+ const outBots = [];
+ const alive = [];
+ s.bots.forEach((b, id) => {
+ if (b.eliminated) { outBots.push({ id, cx: b.cx, cy: b.cy, score: b.score, dmg: b.dmg, balloons: b.balloons, elim: true }); return; } /* ตายแล้ว: emit elim:true ต่อ (กัน client พลาด packet = ค้างนิ่ง) */
+ alive.push(b);
+ /* หลบบอส: ดริฟต์ "home" ออกจากบอสที่เคลื่อนเข้าใกล้ (แทน clamp ตำแหน่ง = ไม่ติดนิ่งขอบบอส) */
+ const hx = b.homeCx - boss.cx, hy = b.homeCy - boss.cy;
+ const hd = Math.hypot(hx, hy) || 1e-6;
+ if (hd < fleeR) { b.homeCx += (hx / hd) * fleeSpd * dt; b.homeCy += (hy / hd) * fleeSpd * dt; }
+ 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 });
+ });
+ /* หลบกันเอง: ดัน home ที่ใกล้กันเกินให้แยกออก (รอบถัดไปบอทจะ wobble รอบ home ใหม่ → ไม่ซ้อนกัน) */
+ for (let i = 0; i < alive.length; i++) {
+ for (let j = i + 1; j < alive.length; j++) {
+ const a = alive[i], c = alive[j];
+ const dx = a.homeCx - c.homeCx, dy = a.homeCy - c.homeCy;
+ const d = Math.hypot(dx, dy) || 1e-6;
+ if (d < sepMin) { const p = (sepMin - d) * 0.5, nx = dx / d, ny = dy / d; a.homeCx += nx * p; a.homeCy += ny * p; c.homeCx -= nx * p; c.homeCy -= ny * p; }
+ }
+ }
+ alive.forEach((b) => { const cl = bbClampWorld(b.homeCx, b.homeCy, mw, mh); b.homeCx = cl.cx; b.homeCy = cl.cy; }); /* กัน home ดริฟต์หลุดขอบ */
+ 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_' (ตรงกับ 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 };
+}
+
+/* คอมโพเนนต์เดินได้ "หลัก" (BFS จาก spawn slot) — กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated walkable) ที่กำแพงล้อม
+ cache ต่อ space (invalidate เมื่อ mapId เปลี่ยน) */
+function lobbyMainCompServer(md, space) {
+ const mid = space && space.mapId;
+ if (space && space._lobbyMainComp && space._lobbyMainCompMap === mid) return space._lobbyMainComp;
+ const w = md.width || 20, h = md.height || 15;
+ const isW = (x, y) => x >= 0 && x < w && y >= 0 && y < h && !(md.objects && md.objects[y] && md.objects[y][x] === 1);
+ const slots = parseLobbyPlayerSpawnsServer(md).filter(Boolean);
+ let seed = null;
+ for (const s of slots) { const sx = Math.floor(s.x), sy = Math.floor(s.y); if (isW(sx, sy)) { seed = [sx, sy]; break; } }
+ if (!seed && md.spawn && isW(Math.floor(md.spawn.x), Math.floor(md.spawn.y))) seed = [Math.floor(md.spawn.x), Math.floor(md.spawn.y)];
+ if (!seed) { for (let y = 0; y < h && !seed; y++) for (let x = 0; x < w; x++) { if (isW(x, y)) { seed = [x, y]; break; } } }
+ const comp = new Set();
+ if (seed) {
+ comp.add(seed[0] + ',' + seed[1]);
+ const q = [seed];
+ while (q.length) { const [a, b] = q.shift(); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = a + dx, ny = b + dy, k = nx + ',' + ny; if (!comp.has(k) && isW(nx, ny)) { comp.add(k); q.push([nx, ny]); } } }
+ }
+ if (space) { space._lobbyMainComp = comp; space._lobbyMainCompMap = mid; }
+ return comp;
+}
+/* เดินบอท 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;
+ const mainComp = lobbyMainCompServer(md, space);
+ const inMain = (x, y) => !mainComp.size || mainComp.has(Math.floor(x) + ',' + Math.floor(y));
+ bots.forEach((b, id) => {
+ /* กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated) → ดึงกลับ spawn หลัก (main component) */
+ if (!inMain(b.x, b.y)) {
+ const slot = parseInt(id.slice(LOBBY_BOT_PREFIX.length), 10);
+ const sp = pickLobbyBotSpawnServer(md, space, slot);
+ if (inMain(sp.x, sp.y)) { b.x = sp.x + 0.5; b.y = sp.y + 0.5; }
+ else { const first = mainComp.values().next().value; if (first) { const [mx, my] = first.split(',').map(Number); b.x = mx + 0.5; b.y = my + 0.5; } }
+ b.wTX = null; b.wTY = null; b.retargetAt = 0; b.pauseUntil = 0;
+ }
+ 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' ? 'หมดเวลา — จบมินิเกม'
+ : (reason === 'all_dead' ? 'ตกขอบหมดแล้ว — จบมินิเกม' : 'จบมินิเกม');
+ 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;
+ }
+
+ /* ตายหมดแล้ว (all_dead) — อยู่ในช่วง grace: หยุดเกม + ให้เวลา client วาดจอ result-gameover → GCM
+ ก่อน server ดึงกลับ LobbyB. เดิม all_dead จบ+return ทันที (0 grace) = client ยังไม่ทันวาด game over
+ ก็โดนดึงกลับ = "ตายหมดแต่ไม่ขึ้น game over". ใช้ grace เท่ากับทางชนะ (GAUNTLET_FINISH_WALK_MS) */
+ if (gr.gameoverAt != null) {
+ if (gr.obstacles && gr.obstacles.length) gr.obstacles = [];
+ if (Date.now() >= gr.gameoverAt) {
+ endGauntletGame(sid, space, 'all_dead');
+ return;
+ }
+ /* ไม่ emit sync ระหว่าง grace — ปล่อย client คุม overlay result เอง (เหมือน finish grace) */
+ return;
+ }
+
+ /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client
+ เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */
+ if (gr.finishPhase) {
+ if (gr.obstacles.length) { gr.obstacles = []; }
+ /* ตายหมดตั้งแต่ก่อน/ระหว่าง finish-walk grace → เข้า grace game over (แทน result-complete จากทางเส้นชัย)
+ (race: client ตัวหนึ่ง scroll ถึงเส้นชัยก่อน → set finishPhase → early-return ข้ามเช็ค all_dead ท้าย tick) */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ตายหมด (ระหว่าง finish-phase) → grace game over → กลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ return;
+ }
+ 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);
+ });
+ }
+
+ /* ผู้เล่น (คนจริง) ตกขอบหมด → เข้า grace game over (ไม่รอหมดเวลา) แล้วค่อย return LobbyB
+ ส่ง sync ครั้งสุดท้าย (ทุกคน eliminated) ให้ client ทริก overlay result-gameover ก่อนหมด grace */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ผู้เล่นตกขอบหมด → grace game over แล้วกลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ emitGauntletSync(sid, space);
+ return;
+ }
+
+ emitGauntletSync(sid, space);
+}
+
+/** MG2 crown/gauntlet: คนจริงที่ "ยังเล่นอยู่" (ไม่นับผู้ถูกแบน/ผู้ชม) ตกขอบหมด = จบเกม all_dead
+ บอทอยู่ใน gr.bots ไม่ใช่ space.peers → ไม่ถูกนับอยู่แล้ว (server ตัดสินจากคนจริงเท่านั้น)
+ - นับเฉพาะ crown map · ต้องมีคนเล่นจริง ≥1 คน (กันจบทันทีตอน 0 คน) */
+function gauntletAllHumansEliminated(space) {
+ if (!isGauntletCrownHeistMapBySpace(space) || !space.peers || space.peers.size === 0) return false;
+ let active = 0;
+ let alive = 0;
+ space.peers.forEach((p) => {
+ if (!p || p.bannedSpectator) return; /* ผู้ถูกแบน = เข้ามาดู ไม่ได้เล่น → ไม่บล็อกการจบเกม */
+ active++;
+ if (!p.gauntletEliminated) alive++;
+ });
+ return active > 0 && alive === 0;
+}
+
+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: clampTroublesomeOfferSec(runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) });
+}
+
+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) {
+ const rollMaxMs = Math.max(1000, Math.round(clampTroublesomeRollMaxSec(runtimeGameTiming && runtimeGameTiming.troublesomeRollMaxSec) * 1000));
+ space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), rollMaxMs);
+ }
+}
+
+/**
+ * ห้องทดสอบเอดิเตอร์ / 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) {
+ /* rejoin whitelist: เช็ค playerKey เป็นหลัก (กันเคส 2 คนชื่อซ้ำ — ลบ nick คนนึงตอนออก ทำให้อีกคน rejoin ไม่ได้)
+ fallback เป็นชื่อ (คนที่ไม่มี playerKey / ความเข้ากันได้เดิม) */
+ const jk = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? ('k:' + playerKey) : '';
+ const wlKeys = space.caseParticipantKeys;
+ const wlNicks = space.caseParticipantNicknames;
+ const okByKey = !!(jk && wlKeys && wlKeys.size > 0 && wlKeys.has(jk));
+ const okByNick = !!(joinNickNorm && wlNicks && wlNicks.size > 0 && wlNicks.has(joinNickNorm));
+ if (!okByKey && !okByNick) {
+ 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 }); });
+ }
+ /* MG3–MG7 pending takeover (คนหลุดช่วง how-to/นับถอยหลัง — ยังไม่มี session live): กลับมาทันก่อนเกมเริ่ม
+ → ล้าง pending ที่ playerKey ตรง กัน drainPendingMissionTakeovers seed เป็นบอทซ้ำตอน startXxxGame
+ ROOT CAUSE "บอทเข้าไปเล่นด้วย MG3/5/7": rejoin เดิมถอนบอทเฉพาะ session ที่ live แล้ว (บล็อกข้างบน)
+ แต่ shell (MG3-7) สร้าง session ตอน 3-2-1 จบ → ตอน rejoin ยังไม่มี session = ไม่มีอะไรถอน +
+ pending ค้าง → seed เป็น "บอทผี" คู่กับคนจริงที่ rejoin. emit user-left(id เก่า) ลบ avatar บอท placeholder */
+ if (Array.isArray(space.pendingMissionTakeovers) && space.pendingMissionTakeovers.length) {
+ const cancelled = space.pendingMissionTakeovers.filter((r) => r && r.playerKey && r.playerKey === joinPlayerKey);
+ if (cancelled.length) {
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => !(r && r.playerKey && r.playerKey === joinPlayerKey));
+ cancelled.forEach((r) => { if (r.id) io.to(spaceId).emit('user-left', { id: r.id }); });
+ glog(spaceId, space, 'join', '[TC173] rejoin ก่อนเกมเริ่ม → ยกเลิก pending bot takeover ' + cancelled.length + ' (กันบอทผี MG3/5/7 · pass)', nickname);
+ }
+ }
+ }
+ /* 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,
+ netReady: false, /* เพิ่ง join → กำลังโหลด จนกว่า client ส่ง client-ready (badge ให้คนอื่นเห็นว่า "กำลังโหลด" ไม่ใช่ค้าง) */
+ };
+ 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),
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ botSlotCount: effectiveBotSlotCount(space),
+ lobbyBotThemes: syncLobbyBotThemeSlots(space),
+ joinLocked: !!space.joinLocked,
+ missionLiveBeginsAt: space.missionLiveBeginsAt || null,
+ missionLive: isMinigameLiveForRejoinResume(space), /* rejoin กลางเกม live → client ข้าม how-to เข้าเล่นต่อ */
+ };
+ 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', (data) => {
+ 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;
+ /* ใช้เฟสที่ผู้เล่นเห็นจริงตอนกด (client ส่งมา) → บล็อกลงตรงจุดที่เห็นแกว่ง ไม่เยื้องตาม half-RTT/latency.
+ sanity bound กว้าง (STACK_DROP_PHASE_TOL = 2π) เหลือไว้กันค่า garbage เท่านั้น (ดู const) */
+ let dropPhase = s.phase;
+ const cph = data && Number(data.phase);
+ if (Number.isFinite(cph) && Math.abs(cph - s.phase) <= STACK_DROP_PHASE_TOL) dropPhase = cph;
+ const hit = stackComputeDropServer(s, md, dropPhase);
+ stackApplyHitServer(s, hit, { id: entry.id });
+ const peer = space.peers.get(socket.id);
+ const theme = stackHumanThemeServer(peer) || (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: dropPhase,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = Date.now() + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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 }] });
+ }
+ });
+
+ /* MG5 — ผู้เล่นสลับแท็บ (hidden): browser throttle setInterval เหลือ ~1Hz → emit move ~1/วิ = คนอื่นเห็นตกเป็นก้อน.
+ แก้: client แจ้ง hidden พร้อม state ปัจจุบัน → server จำลองการตก (jsPhysicsIntegrate) แทน ใน stepJumpSurviveBots
+ แล้ว broadcast ผ่าน jump-survive-bot-move (full rate) → คนอื่นเห็นลื่น. visible → hidden:false → client คุมเอง. */
+ socket.on('jump-survive-hidden', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !data) return;
+ const s = space.jumpSurviveSession;
+ if (!s || !s.active || s.ended) return;
+ if (!s.players[socket.id]) s.players[socket.id] = {};
+ const st = s.players[socket.id];
+ if (data.hidden) {
+ if (st.eliminated) return;
+ st.hidden = true;
+ st.x = Number.isFinite(data.x) ? data.x : (Number.isFinite(st.x) ? st.x : 1);
+ st.y = Number.isFinite(data.y) ? data.y : (Number.isFinite(st.y) ? st.y : 1);
+ st.vy = Number.isFinite(data.vy) ? data.vy : 0;
+ st.onGround = !!data.onGround;
+ st.dir = data.direction || st.dir || 'down';
+ } else {
+ st.hidden = false;
+ }
+ });
+
+ /** เปลี่ยนชื่อที่แสดงใน 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) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK เริ่มสืบสวน — รอผู้เล่นกลับเข้า LobbyB ครบ (' + waitInfo.present + '/' + waitInfo.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({
+ ok: false,
+ waitingPlayers: true,
+ present: waitInfo.present,
+ total: waitInfo.total,
+ error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')',
+ });
+ }
+ /* TC156: gate จนกว่าทุกคนอ่าน story (cutscene) ครบ — เฉพาะรอบแรก (storyReadComplete เป็น true หลังครบ/รอบถัดไป) */
+ if (!space.storyReadComplete) {
+ const stRead = detectiveStoryReadStatus(space);
+ if (!stRead.allRead) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'story-gate', '[TC156] BLOCK เริ่มสืบสวน — รอทุกคนอ่าน story ครบ (' + stRead.present + '/' + stRead.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingStory: true, present: stRead.present, total: stRead.total, error: 'รอผู้เล่นอ่านเนื้อเรื่องให้ครบก่อน (' + stRead.present + '/' + stRead.total + ')' });
+ }
+ space.storyReadComplete = true;
+ }
+ 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' });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'vote-gate', '[TC157] เริ่ม vote ban (ผู้เล่นเข้า LobbyB ครบ ' + waitInfo.present + '/' + waitInfo.total + ') — มินิเกมจะเริ่มหลังโหวตจบเท่านั้น (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ 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);
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ ' + waitInfo.total + ' คน · ผู้ต้องสงสัย #' + (selectedIndex + 1), (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ reply(started);
+ });
+
+ socket.on('detective-minigame-finished', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) {
+ return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ }
+ /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
+ const finPeer = space.peers.get(socket.id);
+ if (finPeer && data && typeof data === 'object') {
+ /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
+ หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */
+ if (Number.isFinite(Number(data.score))) {
+ let rs = Math.max(0, Math.floor(Number(data.score)));
+ if (rs > REPORTED_MINI_SCORE_MAX) {
+ console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX);
+ rs = REPORTED_MINI_SCORE_MAX;
+ }
+ finPeer.reportedMiniScore = rs;
+ }
+ if (data.survived === false) finPeer.reportedMiniSurvived = false;
+ }
+ if (!space.detectiveMinigameActive) {
+ if (serverMapIsPostCaseLobbyB(space)) {
+ const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
+ return reply({
+ ok: true,
+ alreadyOnLobbyB: true,
+ suspectIndex: grant.suspectIdx,
+ awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
+ awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
+ grade: grant.grade || null,
+ freeEvidenceCount: grant.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ }
+ return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
+ }
+ /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก"
+ จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด
+ ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */
+ const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) {
+ if (finPeer) finPeer.detectiveFinished = true;
+ const allDone = [...space.peers.values()].every((p) =>
+ p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished
+ );
+ if (!allDone) {
+ /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */
+ return reply({ ok: true, holdInScene: true });
+ }
+ /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */
+ }
+ // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
+ returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
+ const g = space.lastEvidenceGrant || {};
+ reply({
+ ok: true,
+ suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
+ awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
+ awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
+ grade: g.grade || null,
+ freeEvidenceCount: g.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ /**
+ * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที
+ * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)
+ */
+ socket.on('detective-debug-award-evidence', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ const idx = Math.floor(Number(data && data.suspectIndex));
+ if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' });
+ const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C');
+ if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' });
+ emitLobbyEvidenceSync(sid, space);
+ reply({
+ ok: true,
+ suspectIndex: idx,
+ awardedCard: card,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย)
+ socket.on('suspect-accuse-open', (_data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
+ const prog = playerSuspectProgressFromEvidence(space, socket.id);
+ if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
+ /* ชี้ตัวคนร้าย = เข้าพิจารณาคดี → ต้องรอผู้เล่นในคดีกลับเข้า LobbyB ครบก่อน (กันคนตกรอบโหวต) */
+ const accuseWait = detectiveLobbyBMissingParticipants(space);
+ if (accuseWait.missing > 0) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK ชี้ตัวคนร้าย — รอผู้เล่นเข้า LobbyB ครบ (' + accuseWait.present + '/' + accuseWait.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingPlayers: true, present: accuseWait.present, total: accuseWait.total, error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + accuseWait.present + '/' + accuseWait.total + ')' });
+ }
+ 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;
+ space.trialVoteAt = space.trialVoteAt || {};
+ space.trialVoteAt[socket.id] = Date.now(); /* [achv c4] เวลาโหวต → เช็ค "10 วิสุดท้าย" */
+ emitTrialVoteUpdate(sid, space);
+ const totalTV = trialEligibleVoterTotal(space);
+ const votedNow = Object.keys(space.trialVotes).length;
+ try { glog(sid, space, 'trial-vote', '[TC180] ยืนยันโหวต ' + votedNow + '/' + totalTV + (votedNow >= totalTV && totalTV > 0 ? ' — ครบ → reveal (pass)' : ' — รออีก')); } catch (e) { /* ignore */ }
+ if (votedNow >= 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)) {
+ /* ล็อก: host กำลังเปิด wizard เลือกระดับ/คดี → ปฏิเสธ set-ready (กัน client หลุด flow ตอน host เลือกอยู่) */
+ if (space.preplayOpen && space.hostId !== socket.id) {
+ const p = space.peers.get(socket.id);
+ io.to(socket.id).emit('peer-ready', { id: socket.id, ready: !!(p && p.ready) }); /* ย้ำสถานะเดิมกลับให้ผู้ส่ง */
+ try { glog(sid, space, 'preplay-lock', '[TC154] REJECT set-ready ระหว่าง host เลือกคดี — feature ทำงาน (pass): client ยกเลิกพร้อมไม่ได้', (p && p.nickname) || String(socket.id).slice(0, 6)); } catch (e) { /* ignore */ }
+ break;
+ }
+ const p = space.peers.get(socket.id);
+ if (p) p.ready = !!ready;
+ io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
+ break;
+ }
+ }
+ });
+
+ /* LobbyA: host เปิด/ปิด wizard เลือกระดับ/คดี → เก็บ state ที่ server + relay ทั้งห้อง (ล็อกปุ่มพร้อมฝั่ง client) */
+ socket.on('lobby-preplay-open', ({ open } = {}) => {
+ for (const [sid, space] of spaces) {
+ if (space.peers.has(socket.id) && space.hostId === socket.id) {
+ space.preplayOpen = !!open;
+ io.to(sid).emit('lobby-preplay-open', { open: !!open });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'preplay-lock', '[TC154] ' + (open ? 'host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง' : 'host ปิด wizard → ปลดล็อก set-ready') + ' (' + space.peers.size + ' คนในห้อง)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ break;
+ }
+ }
+ });
+
+ /* TC156: client อ่าน story (cutscene) จบ → บันทึกว่าคนนี้พร้อม · ครบทุกคน → ปลดล็อกเลือกผู้ต้องสงสัย */
+ socket.on('story-read-done', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.storyReadKeys) space.storyReadKeys = new Set();
+ const already = space.storyReadKeys.has(key);
+ space.storyReadKeys.add(key);
+ /* อ่าน story จบ = โหลด LobbyB เสร็จแน่นอน → นับ lobbyb-loaded ด้วย (reliable กว่า event lobbyb-loaded เดี่ยว) */
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ space.lobbyBLoadedKeys.add(key);
+ const st = detectiveStoryReadStatus(space);
+ if (!already) { try { glog(sid, space, 'story-gate', '[TC156] อ่าน story จบ ' + st.present + '/' + st.total, nick); } catch (e) { /* ignore */ } }
+ if (st.allRead && !space.storyReadComplete) {
+ space.storyReadComplete = true;
+ if (space.storyReadTimer) { clearTimeout(space.storyReadTimer); space.storyReadTimer = null; }
+ try { glog(sid, space, 'story-gate', '[TC156] ทุกคนอ่าน story ครบ (' + st.total + ') → ปลดล็อกเลือกผู้ต้องสงสัย (pass)'); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveStoryReadPresence(sid, space);
+ /* piggyback เพิ่ม lobbyBLoadedKeys แล้ว → ต้อง broadcast lobbyb-presence ใหม่ด้วย
+ ไม่งั้น client ค้างโชว์ "รอผู้เล่น (2/3)" (ค่าเก่า) → ปุ่ม host ถูก disable ทั้งที่ครบแล้ว */
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉาก LobbyB เสร็จจริง → นับเป็น "present" ได้ (กัน host เริ่มก่อนคนโหลดเสร็จ = ค้างตอนจบ) */
+ socket.on('lobbyb-loaded', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ if (!space.lobbyBLoadedKeys.has(key)) {
+ space.lobbyBLoadedKeys.add(key);
+ const info = detectiveLobbyBMissingParticipants(space);
+ try { glog(sid, space, 'lobbyb-gate', '[TC144] โหลด LobbyB เสร็จ ' + info.present + '/' + info.total, nick); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉากเสร็จ (ทุกฉาก lobby/minigame) → เอา badge "กำลังโหลด" ออก (คนอื่นเห็นว่าพร้อมแล้ว) */
+ socket.on('client-ready', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ const p = space.peers.get(socket.id);
+ if (p && p.netReady !== true) {
+ p.netReady = true;
+ io.to(sid).emit('peer-net', { id: socket.id, nr: true });
+ }
+ 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) {
+ space.preplayOpen = false; /* host เริ่มเกมแล้ว → ปลดล็อก (client เปลี่ยนฉากอยู่แล้ว แต่เคลียร์ state กัน stuck) */
+ 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();
+ const caseKeyWl = new Set();
+ space.peers.forEach((p) => {
+ caseNickWl.add(normalizeLobbyNickname(p.nickname)); /* ชื่อ — ใช้ whitelist rejoin (คงเดิม) */
+ const k = detectiveParticipantKey(p);
+ if (k) caseKeyWl.add(k); /* playerKey — ใช้ presence/story/gate (กันชื่อซ้ำนับเป็นคนเดียว) */
+ });
+ space.caseParticipantNicknames = caseNickWl;
+ space.caseParticipantKeys = caseKeyWl;
+ /* TC156: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story (cutscene) ครบ — reset ที่ initial LobbyA→LobbyB เท่านั้น
+ (รอบ investigate ถัดไปไม่มี cutscene → storyReadComplete คงเป็น true) · safety timer กัน deadlock ถ้า client ไม่ emit */
+ space.storyReadKeys = new Set();
+ space.storyReadComplete = false;
+ space.lobbyBLoadedKeys = new Set(); /* เริ่มคดี → ทุกคนต้องส่ง lobbyb-loaded ใหม่ ก่อนนับ present */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock */
+ if (space.storyReadTimer) clearTimeout(space.storyReadTimer);
+ space.storyReadTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (sp && !sp.storyReadComplete) {
+ sp.storyReadComplete = true;
+ try { glog(sid, sp, 'story-gate', '[TC156] safety-timeout 90s → force ปลดล็อก (client บางคนไม่ส่ง story-read-done)'); } catch (e) { /* ignore */ }
+ try { emitDetectiveStoryReadPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 90000);
+ 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 ยังมีอยู่ — รวมช่วง how-to / pre-run hold (crownRunHeld) ด้วย เหมือน MG1
+ (เดิมข้าม crownRunHeld → host หลุดตอน how-to = avatar หายไปเลย ไม่มีบอทรับช่วง; ตอนนี้แปลงเป็นบอททันที
+ บอทจะค้างในเลนตอน how-to แล้ววิ่งเองเมื่อเกมเริ่ม — emitGauntletSync ส่งบอท takeover ระหว่าง hold อยู่แล้ว) */
+ if (grT && md9b && md9b.gameType === 'gauntlet') {
+ 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 */ }
+ /* MG3–MG7 หลุดช่วง how-to / นับถอยหลัง (ยังไม่มี session live) → เก็บ pending takeover ไว้
+ แล้ว seed เป็นบอทตอนเกมเริ่ม + แจ้ง client เก็บ avatar (*-player-to-bot) เหมือน MG1/MG2
+ (เดิม: ไม่มี session = ไม่ takeover = avatar หายเลยตอน how-to) */
+ let shellTakeoverEvent = null;
+ try {
+ if (!mg1Takeover && !mg2Takeover && !mg3Takeover && !mg4Takeover && !mg5Takeover && !mg6Takeover && !mg7Takeover && space.detectiveMinigameActive) {
+ const gtShell = missionShellGameTypeForSpace(space);
+ const liveActive = !!(
+ (gtShell === 'space_shooter' && space.spaceShooterSession && space.spaceShooterSession.active) ||
+ (gtShell === 'jump_survive' && space.jumpSurviveSession && space.jumpSurviveSession.active) ||
+ (gtShell === 'balloon_boss' && space.balloonBossSession && space.balloonBossSession.active) ||
+ (gtShell === 'quiz_carry' && space.quizCarrySession && space.quizCarrySession.active) ||
+ (gtShell === 'stack' && space.stackSession && space.stackSession.active)
+ );
+ if (gtShell && !liveActive) {
+ recordPendingMissionTakeover(space, gtShell, socket.id, leftPeerInfo, leftPlayerKey, leftNick, leftWasEliminated);
+ shellTakeoverEvent =
+ gtShell === 'space_shooter' ? 'space-shooter-player-to-bot' :
+ gtShell === 'jump_survive' ? 'jump-survive-player-to-bot' :
+ gtShell === 'balloon_boss' ? 'balloon-boss-player-to-bot' :
+ gtShell === 'quiz_carry' ? 'quiz-carry-player-to-bot' :
+ gtShell === 'stack' ? 'stack-player-to-bot' : null;
+ }
+ }
+ } 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.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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.pendingMissionTakeovers = []; /* ห้องว่าง → ล้าง pending takeover ค้าง (กัน seed บอทผีรอบหน้า) */
+ 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) {
+ /* host หลุดกลางเปิด wizard เลือกคดี → ปลดล็อกปุ่มพร้อมให้คนที่เหลือ (กัน stuck lock) */
+ if (space.preplayOpen) { space.preplayOpen = false; io.to(sid).emit('lobby-preplay-open', { open: false }); try { glog(sid, space, 'preplay-lock', '[TC154] host หลุดกลางเปิด wizard → auto-ปลดล็อก set-ready (กัน stuck)', leftNick); } catch (e) { /* ignore */ } }
+ 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 if (shellTakeoverEvent) {
+ /* how-to/นับถอยหลัง: client เก็บ avatar + ติดป้าย (บอท) — บอท server จะ seed ตอนเกมเริ่ม (pendingMissionTakeovers) */
+ io.to(sid).emit(shellTakeoverEvent, { id: socket.id, nickname: leftNick });
+ glog(sid, space, 'leave', 'หลุดช่วง how-to → บอทรับช่วงตอนเกมเริ่ม · เหลือคน ' + 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 */ }
+ /* เอา participantKey ออกจาก roster gate ด้วย (playerKey — กัน gate รอคนที่ออกถาวร) */
+ try {
+ if (sp.caseParticipantKeys) {
+ if (leftPlayerKey && /^[a-zA-Z0-9_-]{8,128}$/.test(leftPlayerKey)) sp.caseParticipantKeys.delete('k:' + leftPlayerKey);
+ sp.caseParticipantKeys.delete('n:' + 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', (data) => {
+ 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;
+ const changed = quizCarryServerTryInteract(sid, space, socket.id, false, data);
+ /* กดแล้วไม่เกิดอะไร (หยิบไม่ได้/ส่งไม่ได้) → ยืนยัน held จริงกลับไปให้ "คนกด" เพื่อแก้ optimistic prediction
+ ที่อาจค้างผิด (เช่น ทำนายว่าหยิบได้แต่ server ปฏิเสธเพราะคนอื่นถืออยู่) */
+ if (!changed) {
+ const hp = s.players[socket.id];
+ io.to(socket.id).emit('quiz-carry-held', { id: socket.id, held: hp ? (hp.held == null ? null : hp.held) : null });
+ }
+ });
+
+ /* ตอบเวลา 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));
\ No newline at end of file
diff --git a/www/html/Game/server.js.bak-20260706-032545 b/www/html/Game/server.js.bak-20260706-032545
new file mode 100644
index 0000000..8692097
--- /dev/null
+++ b/www/html/Game/server.js.bak-20260706-032545
@@ -0,0 +1,13264 @@
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { Server } = require('socket.io');
+
+const PORT = parseInt(process.env.PORT, 10) || 3001;
+/** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */
+const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0';
+const BASE_PATH = '/Game';
+const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
+const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
+const adminTokens = new Set();
+/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
+const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
+/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */
+const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
+/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */
+/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */
+const DETECTIVE_MINIGAME_POOL = [
+ { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' },
+ { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' },
+ { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' },
+ { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' },
+ { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' },
+ { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' },
+ { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' },
+];
+/** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */
+function normalizeForcedMinigameKeys(v) {
+ let arr;
+ if (Array.isArray(v)) arr = v;
+ else if (v != null && String(v).trim()) arr = [v, v, v];
+ else arr = [];
+ const one = (k) => {
+ const s = (k == null ? '' : String(k)).trim();
+ return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : '';
+ };
+ return [one(arr[0]), one(arr[1]), one(arr[2])];
+}
+const LOBBY_SLOT_TOTAL = 6;
+
+/* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้
+ ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */
+const LIVE_COUNTDOWN_MS = 3500;
+const LOBBY_THEME_COUNT = 8;
+const LOBBY_SKIN_COUNT = 3;
+
+/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
+function getTakenLobbyThemeIndices(space, excludePeerId) {
+ const taken = new Set();
+ if (!space) return taken;
+ space.peers.forEach((p, id) => {
+ if (excludePeerId && id === excludePeerId) return;
+ const ti = parseInt(p.lobbyColorThemeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ const bots = space.lobbyBotThemeBySlot;
+ if (bots && typeof bots === 'object') {
+ Object.keys(bots).forEach((k) => {
+ const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ }
+ return taken;
+}
+
+function pickFreeLobbyThemeIndex(space, excludePeerId) {
+ const taken = getTakenLobbyThemeIndices(space, excludePeerId);
+ for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
+ if (!taken.has(i)) return i;
+ }
+ const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
+ return ((n - 1) % LOBBY_THEME_COUNT) + 1;
+}
+
+function pickLobbySkinIndexForSlot(slotIndex) {
+ const s = Number(slotIndex);
+ if (!Number.isFinite(s)) return 1;
+ return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
+}
+
+/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น
+ 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;
+ /* เลือกผู้เล่น ≥ 4 คนตอนสร้างห้อง → ไม่เติมบอท (รอคนจริง) — เว้นแต่ host ตั้งบอทเองภายหลัง (n>0) */
+ if (n === 0 && maxP >= 4) return 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');
+const SOUND_SETTINGS_PATH = path.join(__dirname, 'data', 'sound-settings.json');
+const SOUND_SETTINGS_DEFAULT = { masterVol: 1.0, musicVol: 0.35, sfxVol: 0.75 };
+function loadSoundSettings() {
+ try {
+ if (fs.existsSync(SOUND_SETTINGS_PATH)) {
+ const j = JSON.parse(fs.readFileSync(SOUND_SETTINGS_PATH, 'utf8'));
+ const clamp = (v, d) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : d; };
+ return {
+ masterVol: clamp(j.masterVol, SOUND_SETTINGS_DEFAULT.masterVol),
+ musicVol: clamp(j.musicVol, SOUND_SETTINGS_DEFAULT.musicVol),
+ sfxVol: clamp(j.sfxVol, SOUND_SETTINGS_DEFAULT.sfxVol),
+ };
+ }
+ } catch (e) { console.error('loadSoundSettings', e.message); }
+ return Object.assign({}, SOUND_SETTINGS_DEFAULT);
+}
+function saveSoundSettings(s) {
+ try { fs.writeFileSync(SOUND_SETTINGS_PATH, JSON.stringify(s, null, 2)); } catch (e) { console.error('saveSoundSettings', e.message); }
+}
+
+/* ===== 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 นี้
+ ตั้ง 22 (เดิม 10) — จาก log 6 คน join ใช้เวลา 10-16 วิ (โหลดหนัก) → grace 10 หมดก่อน = bot takeover ผิด → "ตัวละครซ้ำ 2 ตัว" (บอท+คนที่กลับมาช้า) + คนหลุด */
+const DISCONNECT_GRACE_SEC = 22;
+
+/** โหมดเทสต์ (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) ต่อช่อง 1–16 */
+ carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
+ /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
+ carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
+ /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
+ carryChoicePlaqueMapScale: 1.25,
+ /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */
+ carryWalkSpeedMultForMapId: '',
+ carryWalkSpeedMult: null,
+ /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */
+ quizRoundQuestionCount: 10,
+ questions: [],
+ carryQuestions: [],
+ battleQuizMcq: [],
+ /** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */
+ specialQuizSets: sanitizeSpecialQuizSets({}),
+ };
+}
+
+function defaultCarryMapPanelTheme() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+}
+
+function sanitizeCssColorToken(input, fallback) {
+ const t = String(input == null ? '' : input).trim().slice(0, 120);
+ if (!t) return fallback;
+ if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) {
+ const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, '');
+ if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim();
+ }
+ return fallback;
+}
+
+function sanitizeCarryMapPanelTheme(raw) {
+ const d = defaultCarryMapPanelTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ let qMin = Number(raw.questionFontMinPx);
+ let qMax = Number(raw.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const t = qMin;
+ qMin = qMax;
+ qMax = t;
+ }
+ return {
+ panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg),
+ panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder),
+ borderWidthPx: (() => {
+ let w = Number(raw.borderWidthPx);
+ if (!Number.isFinite(w)) w = d.borderWidthPx;
+ return Math.max(0, Math.min(12, Math.round(w)));
+ })(),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+}
+
+/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
+function defaultCarryChoicePlaqueTheme() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ };
+}
+
+function sanitizeCarryChoicePlaqueTheme(raw) {
+ const d = defaultCarryChoicePlaqueTheme();
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
+ fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
+ };
+}
+
+const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
+
+function defaultCarryChoicePlaqueThemes() {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme({}));
+ }
+ return out;
+}
+
+/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
+function sanitizeCarryChoicePlaqueThemes(raw) {
+ if (Array.isArray(raw) && raw.length > 0) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
+ }
+ return out;
+ }
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
+ const one = sanitizeCarryChoicePlaqueTheme(raw);
+ return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
+ }
+ return defaultCarryChoicePlaqueThemes();
+}
+
+function sanitizeCarryChoiceImageUrl(input) {
+ const s = String(input == null ? '' : input).trim().slice(0, 512);
+ if (!s) return '';
+ if (/[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const u = new URL(s);
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+}
+
+function defaultCarryEmbedCountdownTheme() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+}
+
+function sanitizeCarryEmbedCountdownTheme(raw) {
+ const d = defaultCarryEmbedCountdownTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ return {
+ overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
+ innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+}
+
+/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
+const QUIZ_BATTLE_CATEGORY_IDS = new Set([
+ 'topic1', 'topic2', 'topic3', 'topic4', 'topic5',
+ 'topic6', 'topic7', 'topic8', 'topic9', 'topic10',
+]);
+
+/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
+function sanitizeBattleQuizMcq(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ let categoryId = String(q.categoryId || '').trim();
+ if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
+ categoryId = 'topic1';
+ }
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ const rawChoices = Array.isArray(q.choices) ? q.choices : [];
+ const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
+ if (choices.length !== 3 || !choices.every(Boolean)) continue;
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ out.push({ categoryId, text, choices, correctIndex: ci });
+ if (out.length >= 200) break;
+ }
+ return out;
+}
+
+/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
+function sanitizeCarryQuestions(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ let choices = Array.isArray(q.choices)
+ ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
+ : [];
+ if (choices.length < 2) continue;
+ if (choices.length > 16) choices = choices.slice(0, 16);
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
+ const row = { text, choices, correctIndex: ci };
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) row.choiceImageUrls = urls;
+ }
+ out.push(row);
+ if (out.length >= 80) break;
+ }
+ return out;
+}
+
+function clampQuizMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(1000, Math.min(300000, Math.floor(v)));
+}
+
+function clampQuizBetweenMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(300000, Math.floor(v)));
+}
+
+function clampCarrySessionLength(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+}
+
+/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
+function clampQuizRoundQuestionCount(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) {
+ const d = Number(def);
+ if (!Number.isFinite(d)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(d)));
+ }
+ return Math.max(1, Math.min(50, Math.floor(v)));
+}
+
+function clampCarryChoicePlaqueMapScale(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
+}
+
+/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
+function sanitizeCarryWalkSpeedMultForMapId(raw) {
+ const t = String(raw == null ? '' : raw).trim().slice(0, 64);
+ if (!t) return '';
+ if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
+ return t;
+}
+
+/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
+function clampCarryWalkSpeedMultSetting(n) {
+ if (n === null || n === undefined || n === '') return null;
+ const v = Number(n);
+ if (Number.isNaN(v)) return null;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+}
+
+function mergeQuizCarryFromMirrorIfSet(base) {
+ if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
+ try {
+ if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
+ const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
+ const j = JSON.parse(raw);
+ if (!j || typeof j !== 'object') return base;
+ if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
+ base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
+ }
+ if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
+ base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
+ }
+ if (j.carryReadMs != null) {
+ base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
+ }
+ if (j.carryAnswerMs != null) {
+ base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
+ }
+ if (j.carrySessionLength != null) {
+ base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
+ }
+ if (j.carryWalkSpeedMultForMapId != null) {
+ base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
+ }
+ if (j.carryWalkSpeedMult != null) {
+ const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
+ if (wm != null) base.carryWalkSpeedMult = wm;
+ }
+ if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
+ base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
+ }
+ if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ }
+ if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
+ base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
+ }
+ if (j.quizRoundQuestionCount != null) {
+ base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
+ }
+ return base;
+ } catch (e) {
+ console.error('mergeQuizCarryFromMirrorIfSet', e.message);
+ return base;
+ }
+}
+
+/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
+const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
+const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
+const EVIDENCE_CASE_COUNT = 15;
+
+/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
+const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
+const CASE_MEDIA_COUNT = 15;
+/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
+const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
+
+function caseMediaDefault(n) {
+ const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png');
+ const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png');
+ return {
+ name: 'คดีที่ ' + n,
+ art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
+ detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
+ story: [
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
+ ],
+ suspects,
+ culprits,
+ };
+}
+
+function sanitizeCaseMediaUrlList(arr, fallbackArr) {
+ const fb = Array.isArray(fallbackArr) ? fallbackArr : [];
+ const src = Array.isArray(arr) ? arr : [];
+ return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || ''));
+}
+
+/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
+function sanitizeCaseMediaUrl(v, fallback) {
+ if (typeof v !== 'string') return fallback;
+ const s = v.trim();
+ if (!s) return fallback;
+ if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
+ return s;
+}
+
+function sanitizeCaseMedia(obj) {
+ const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
+ const cases = {};
+ for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
+ const d = caseMediaDefault(n);
+ const c = src[n] || src[String(n)] || {};
+ const story = Array.isArray(c.story) ? c.story : [];
+ cases[n] = {
+ name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
+ art: sanitizeCaseMediaUrl(c.art, d.art),
+ detail: sanitizeCaseMediaUrl(c.detail, d.detail),
+ story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
+ suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
+ culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
+ };
+ }
+ return { cases };
+}
+
+function loadCaseMedia() {
+ try {
+ if (fs.existsSync(CASE_MEDIA_PATH)) {
+ return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
+ }
+ } catch (e) { /* ignore — คืนค่า default */ }
+ return sanitizeCaseMedia({});
+}
+
+function saveCaseMedia(d) {
+ try {
+ const clean = sanitizeCaseMedia(d);
+ fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
+ return { ok: true, cases: clean.cases };
+ } catch (e) {
+ let err = 'บันทึกไม่ได้';
+ if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
+ else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
+ return { ok: false, error: err };
+ }
+}
+
+/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
+function listCaseMediaImages() {
+ const out = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] };
+ ['case', 'cutscene', 'suspect-pick', 'culprit-prison'].forEach((sub) => {
+ try {
+ const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
+ out[sub] = fs.readdirSync(p)
+ .filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ });
+ return out;
+}
+const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
+/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
+const EVIDENCE_RARITY_RANGES = [
+ { rarity: 'common', min: 1, max: 22, stars: 1 },
+ { rarity: 'rare', min: 23, max: 44, stars: 2 },
+ { rarity: 'legendary', min: 45, max: 54, stars: 3 },
+];
+
+function evidenceRarityForNum(n) {
+ for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
+ return EVIDENCE_RARITY_RANGES[0];
+}
+
+function evidenceCardImageDefault(suspectIdx, cardIdx) {
+ return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
+}
+
+/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
+let _evidencePoolCache = null;
+let _evidencePoolCacheAt = 0;
+function evidenceImagePools() {
+ const now = Date.now();
+ if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
+ const pools = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const buckets = { common: [], rare: [], legendary: [] };
+ try {
+ const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
+ fs.readdirSync(p).forEach((f) => {
+ const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
+ if (!m) return;
+ const num = parseInt(m[1], 10);
+ const rr = evidenceRarityForNum(num);
+ buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
+ });
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
+ pools[dir] = buckets;
+ });
+ _evidencePoolCache = pools;
+ _evidencePoolCacheAt = now;
+ return pools;
+}
+
+function evidencePoolCounts() {
+ const pools = evidenceImagePools();
+ const out = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const b = pools[dir] || { common: [], rare: [], legendary: [] };
+ out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
+ });
+ return out;
+}
+
+/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
+function rollEvidenceRarityKey(grade) {
+ const r = Math.random();
+ if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
+ if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
+ return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
+}
+
+/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
+function evidenceImageDirForSuspect(space, suspectIndex) {
+ const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
+ try {
+ const cid = String((space && space.caseId) || '1');
+ const cfg = loadEvidenceCards();
+ const cse = cfg.cases && cfg.cases[cid];
+ const s = cse && cse.suspects && cse.suspects[suspectIndex];
+ if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
+ } catch (e) { /* ใช้ default */ }
+ return def;
+}
+
+/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
+function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
+ const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
+ const pools = evidenceImagePools();
+ const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
+ let want = rollEvidenceRarityKey(grade || 'C');
+ let pool = buckets[want];
+ if (!pool || !pool.length) {
+ const tryOrder = ['common', 'rare', 'legendary'];
+ for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
+ }
+ if (!pool || !pool.length) return null;
+ const pick = pool[Math.floor(Math.random() * pool.length)];
+ return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
+}
+
+function evidenceGradeFromPct(avgPct) {
+ return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
+}
+
+/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
+function evidencePlayerRawScore(space, p, gameType) {
+ switch (gameType) {
+ case 'gauntlet':
+ case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
+ case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
+ case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
+ default:
+ return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
+ }
+}
+
+/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
+function computeRunGradeForEvidence(space) {
+ try {
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gameType = md && md.gameType;
+ /* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C
+ quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */
+ if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
+ const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
+ const ids = Object.keys(space.quizSession.players);
+ if (total > 0 && ids.length) {
+ const avgPct = ids.reduce((s, id) => {
+ const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
+ return s + Math.min(100, (sc / total) * 100);
+ }, 0) / ids.length;
+ return evidenceGradeFromPct(avgPct);
+ }
+ }
+ /* 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); }
+ });
+}
+
+/* streak achievements (a6/d4/e1) — POST action=streak {playerKey,id,event:'hit'|'miss'}
+ PHP นับต่อ/รีเซ็ตเอง + set achievement = best streak (ไม่ลดลง) */
+function submitAchievementStreak(items) {
+ const secret = gameAwardSecret();
+ if (!secret || !Array.isArray(items) || !items.length) return;
+ items.forEach((it) => {
+ if (!it || !it.playerKey || !it.id || (it.event !== 'hit' && it.event !== 'miss')) return;
+ try {
+ fetch(ACHIEVEMENTS_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'streak', secret, playerKey: it.playerKey, id: it.id, event: it.event }),
+ }).then((r) => r.json()).then((d) => {
+ if (!d || !d.ok) console.error('[achv] streak failed', it.id, d);
+ }).catch((e) => console.error('[achv] streak error', e && e.message));
+ } catch (e) { console.error('[achv] streak 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_JUMP_HEIGHT_MULT = 0.52; /* MG2 ความสูงกระโดด = ×tile (จุดสูงสุดของส่วนโค้ง sin) — ปรับได้ใน Admin */
+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,
+ gauntletJumpHeightMult: GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT,
+ gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
+ troublesomeOfferSec: 15,
+ ...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,
+ troublesomeRollMaxSec: 8,
+ /** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */
+ specialQuizIconExpireSec: 10,
+ /** เวลาโหวตเลือกผู้เล่น (การ์ด Silence/Ban) วินาที */
+ pickVoteSec: 25,
+ /** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */
+ trialVoteSec: 30,
+ /** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
+ trialResultRevealMs: 7000,
+ };
+}
+
+/** อ่านเวลา 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; ค่าอื่น = 10–7200 */
+function clampJumpSurviveMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */
+function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = [];
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : '';
+ const out = [];
+ for (let i = 0; i < 3; i++) {
+ const o = arr[i];
+ const obj = o && typeof o === 'object' ? o : {};
+ let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : '');
+ if (i === 0 && !url && legacy) url = legacy;
+ let ww = Number(obj.w);
+ let hh = Number(obj.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ out.push({ url, w: ww, h: hh });
+ }
+ return out;
+}
+
+function clampSpaceShooterMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */
+function normalizeSpaceShooterShipImageUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < 6; i++) {
+ out.push(sanitizeGauntletAssetUrl(arr[i]));
+ }
+ return out;
+}
+
+/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */
+function normalizeSpaceShooterAsteroidSpriteUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < arr.length && out.length < 32; i++) {
+ const u = sanitizeGauntletAssetUrl(arr[i]);
+ if (u) out.push(u);
+ }
+ return out;
+}
+
+function clampSpaceShooterAsteroidExplodeFrameMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return 70;
+ return Math.max(30, Math.min(500, Math.round(v)));
+}
+
+const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040;
+function clampSpaceShooterAsteroidIntervalMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
+ return Math.max(200, Math.min(10000, Math.floor(v)));
+}
+
+/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */
+function normalizeSpaceShooterShipDamageOverlayUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = ['', '', ''];
+ for (let i = 0; i < 3; i++) {
+ out[i] = sanitizeGauntletAssetUrl(arr[i]);
+ }
+ return out;
+}
+
+function clampGauntletTickMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
+ return Math.max(80, Math.min(800, Math.floor(v)));
+}
+
+function clampGauntletJumpTicks(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
+ return Math.max(1, Math.min(40, Math.floor(v)));
+}
+function clampGauntletJumpHeightMult(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT;
+ return Math.max(0.2, Math.min(1.5, v));
+}
+/** เวลารับข้อเสนอ "ตัวป่วน" (วินาที) — admin ปรับได้ใน game-timing; 5–120, default 15 */
+function clampTroublesomeOfferSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 15;
+ return Math.max(5, Math.min(120, Math.floor(v)));
+}
+/* เวลาสูงสุด (วินาที) ที่ระบบรอให้ทุกคนรายงานพร้อม ก่อน "บังคับสุ่ม" ผู้รับบทตัวป่วน (เดิม hardcode 8000ms) */
+function clampTroublesomeRollMaxSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 8;
+ return Math.max(1, Math.min(30, 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(j.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(j, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(j.troublesomeOfferSec) : 15,
+ ...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,
+ troublesomeRollMaxSec: clampTroublesomeRollMaxSec(j.troublesomeRollMaxSec),
+ 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,
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
+ : 7000,
+ };
+ }
+ } 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(d.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(d.troublesomeOfferSec) : ((runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) || 15),
+ ...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,
+ troublesomeRollMaxSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeRollMaxSec')
+ ? clampTroublesomeRollMaxSec(d.troublesomeRollMaxSec)
+ : clampTroublesomeRollMaxSec(prev.troublesomeRollMaxSec),
+ 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),
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
+ : (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
+ /* 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,
+ nr: p.netReady !== false, /* net-ready: false = เพิ่ง join กำลังโหลด (client โชว์ badge) */
+ };
+ 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) {
+ const payload = {
+ readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady),
+ expectedActive: (space && space.minigameExpectedActiveCount) || 0,
+ peers: buildPeersSnapForSpace(space),
+ };
+ /* MG2: ส่งตำแหน่ง spawn ของบอทมาด้วยตอน pregame → client วางบอทที่จุดเกิด (เดิมไม่ส่ง = บอทค้าง (1,1) มุมบน) */
+ const gr = space && space.gauntletRun;
+ if (gr && gr.bots && gr.bots.size) {
+ const bots = [];
+ gr.bots.forEach((b, id) => {
+ if (!b) return;
+ bots.push({ id, x: b.x, y: b.y, direction: b.dir || 'right', gauntletScore: b.gauntletScore || 0, gauntletEliminated: !!b.gauntletEliminated });
+ });
+ if (bots.length) payload.bots = bots;
+ }
+ return payload;
+}
+
+/* เช็คว่าพร้อมเริ่มมินิเกม 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;
+}
+
+/* key ผู้เล่นในคดี — ใช้ playerKey (คงที่ต่อ browser ข้าม reconnect) กันเคส "2 คนตั้งชื่อซ้ำ = ถูกนับเป็นคนเดียว"
+ fallback เป็นชื่อ (n:) ถ้า peer ไม่มี playerKey ที่ valid */
+function detectiveParticipantKey(p) {
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ const n = normalizeLobbyNickname(p.nickname || '');
+ return n ? 'n:' + n : '';
+}
+
+/* นับผู้เล่นในคดี (caseParticipantKeys = playerKey) ที่กลับเข้า LobbyB + โหลดเสร็จ เทียบกับทั้งหมด
+ ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */
+function detectiveLobbyBMissingParticipants(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 };
+ /* present = participantKey ที่ "มี peer" และ "โหลด LobbyB เสร็จจริง" (lobbyb-loaded)
+ — กันเคส peer มีอยู่ (socket re-join ช่วงเปลี่ยนหน้า) แต่ client ยังค้างฉากเกมเก่า = นับครบผิด
+ fallback: ก่อนมีใครส่ง lobbyb-loaded (loadedSet ว่าง) ใช้ peer-count (กัน deadlock ช่วงแรกสุด) */
+ const loadedSet = space.lobbyBLoadedKeys;
+ const useLoaded = !!(loadedSet && loadedSet.size > 0);
+ const present = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (!k || !roster.has(k)) return;
+ if (useLoaded) { if (loadedSet.has(k)) present.add(k); }
+ else present.add(k);
+ });
+ 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,
+ });
+}
+
+/* TC166 safety: กัน lobbyb-loaded gate deadlock — ถ้า 25s แล้วยังไม่ครบ ให้ force นับ present จาก peer ที่อยู่จริงใน LobbyB */
+function armLobbyBLoadedSafety(sid, space) {
+ if (!sid || !space) return;
+ if (space.lobbyBLoadedTimer) { clearTimeout(space.lobbyBLoadedTimer); space.lobbyBLoadedTimer = null; }
+ space.lobbyBLoadedTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (!sp || !serverMapIsPostCaseLobbyB(sp)) return;
+ const roster = sp.caseParticipantKeys;
+ if (!roster || roster.size === 0) return;
+ if (!sp.lobbyBLoadedKeys) sp.lobbyBLoadedKeys = new Set();
+ let added = 0;
+ sp.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k) && !sp.lobbyBLoadedKeys.has(k)) { sp.lobbyBLoadedKeys.add(k); added++; }
+ });
+ if (added) {
+ try { glog(sid, sp, 'lobbyb-gate', '[TC166] safety 25s → force นับ present จาก peer ที่อยู่จริง (+' + added + ')'); } catch (e) { /* ignore */ }
+ try { emitDetectiveLobbyBPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 25000);
+}
+
+/* TC156 story-gate: นับผู้เล่นในคดี "ที่อ่าน story (cutscene) จบแล้ว" เทียบกับคนที่อยู่ในห้องตอนนี้
+ allRead = ทุก roster-nick ที่ยัง present ได้ส่ง story-read-done แล้ว (คนที่ left ไม่นับ — presence gate คุมแยก) */
+function detectiveStoryReadStatus(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { present: 0, total: 0, allRead: true };
+ const read = space.storyReadKeys || new Set();
+ const presentRosterKeys = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k)) presentRosterKeys.add(k);
+ });
+ let readCount = 0;
+ presentRosterKeys.forEach((k) => { if (read.has(k)) readCount++; });
+ const allRead = !!space.storyReadComplete || (presentRosterKeys.size > 0 && readCount >= presentRosterKeys.size);
+ return { present: readCount, total: presentRosterKeys.size, allRead };
+}
+function emitDetectiveStoryReadPresence(sid, space) {
+ if (!sid || !space) return;
+ if (!serverMapIsPostCaseLobbyB(space)) return;
+ const st = detectiveStoryReadStatus(space);
+ io.to(sid).emit('detective-story-read-presence', { present: st.present, total: st.total, allRead: st.allRead });
+}
+
+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) || '',
+ };
+ });
+ // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ (รวม evidenceByNick fallback — กันหลักฐานคดีเก่า leak ข้ามคดี)
+ space.playerEvidence = {};
+ space.evidenceByNick = {};
+ space.caseMinigameSuspects = {}; /* [achv b2] มินิเกมที่รันในคดีนี้ (suspectIndex) — เช็ค "เล่นครบทุกรอบ" */
+ space.caseCardTargetedKeys = {}; /* [achv d2] playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ /* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
+ ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
+ space.suspectTeamProgress = [0, 0, 0];
+ // สุ่มคนร้ายตัวจริง 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 */
+/* คีย์เก็บหลักฐานต่อผู้เล่น — ใช้ playerKey (unique ต่อ browser) เป็นหลัก กัน "ชื่อซ้ำ"
+ (เช่น 2-3 คนชื่อ MONE) ใช้ evidence ก้อนเดียวกัน = ถูกแจกซ้ำ/เกิน. fallback ชื่อเฉพาะกรณีไม่มี key */
+function peerNickKey(space, playerId) {
+ if (!space || !playerId) return '';
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ if (!p.nickname) return '';
+ return 'n:' + 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;
+}
+
+/* [achv Phase A 2026-07-05] นับหลักฐานที่ผู้เล่นเก็บได้ → achievements
+ b4_data_miner(รวมทุกใบ ×100) · b1_evidence_collector(common ×20) · a2_sharp_eye(rare/Silver ×10) · b3_hidden_fragment(legendary ครั้งแรก)
+ เฉพาะผู้เล่นจริง (มี playerKey) — ข้ามบอท + เรียกจาก "จุดแจกจริง" 2 ที่เท่านั้น (ไม่รวม debug hotkey) */
+function trackEvidenceCollectAchv(space, playerId, card) {
+ if (!card || !card.rarity) return;
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ const pkey = p && typeof p.playerKey === 'string' ? p.playerKey : null;
+ if (!pkey) return;
+ const achv = [{ playerKey: pkey, id: 'b4_data_miner', inc: 1 }];
+ if (card.rarity === 'common') achv.push({ playerKey: pkey, id: 'b1_evidence_collector', inc: 1 });
+ else if (card.rarity === 'rare') achv.push({ playerKey: pkey, id: 'a2_sharp_eye', inc: 1 });
+ else if (card.rarity === 'legendary') achv.push({ playerKey: pkey, id: 'b3_hidden_fragment', inc: 1 });
+ submitAchievementProgress(achv);
+}
+
+function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
+ const out = {};
+ /* [achv b2] บันทึกว่า suspect นี้มีมินิเกมรันในคดีนี้ (ใช้เช็ค "เล่นมินิเกมครบทุกรอบ") */
+ if (suspectIndex >= 0 && suspectIndex <= 2) {
+ space.caseMinigameSuspects = space.caseMinigameSuspects || {};
+ space.caseMinigameSuspects[suspectIndex] = true;
+ }
+ const grantedKeys = new Set(); /* แจก 1 การ์ด/playerKey ต่อเกม — กัน "หลายแท็บ/ชื่อซ้ำ" key เดียวได้เกิน */
+ (playerIds || []).forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && grantedKeys.has(k)) return; /* key นี้ได้การ์ดรอบนี้แล้ว (evidence เก็บก้อนเดียวกันตาม key) */
+ const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
+ if (card != null) { out[pid] = card; if (k) grantedKeys.add(k); trackEvidenceCollectAchv(space, 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++) {
+ const freeKeys = new Set(); /* 1 ใบ/playerKey ต่อรอบ (กันชื่อซ้ำ/หลายแท็บได้เกิน) */
+ ids.forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && freeKeys.has(k)) return;
+ const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
+ if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; if (k) freeKeys.add(k); trackEvidenceCollectAchv(space, pid, extra); }
+ });
+ }
+ pend.consumed = true;
+ }
+ space.detectiveAwardDoneForRun = true;
+ space.lastDetectiveInvestigatedSuspect = suspectIdx;
+ space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
+ /* [TC182] verify: จำนวนใบต่อ playerKey ที่แจกรอบนี้ — ควรได้ 1 ใบ/key/เกม (กันชื่อซ้ำแจกเกิน) */
+ try {
+ const keyCounts = {};
+ Object.keys(awardedList).forEach((pid) => {
+ const k = peerNickKey(space, pid) || String(pid);
+ keyCounts[k] = (keyCounts[k] || 0) + (awardedList[pid] ? awardedList[pid].length : 0);
+ });
+ const vals = Object.values(keyCounts);
+ const over = vals.some((n) => n > 1);
+ glog(sid, space, 'evidence-grant', '[TC182] แจกหลักฐาน suspect#' + (suspectIdx + 1) + ' → ' + vals.length + ' key · ใบ/key=' + JSON.stringify(vals) + (over ? ' ⚠️เกิน' : ' (1/key · pass)'));
+ } catch (e) { /* ignore */ }
+ 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.lobbyBLoadedKeys = new Set(); /* ออกไปเล่นมินิเกม → ล้าง loaded; ตอนกลับ LobbyB ทุกคนต้องส่ง lobbyb-loaded ใหม่ */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock ถ้า signal ไม่ครบ */
+ 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;
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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;
+ /* #6: นับความคืบหน้าการสืบ (shared) +1 ต่อการเล่นมินิเกม 1 รอบของผู้ต้องสงสัยนั้น (สูงสุด 3) */
+ if (!Array.isArray(space.suspectTeamProgress)) space.suspectTeamProgress = [0, 0, 0];
+ if (suspectIdx >= 0 && suspectIdx <= 2) {
+ space.suspectTeamProgress[suspectIdx] = Math.min(3, (space.suspectTeamProgress[suspectIdx] || 0) + 1);
+ try { glog(sid, space, 'suspect-progress', '[TC179] สืบผู้ต้องสงสัย #' + (suspectIdx + 1) + ' → team progress ' + space.suspectTeamProgress.join('/') + ' (shared เท่ากันทุกจอ · pass)'); } catch (e) { /* ignore */ }
+ }
+ 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 || [],
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ 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 = [];
+ /* Phase B: majority vote (เช็ค lone-wolf) + มีคนโหวตไหม */
+ let mvAchv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mvAchv]) mvAchv = k; }
+ const anyVotesAchv = (counts[0] + counts[1] + counts[2]) > 0;
+ /* 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 });
+ /* ===== Phase B (2026-07-05) — เงื่อนไขต่อผู้โหวตถูก ===== */
+ /* c5 Lone Wolf: โหวตถูกแต่เสียงส่วนใหญ่ผิด (สวนทางทีม) */
+ if (anyVotesAchv && mvAchv !== culprit) achv.push({ playerKey: p.playerKey, id: 'c5_lone_wolf', inc: 1 });
+ /* หลักฐานในมือคดีนี้ (ต่อผู้ต้องสงสัย 3 กอง) */
+ const evRows = clonePlayerEvidence(space, id);
+ const evTotal = evRows.reduce((s, row) => s + (Array.isArray(row) ? row.length : 0), 0);
+ /* c2 High Stakes: โหวตถูกโดยมีหลักฐาน ≤3 ใบ */
+ if (evTotal <= 3) achv.push({ playerKey: p.playerKey, id: 'c2_high_stakes', inc: 1 });
+ /* a4 Logic Over Luck: โหวตถูกโดยไม่มีหลักฐาน legendary (ชี้ชัด) เลย */
+ const hasLegendary = evRows.some((row) => Array.isArray(row) && row.some((c) => c && c.rarity === 'legendary'));
+ if (!hasLegendary) achv.push({ playerKey: p.playerKey, id: 'a4_logic_over_luck', inc: 1 });
+ /* c4 Clutch Mind: โหวตถูกใน 10 วิสุดท้ายก่อนหมดเวลา */
+ const voteAt = space.trialVoteAt && space.trialVoteAt[id];
+ if (voteAt && space.trialVoteEndsAt) {
+ const remain = space.trialVoteEndsAt - voteAt;
+ if (remain >= 0 && remain <= 10000) achv.push({ playerKey: p.playerKey, id: 'c4_clutch_mind', 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 */
+ }
+ }
+ /* ===== Phase C/D (2026-07-06) — ต่อผู้เล่นที่จบคดี (ไม่ใช่แค่ผู้โหวตถูก) ===== */
+ const streakItems = [];
+ /* c3 Quick Draw: ผู้โหวตถูกที่ "โหวตเร็วที่สุด" ในทีม (ใช้ trialVoteAt) */
+ {
+ let fastId = null, fastAt = Infinity;
+ winners.forEach((id) => {
+ const at = space.trialVoteAt && space.trialVoteAt[id];
+ const wp = space.peers.get(id);
+ if (at && wp && wp.playerKey && at < fastAt) { fastAt = at; fastId = id; }
+ });
+ if (fastId) { const fp = space.peers.get(fastId); if (fp && fp.playerKey) achv.push({ playerKey: fp.playerKey, id: 'c3_quick_draw', inc: 1 }); }
+ }
+ /* วนผู้เล่นจริงทุกคนที่จบคดี (ข้ามบอท/ผู้ถูกแบน spectator) */
+ const caseMgSuspects = Object.keys(space.caseMinigameSuspects || {}).map((n) => parseInt(n, 10));
+ const targetedKeys = space.caseCardTargetedKeys || {};
+ space.peers.forEach((p, pid) => {
+ if (!p || !p.playerKey || p.bannedSpectator) return;
+ const evRows = clonePlayerEvidence(space, pid);
+ const rarSet = {};
+ evRows.forEach((row) => { if (Array.isArray(row)) row.forEach((c) => { if (c && c.rarity) rarSet[c.rarity] = true; }); });
+ /* a3 Mind Architect: มีหลักฐานครบ 3 ระดับในคดีนี้ (common+rare+legendary) */
+ if (rarSet.common && rarSet.rare && rarSet.legendary) achv.push({ playerKey: p.playerKey, id: 'a3_mind_architect', inc: 1 });
+ /* b2 Relentless Investigator: เก็บหลักฐานครบทุก suspect ที่มีมินิเกมรันในคดีนี้ (= เล่นครบทุกรอบ) */
+ if (caseMgSuspects.length > 0 && caseMgSuspects.every((si) => Array.isArray(evRows[si]) && evRows[si].length > 0)) {
+ achv.push({ playerKey: p.playerKey, id: 'b2_relentless_investigator', inc: 1 });
+ }
+ /* d2 Silent Guardian: ไม่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ if (!targetedKeys[p.playerKey]) achv.push({ playerKey: p.playerKey, id: 'd2_silent_guardian', inc: 1 });
+ /* e1 The Observer (streak): จบคดีนี้โดยไม่แตะ legendary = hit, แตะ = รีเซ็ต */
+ streakItems.push({ playerKey: p.playerKey, id: 'e1_the_observer', event: rarSet.legendary ? 'miss' : 'hit' });
+ });
+ /* a6 Unbreakable Logic (โหวตถูกติดกัน) + d4 Flawless Diver (ไม่โหวตผิดติดกัน) — เฉพาะคนที่โหวตจริง */
+ Object.keys(votes).forEach((id) => {
+ const p = space.peers.get(id);
+ if (!p || !p.playerKey) return;
+ const ok = votes[id] === culprit;
+ streakItems.push({ playerKey: p.playerKey, id: 'a6_unbreakable_logic', event: ok ? 'hit' : 'miss' });
+ streakItems.push({ playerKey: p.playerKey, id: 'd4_flawless_diver', event: ok ? 'hit' : 'miss' });
+ });
+ submitAchievementProgress(achv);
+ submitAchievementStreak(streakItems);
+ } 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.trialVoteAt = {}; /* [achv c4] รีเซ็ตเวลาโหวตทุกครั้งที่เปิดโหวต (รวม revote) */
+ 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 });
+ /* [achv d2] บันทึก playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ (→ ไม่ได้ d2_silent_guardian) */
+ if (target && (pv.purpose === 'silence' || pv.purpose === 'ban')) {
+ const tp = space.peers.get(target);
+ if (tp && tp.playerKey) {
+ space.caseCardTargetedKeys = space.caseCardTargetedKeys || {};
+ space.caseCardTargetedKeys[tp.playerKey] = true;
+ }
+ }
+ 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 = [];
+ const tc191Rows = []; /* [TC191] verify: เทียบผลตัดสิน "แถวเท้า" vs "หัว(เดิม)" ต่อคน */
+
+ space.peers.forEach((p, peerId) => {
+ const st = sess.players[peerId];
+ if (!st) return;
+ eligibleCount++;
+ /* จับที่ "แถวเท้า" (bottom row ของ footprint) เท่านั้น — หัว/ตัวโผล่เข้าโซนแต่เท้ายังไม่ถึง = ไม่นับ
+ (ต้องตรงกับ client quizResolveChoiceFromStanding) */
+ const { cw: qCw, ch: qCh } = getCharacterFootprintWHForMove(mdLive);
+ const qBaseTx = Math.floor(Number(p.x));
+ const qFeetTy = Math.floor(Number(p.y)) + qCh - 1;
+ let inT = false, inF = false;
+ for (let qdx = 0; qdx < qCw; qdx++) {
+ const qftx = qBaseTx + qdx;
+ if (!inT && quizCellOn(tGrid, qftx, qFeetTy)) inT = true;
+ if (!inF && quizCellOn(fGrid, qftx, qFeetTy)) inF = true;
+ }
+ 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,
+ });
+ }
+
+ /* [TC191] เก็บบรรทัดเทียบ: เท้า(ที่ใช้จริง) vs หัว(สูตรเดิม single-tile) → พิสูจน์ว่าตัดสินที่แถวเท้า */
+ {
+ const headY = Math.floor(Number(p.y));
+ tc191Rows.push((p.nickname || '?').slice(0, 8) + ' fY=' + qFeetTy + ' hY=' + headY
+ + ' เท้า[T' + (inT ? 1 : 0) + 'F' + (inF ? 1 : 0) + ']'
+ + ' หัวเดิม[T' + (quizCellOn(tGrid, qBaseTx, headY) ? 1 : 0) + 'F' + (quizCellOn(fGrid, qBaseTx, headY) ? 1 : 0) + ']'
+ + ' → ' + (choice === true ? 'จริง' : choice === false ? 'เท็จ' : '-') + (right ? '✓' : '✗'));
+ }
+ results.push({
+ id: peerId,
+ nickname: p.nickname || '',
+ right,
+ choice,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : 0,
+ });
+ });
+
+ if (tc191Rows.length) {
+ try { glog(sid, space, 'mg1-feet', '[TC191] Q' + (idx + 1) + ' ตัดสินแถวเท้า: ' + tc191Rows.join(' | ')); } catch (e) { /* ignore */ }
+ }
+
+ 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_` 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;
+ /* โต๊ะ submit กลาง (interactive/เขียว) = สิ่งกีดขวาง ห้ามยืนทับ/เดินทะลุ (ส่งคำตอบจากช่องข้างๆ ได้ — quizCarryCanSubmitAtServer รับ adjacent) */
+ if (quizCellOn(md.interactive || [], 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;
+}
+
+/* ช่องที่บอท "เดินถึงได้จริง" (footprint-aware BFS) จากโซน option/spawn — กันบอทสุ่มเกิด/ติดใน
+ "คอนโซลกลาง" ที่ยืนได้แต่ออกไม่ได้ (footprint 3×4 ลอดช่องแคบ/ทางที่ติดกำแพง+hub+interactive ไม่ได้
+ = อาการ "บอทยืนบนโต๊ะ"). cache ต่อ md (โครงสร้างแมปคงที่ต่อรอบเกม) */
+function quizCarryReachableTilesServer(md) {
+ if (md.__qcReach) return md.__qcReach;
+ const w = md.width || 20, h = md.height || 15;
+ const oa = md.quizCarryOptionArea, sa = md.spawnArea;
+ const seeds = [];
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const isSeed = (Array.isArray(oa) && oa[y] && Number(oa[y][x]) >= 1) || (Array.isArray(sa) && quizCellOn(sa, x, y));
+ if (isSeed && quizCarryEntityCanStandServer(md, x, y)) seeds.push([x, y]);
+ }
+ }
+ const reach = new Set();
+ if (!seeds.length) {
+ /* ไม่มี seed (แมปไม่มี option/spawn) → ไม่กรอง: ทุก standable นับว่าถึงได้ */
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (quizCarryEntityCanStandServer(md, x, y)) reach.add(x + ',' + y);
+ md.__qcReach = reach;
+ return reach;
+ }
+ const q = [];
+ for (const s of seeds) { const k = s[0] + ',' + s[1]; if (!reach.has(k)) { reach.add(k); q.push(s); } }
+ while (q.length) {
+ const cur = q.shift();
+ const nb = [[cur[0] + 1, cur[1]], [cur[0] - 1, cur[1]], [cur[0], cur[1] + 1], [cur[0], cur[1] - 1]];
+ for (const n of nb) {
+ const k = n[0] + ',' + n[1];
+ if (!reach.has(k) && quizCarryEntityCanStandServer(md, n[0], n[1])) { reach.add(k); q.push(n); }
+ }
+ }
+ md.__qcReach = reach;
+ return reach;
+}
+function quizCarryTileReachableServer(md, x, y) {
+ return quizCarryReachableTilesServer(md).has(Math.floor(x) + ',' + Math.floor(y));
+}
+
+/** รวมรายชื่อช่องว่าง (เดินยืนได้ + ถึงได้จริง + ไม่ใช่ 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;
+ if (!quizCarryTileReachableServer(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 ถ้ามีการเปลี่ยนสถานะ · clientPos = ตำแหน่งที่ client รายงานตอนกด grab (ใช้ถ้าใกล้ server ≤1.25 tile) */
+function quizCarryServerTryInteract(sid, space, actorId, isBot, clientPos) {
+ 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);
+ /* client รายงานตำแหน่งตอนกด F — ถ้าใกล้ server พอ ใช้ client เพื่อลด desync หยิบไม่ติด/เด้ง */
+ if (clientPos && typeof clientPos === 'object') {
+ const cx = Number(clientPos.x), cy = Number(clientPos.y);
+ if (Number.isFinite(cx) && Number.isFinite(cy) && Number.isFinite(px) && Number.isFinite(py)) {
+ if (Math.hypot(cx - px, cy - py) <= 1.25) { px = cx; py = cy; }
+ }
+ }
+ }
+ 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 });
+ /* แจ้งผลถูก/ผิด "บรอดแคสต์ให้ทุกคน" → โชว์ไอคอน ✓/✗ เหนือหัวทั้งคนจริงและบอท (ทุกจอเห็นพร้อมกัน) */
+ io.to(sid).emit('quiz-carry-answer-feedback', { id: actorId, correct: ok });
+ 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;
+ /* resolve ชื่อผู้ชนะ (ให้ client โชว์ชื่อจริง ไม่ใช่ 'ผู้เล่น' ตอน churn) + diag: ใครตอบถูก (คน/บอท) */
+ let winnerNick = '', winnerIsBot = false;
+ if (winnerId) {
+ if (s.bots && s.bots.has(winnerId)) { winnerIsBot = true; }
+ else { const wp = space.peers.get(winnerId); if (wp && wp.nickname) winnerNick = wp.nickname; }
+ }
+ try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId ? ('[TC169] ' + (winnerNick || (winnerIsBot ? 'BOT ' + winnerId : winnerId))) : '-(หมดเวลา ไม่มีคนถูก)') + ' correctIdx=' + (s.current ? s.current.correctIndex : '?') + ' willEnd=' + willEnd); } catch (e) { /* ignore */ }
+ io.to(sid).emit('quiz-carry-result', {
+ questionIndex: s.qIndex + 1,
+ correctIndex: s.current ? s.current.correctIndex : null,
+ winnerId: winnerId || null,
+ winnerNick,
+ winnerIsBot,
+ 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.lastAdvanceAt = Date.now();
+ try { glog(sid, space, 'mg4-read', 'q=' + s.qIndex + ' deck=' + (s.deck ? s.deck.length : 0) + ' rounds=' + s.roundsCompleted + '/' + s.sessionLength); } catch (e) { /* ignore */ }
+ 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';
+ s.lastAdvanceAt = Date.now();
+ 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);
+ /* 0 = ไม่ได้ตั้งค่า → จบเมื่อ "ถามครบทุกข้อในชุด" รอบเดียว (ไม่วนซ้ำ) ตรงกับที่ผู้เล่นคาดหวังว่า
+ "พอจบทุกคำถาม เกมต้องจบ"; ถ้า pool ว่างค่อย fallback 10 (กันหารด้วยศูนย์/ค้าง) */
+ if (!(sessionLength > 0)) sessionLength = Math.max(1, pool.length || 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover (BFS เล่นต่อ) — แทนคนที่ออก ใช้ socket.id เดิม ไม่ชน id บอท AI */
+ {
+ const sC = space.quizCarrySession;
+ drainPendingMissionTakeovers(space, 'quiz_carry', (rec) => {
+ sC.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ dir: rec.direction || 'down', isWalking: false,
+ held: null, score: 0,
+ botPath: [], pathfindAfter: 0, wantIdx: null,
+ wanderTX: null, wanderTY: null, retargetAt: 0,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+ }
+ if (!pool.length) { endQuizCarryGame(sid, space); return; }
+ /* watchdog กันเกมค้างไม่จบ: เก็บนอก space.quizTimers (เพราะ resolveQuizCarryRound เคลียร์ array นั้นทุกข้อ)
+ ถ้าไม่มีความคืบหน้า (lastAdvanceAt) นานเกิน → force resolve/จบ — ป้องกัน "เล่นจนครบแต่เกมไม่ยอมจบ" */
+ {
+ const sC = space.quizCarrySession;
+ sC.lastAdvanceAt = Date.now();
+ const perRound = Math.max(0, sC.carryReadMs) + Math.max(1000, sC.carryAnswerMs) + QUIZ_CARRY_BETWEEN_MS + 1500;
+ const stallMs = perRound + 20000; /* ช่วงเวลาที่ "ยอมรับได้" ต่อข้อ ก่อนถือว่าค้าง */
+ if (space.quizCarryWatchdog) clearInterval(space.quizCarryWatchdog);
+ space.quizCarryWatchdog = setInterval(() => {
+ const sp = spaces.get(sid);
+ const ss = sp && sp.quizCarrySession;
+ if (!sp || !ss || !ss.active || ss.ended) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; return; }
+ const idle = Date.now() - (ss.lastAdvanceAt || 0);
+ if (idle > stallMs) {
+ try { glog(sid, sp, 'mg4-watchdog', 'STALL idle=' + idle + 'ms phase=' + ss.phase + ' round=' + ss.roundsCompleted + '/' + ss.sessionLength); } catch (e) { /* ignore */ }
+ ss.lastAdvanceAt = Date.now();
+ /* ดันให้ไปต่อ: ถ้ายังตอบ/อ่านค้าง → จบรอบนี้ (ไม่ให้คะแนน) แล้วระบบจะเลื่อนข้อหรือจบเองตาม willEnd */
+ if (!ss.roundResolved) { try { resolveQuizCarryRound(sid, sp, null); } catch (e) { /* ignore */ } }
+ else { try { scheduleQuizCarryReadPhase(sid, sp); } catch (e) { /* ignore */ } }
+ }
+ }, 5000);
+ }
+ scheduleQuizCarryReadPhase(sid, space);
+}
+
+function endQuizCarryGame(sid, space) {
+ const s = space.quizCarrySession;
+ if (s) { s.active = false; s.ended = true; }
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; }
+ try { glog(sid, space, 'mg4-end', 'END rounds=' + (s ? s.roundsCompleted : '?') + '/' + (s ? s.sessionLength : '?')); } catch (e) { /* ignore */ }
+ 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;
+
+ /* ก้าวไป (x,y) ได้ไหม: ต้อง "ยืนได้ทั้ง footprint" (canStand = ทุกช่องของตัวละครไม่ใช่กำแพง/ไม่ทับ hub/ไม่ทับโต๊ะ interactive)
+ — เดิมเช็คแค่ quizTileWalkableServer ที่ "ช่อง anchor จุดเดียว" ทำให้ footprint ขอบตัวเลื่อนคร่อมกำแพงได้ →
+ ตกไปช่องที่ reachability-BFS (footprint-aware) ถือว่า "ออกไม่ได้" → โดน push-out teleport ทุก tick = อาการ "บอทว๊าป".
+ ใช้ canStand ให้ตรงกับ pathfind (ที่วางเส้นบน canStand อยู่แล้ว) → บอทไม่เดินเข้าช่องที่จะถูกดันออก */
+ const canStepTo = (x, y) => quizCarryEntityCanStandServer(md, Math.floor(x), Math.floor(y));
+
+ 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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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) => {
+ /* ช่วงอ่านคำถาม (read) → บอท freeze อยู่ตรงไหนตรงนั้น (ตรงกับผู้เล่นที่ freeze ฝั่ง client) */
+ if (s.phase === 'read') { b.isWalking = false; return; }
+ /* ทับ hub / โต๊ะ submit กลาง (interactive) / หรือ "ติดในคอนโซลกลาง" (ยืนได้แต่เดินออกไม่ได้) → ดันออกไปช่องว่างที่ถึงได้
+ หมายเหตุ: หลังแก้ canStepTo → canStand แล้ว บอทไม่ควรเดินเข้าช่องแบบนี้เอง teleport นี้จึงเป็น safety-net (ควรยิงน้อยมาก)
+ — diag: glog ทุกครั้งที่ teleport จริง เพื่อยืนยันว่า "ว๊าป" ลดลง (ถอด log ออกเมื่อยืนยันแล้ว) */
+ if (quizCarryFootprintOverlapsHubServer(md, b.x, b.y) || quizCarryFootprintOverlapsInteractiveServer(md, b.x, b.y) || !quizCarryTileReachableServer(md, b.x, b.y)) {
+ const tiles = quizCarrySpawnTilesServer(md);
+ if (tiles.length) {
+ const fromX = b.x, fromY = b.y;
+ const t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = [];
+ try { glog(sid, space, 'mg4-warp', '[TC158] push-out teleport ' + bid + ' (' + fromX.toFixed(2) + ',' + fromY.toFixed(2) + ')->(' + b.x.toFixed(2) + ',' + b.y.toFixed(2) + ') — event นี้ = ยังว๊าป (fail); ไม่มี event ตลอดเกม = pass'); } catch (e) { /* ignore */ }
+ }
+ }
+ /* ยังไม่มีเส้นทาง → ลองหยิบ/ส่งที่จุดยืนก่อน */
+ 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)) {
+ /* ช่องที่ "ยืนได้จริง + ชิดโต๊ะ interactive" (ไม่ใช่ยืนทับโต๊ะ — โต๊ะเป็นสิ่งกีดขวางแล้ว) → ส่งจากข้างๆ */
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!quizCarryEntityCanStandServer(md, x, y)) continue;
+ if (quizCarryFootprintAdjacentInteractiveServer(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 — เลข 2–16หาย) */
+function normalizeQuizCarryLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_carry') return;
+ const w = m.width || 20, h = m.height || 15;
+ const normHub = () => {
+ const src = m.quizCarryHubArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizCarryHubArea = rows;
+ };
+ const normOptions = () => {
+ const src = m.quizCarryOptionArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ m.quizCarryOptionArea = rows;
+ };
+ const normCountdown = () => {
+ const src = m.carryEmbedCountdownArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ rows.push(row);
+ }
+ m.carryEmbedCountdownArea = rows;
+ };
+ normHub();
+ normOptions();
+ normCountdown();
+}
+
+/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
+function normalizeQuizBattleDomeAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattleDomeArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattleDomeArea = rows;
+}
+
+/** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */
+function normalizeQuizBattleDomeCompOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const grid = m.quizBattleDomeArea;
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ let nextId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue;
+ nextId++;
+ const stack = [[x, y]];
+ comp[y][x] = nextId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue;
+ comp[ny][nx] = nextId;
+ stack.push([nx, ny]);
+ }
+ }
+ }
+ }
+ m.quizBattleDomeComp = comp;
+}
+
+/**
+ * ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน
+ * ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน
+ */
+function hasQuizBattleManualPathGates(m) {
+ const mg = m && m.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+}
+
+function buildQuizBattlePathGateFromManualOnMap(m) {
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const manual = m.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ /* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function buildQuizBattlePathGateRequiredOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) {
+ if (m) m.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGates(m)) {
+ buildQuizBattlePathGateFromManualOnMap(m);
+ return;
+ }
+ normalizeQuizBattleDomeCompOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const domeComp = m.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function finalizeQuizBattleMapLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ normalizeQuizBattlePathAreaOnMap(m);
+ normalizeQuizBattleDomeCompOnMap(m);
+ buildQuizBattlePathGateRequiredOnMap(m);
+}
+
+function normalizeQuizBattlePathAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattlePathArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattlePathArea = rows;
+}
+
+function quizBattlePathModeActiveServer(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
+ const g = m.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattleFootprintFullyOnPathServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ const g = m.quizBattlePathArea;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+/** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */
+function quizBattlePositionAllowedServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true;
+ const sa = m.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+function quizBattlePlayerSolvedCompsServer(space, socketId) {
+ if (!space || !space.qbwSolved) return new Set();
+ const set = space.qbwSolved.get(socketId);
+ return set || new Set();
+}
+
+function quizBattlePathGateRequiredAtServer(m, px, py) {
+ const gate = m && m.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActiveServer(m)) return 0;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ const path = m.quizBattlePathArea;
+ let maxReq = 0;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+}
+
+function quizBattleFootprintOnDomeServer(m, px, py) {
+ const dome = m && m.quizBattleDomeArea;
+ if (!dome) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattlePathGateAllowsServer(m, space, socketId, px, py) {
+ if (!m || !quizBattlePathModeActiveServer(m)) return true;
+ if (quizBattleFootprintOnDomeServer(m, px, py)) return true;
+ const maxReq = quizBattlePathGateRequiredAtServer(m, px, py);
+ if (maxReq <= 0) return true;
+ const solved = quizBattlePlayerSolvedCompsServer(space, socketId);
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solved.has(c)) return false;
+ }
+ return true;
+}
+
+function serverFootprintClearOfWalls(md, px, py) {
+ const w = md.width || 20, h = md.height || 15;
+ const keys = serverWallCollisionTileKeys(md, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects && md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+}
+
+/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
+function snapPositionOntoQuizBattlePathServer(md, px, py) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidServer(md, px, py)) return { x: px, y: py };
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidServer(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+}
+
+function collectQuizBattleValidSpawnCentersServer(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+}
+
+function quizBattleSnapTargetValidServer(md, x, y) {
+ if (!md || !md.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ if (!serverFootprintClearOfWalls(md, x, y)) return false;
+ if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false;
+ return true;
+}
+
+function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersServer(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+}
+
+function collectQuizBattleSpawnPoolServer(md) {
+ const out = [];
+ if (!md) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActiveServer(md)) {
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ } else if (!isMapTileWalkableForSpawn(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActiveServer(md)) {
+ return collectQuizBattleValidSpawnCentersServer(md);
+ }
+ return out;
+}
+
+function pickQuizBattleRandomSpawnWorldServer(md) {
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y);
+ }
+ const idx = typeof crypto.randomInt === 'function'
+ ? crypto.randomInt(0, pool.length)
+ : Math.floor(Math.random() * pool.length);
+ const pick = pool[idx];
+ return { x: pick.x, y: pick.y };
+}
+
+function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMap(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldServer(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldServer(md, sx, sy);
+ }
+ return pickQuizBattleRandomSpawnWorldServer(md);
+}
+
+function pickQuizBattleSpawnFromMap(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+}
+
+function normalizeJumpSurvivePlatformAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformArea = rows;
+}
+
+function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const pa = m.jumpSurvivePlatformArea || [];
+ const src = m.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformVariantArea = rows;
+}
+
+function normalizeJumpSurviveHazardAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurviveHazardArea = rows;
+}
+
+/**
+ * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
+ */
+function applyStackTowerMissionMapPlayPolicy(safeId, m) {
+ if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
+ const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
+ const spNum = Number(raw.speedPxPerSec);
+ const speedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ const sel = Math.floor(Number(raw.stepEveryLayers));
+ const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
+ const ssp = Math.floor(Number(raw.stepScrollPx));
+ const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
+ const sam = Math.floor(Number(raw.stepAnimMs));
+ const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
+ const st = {
+ enabled: raw.enabled === true,
+ speedPxPerSec,
+ scrollDirection,
+ stepEveryLayers,
+ stepScrollPx,
+ stepAnimMs,
+ };
+ if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
+ if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
+ const rg = Number(raw.releaseGapWorldPx);
+ if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
+ m.stackTowerBgScroll = st;
+ const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
+ const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
+ const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
+ m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
+}
+
+function loadMaps() {
+ try {
+ if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(MAPS_DIR);
+ for (const f of files) {
+ if (!f.endsWith('.json')) continue;
+ const id = f.slice(0, -5);
+ if (!safeMapId(id)) continue;
+ try {
+ const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(id, data);
+ maps.set(id, data);
+ } catch (e) { console.error('load map', f, e.message); }
+ }
+ } catch (e) { console.error('loadMaps', e.message); }
+}
+
+/**
+ * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
+ * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
+ */
+function rehydrateSingleMapFromDisk(safeId) {
+ if (!safeId) return false;
+ const fp = path.join(MAPS_DIR, safeId + '.json');
+ if (!fs.existsSync(fp)) return false;
+ try {
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(safeId, data);
+ maps.set(safeId, data);
+ return true;
+ } catch (e) {
+ console.error('rehydrate map', safeId, e.message);
+ return false;
+ }
+}
+
+/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
+function ensurePostCaseLobbyMapLoaded() {
+ if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
+ try {
+ const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
+ if (!fs.existsSync(fp)) return false;
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ maps.set(POST_CASE_LOBBY_SPACE_ID, data);
+ return true;
+ } catch (e) {
+ console.error('ensurePostCaseLobbyMapLoaded', e.message);
+ return false;
+ }
+}
+
+function saveMap(id, m) {
+ const safe = safeMapId(id);
+ if (!safe) return;
+ try {
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
+ } catch (e) { console.error('saveMap', id, e.message); }
+}
+
+function isMapNameTaken(name, excludeId) {
+ const n = (name || '').trim().toLowerCase();
+ for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
+ return false;
+}
+
+function applyDevCors(req, res) {
+ const origin = req.headers.origin;
+ if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.setHeader('Vary', 'Origin');
+ }
+}
+
+const server = http.createServer((req, res) => {
+ applyDevCors(req, res);
+ if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ let url = req.url;
+ if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
+ if (url.startsWith(BASE_PATH + '/api/maps')) {
+ let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
+ if (id.includes('?')) id = id.split('?')[0];
+ if (req.method === 'GET' && id) {
+ const sid = safeMapId(decodeURIComponent(id));
+ if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ rehydrateSingleMapFromDisk(sid);
+ const m = maps.get(sid);
+ if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
+ }
+ if (req.method === 'GET' && !id) {
+ const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
+ }
+ if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || 'ฉากใหม่';
+ if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const id = Date.now().toString(36);
+ const m = { ...defaultMap(), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ if (req.method === 'PUT' && id && maps.has(id)) {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || maps.get(id).name;
+ if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const m = { ...maps.get(id), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ applyStackTowerMissionMapPlayPolicy(id, m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ }
+ const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ const quizCarryDiskPaths = new Set([
+ BASE_PATH + '/api/quiz-carry-from-disk',
+ BASE_PATH + '/api-quiz-carry-from-disk.php',
+ ]);
+ if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
+ if (req.method === 'GET') {
+ const g = loadQuizSettings();
+ const slice = {
+ carryMapPanelTheme: g.carryMapPanelTheme,
+ carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
+ carryReadMs: g.carryReadMs,
+ carryAnswerMs: g.carryAnswerMs,
+ carrySessionLength: g.carrySessionLength,
+ carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
+ carryWalkSpeedMult: g.carryWalkSpeedMult,
+ carryQuestions: g.carryQuestions,
+ };
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(slice));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(getSystemStats()));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadQuizSettings()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveQuizSettings(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ battleQuizMcqSaved: saved.battleQuizMcqSaved,
+ carryQuestionsSaved: saved.carryQuestionsSaved,
+ specialQuizSets: saved.specialQuizSets,
+ carryMapPanelTheme: saved.carryMapPanelTheme,
+ carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadEvidenceCards()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveEvidenceCards(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadCaseMedia()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveCaseMedia(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ });
+ return res.end(JSON.stringify({ ...runtimeGameTiming }));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveGameTimingToFile(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ const { ok: _ok, error: _err, ...timingRest } = saved;
+ return res.end(JSON.stringify({ ok: true, ...timingRest }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureGauntletAssetsDir();
+ const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
+ let fname;
+ if (replaceFilename) {
+ const s = safeGauntletStoredFilename(replaceFilename);
+ if (!s) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
+ }
+ const cur = path.join(GAUNTLET_ASSETS_DIR, s);
+ if (!fs.existsSync(cur)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
+ }
+ const norm = (e) => {
+ let x = String(e || '').toLowerCase();
+ if (x.startsWith('.')) x = x.slice(1);
+ return x === 'jpeg' ? 'jpg' : x;
+ };
+ if (norm(path.extname(s)) !== norm(ext)) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
+ }
+ fname = s;
+ } else {
+ fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ }
+ fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
+ const meta = loadGauntletAssetsMeta();
+ const prev = meta[fname] || {};
+ meta[fname] = {
+ ...prev,
+ label: labelIn || prev.label || fname,
+ updatedAt: Date.now(),
+ };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/gauntlet-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureQuizCarryPlaqueAssetsDir();
+ const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const fname = safeGauntletStoredFilename(d.filename);
+ if (!fname) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
+ }
+ const meta = loadGauntletAssetsMeta();
+ const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
+ try {
+ const q = url.includes('?') ? url.split('?')[1] : '';
+ const sp = new URLSearchParams(q);
+ const rawFn = sp.get('file');
+ const fname = safeGauntletStoredFilename(rawFn);
+ if (!fname) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
+ const meta = loadGauntletAssetsMeta();
+ delete meta[fname];
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
+ }
+ }
+ if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const ids = new Set();
+ const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
+ files.forEach(f => {
+ let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ });
+ const idArr = [...ids];
+ const oldestMsById = {};
+ idArr.forEach((id) => {
+ let oldest = Infinity;
+ const prefix = id + '_';
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ try {
+ const st = fs.statSync(path.join(CHARACTERS_DIR, f));
+ const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
+ if (t < oldest) oldest = t;
+ } catch (e) { /* ignore */ }
+ });
+ oldestMsById[id] = oldest === Infinity ? 0 : oldest;
+ });
+ idArr.sort((a, b) => {
+ const ta = oldestMsById[a] || 0;
+ const tb = oldestMsById[b] || 0;
+ if (ta !== tb) return ta - tb;
+ return String(a).localeCompare(String(b));
+ });
+ const list = idArr.map((id) => {
+ const prefix = id + '_';
+ const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
+ const layerNameSet = new Set();
+ if (hasLayerFiles) {
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) {
+ layerNameSet.add(m[1]);
+ return;
+ }
+ m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) layerNameSet.add(m[1]);
+ });
+ }
+ const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
+ const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
+ return { id, name: id, hasLayerFiles, layers, layerManifest };
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(list));
+ } catch (e) {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify([]));
+ }
+ }
+ const urlPath = url.split('?')[0];
+ {
+ const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
+ if (lm && req.method === 'GET') {
+ const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const manifest = buildCharacterLayerManifest(id, files);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(manifest));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
+ }
+ }
+ }
+ if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
+ if (/\/layer-manifest$/i.test(urlPath)) {
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
+ }
+ const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
+ const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ let removed = 0;
+ files.forEach(f => {
+ if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
+ try {
+ fs.unlinkSync(path.join(CHARACTERS_DIR, f));
+ removed++;
+ } catch (e) {
+ // ignore single file errors
+ }
+ }
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, removed }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ return;
+ }
+ if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ if (!body || body.length < 2) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
+ }
+ const d = JSON.parse(body);
+ const dirs = ['up', 'down', 'left', 'right'];
+ const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
+ let walkWritten = 0;
+ for (const dir of dirs) {
+ let data = d[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ // เขียนไฟล์เฟรม (สำหรับ animation)
+ const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ walkWritten++;
+ frameIndex++;
+ }
+ }
+ let layerWritten = 0;
+ // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
+ const lf = d.layerFrames;
+ if (lf && typeof lf === 'object') {
+ for (const dir of dirs) {
+ const arr = lf[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const lfIdle = d.layerFramesIdle;
+ if (lfIdle && typeof lfIdle === 'object') {
+ for (const dir of dirs) {
+ const arr = lfIdle[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_idle_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
+ if (idleObj) {
+ for (const dir of dirs) {
+ let data = idleObj[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameFrame = list.length > 1
+ ? (id + '_' + dir + '_idle_' + frameIndex + '.png')
+ : (id + '_' + dir + '_idle.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '_idle.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ frameIndex++;
+ }
+ }
+ }
+ if (walkWritten === 0 && layerWritten === 0) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, characterId: id }));
+ } catch (e) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/api/spaces')) {
+ const parts = url.slice((BASE_PATH + '/api/spaces').length).split('?')[0].split('#')[0].split('/').filter(Boolean);
+ const spaceId = parts[0];
+ if (req.method === 'GET' && !spaceId) {
+ const now = Date.now();
+ for (const [id, s] of [...spaces.entries()]) {
+ if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
+ const n = s.peers ? s.peers.size : 0;
+ if (n !== 0) continue;
+ /* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */
+ const since = s.emptySince || s.createdAt;
+ const stale = since == null || now - since > SPACE_EMPTY_TTL_MS;
+ if (stale) spaces.delete(id);
+ }
+ const list = [...spaces]
+ .filter(([, s]) => !s.isPrivate)
+ .filter(([, s]) => (s.peers && s.peers.size > 0))
+ .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;
+ }
+ /* เสียง: volume master/music/sfx — GET เปิดสาธารณะ (sound.js ทุก client อ่าน) · PUT เฉพาะแอดมิน */
+ if (url === BASE_PATH + '/api/sound-settings' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(loadSoundSettings()));
+ return;
+ }
+ if (url === BASE_PATH + '/api/sound-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 = loadSoundSettings();
+ const clamp = (v, cur) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : cur; };
+ if (d.masterVol !== undefined) s.masterVol = clamp(d.masterVol, s.masterVol);
+ if (d.musicVol !== undefined) s.musicVol = clamp(d.musicVol, s.musicVol);
+ if (d.sfxVol !== undefined) s.sfxVol = clamp(d.sfxVol, s.sfxVol);
+ saveSoundSettings(s);
+ res.writeHead(200);
+ res.end(JSON.stringify({ ok: true, settings: s }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const message = (d.message || d.text || '').trim();
+ if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
+ const s = loadAiSettings();
+ if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
+ const model = s.model || 'gpt-4o-mini';
+ const messages = [];
+ const systemParts = [];
+ if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
+ if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
+ systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
+ }
+ if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
+ messages.push({ role: 'user', content: message });
+ const payload = JSON.stringify({
+ model: model,
+ messages: messages,
+ max_tokens: 500,
+ });
+ const urlObj = new URL('https://api.openai.com/v1/chat/completions');
+ const opts = {
+ hostname: urlObj.hostname,
+ path: urlObj.pathname,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + s.openai_api_key,
+ 'Content-Length': Buffer.byteLength(payload, 'utf8'),
+ },
+ };
+ const reqOut = require('https').request(opts, (resOut) => {
+ let data = '';
+ resOut.on('data', chunk => data += chunk);
+ resOut.on('end', () => {
+ try {
+ const parsed = JSON.parse(data);
+ const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
+ if (text != null) {
+ res.writeHead(200);
+ res.end(JSON.stringify({ response: String(text).trim() }));
+ } else {
+ res.writeHead(502);
+ res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
+ }
+ } catch (e) {
+ res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
+ }
+ });
+ });
+ reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
+ reqOut.write(payload);
+ reqOut.end();
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/img/characters/') && (req.method === 'GET' || req.method === 'HEAD')) {
+ const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
+ const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
+ if (fname && fname.length > 0) {
+ const fp = path.join(CHARACTERS_DIR, fname);
+ if (fs.existsSync(fp)) {
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': 'image/png' });
+ if (req.method === 'HEAD') return res.end();
+ res.end(data);
+ });
+ }
+ }
+ }
+ if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
+ const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
+ // production: nginx เสิร์ฟ /Game/img/* เป็น static ทั้งหมด — local node จึงต้องเสิร์ฟไฟล์ที่มีอยู่จริงในโฟลเดอร์ด้วย
+ // (ไม่จำกัดเฉพาะไฟล์อัปโหลด gauntlet-) ไม่งั้น asset ดีไซน์ (popup-Howto, Avartar, btn-ready…) จะ 400 เฉพาะตอนรันผ่าน node ตรง ๆ
+ const fname = safeGauntletStoredFilename(sub) || path.basename(sub); // basename กัน path traversal
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fname || !fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end();
+ }
+ const ext = path.extname(fname);
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
+ res.end(data);
+ });
+ }
+ if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
+ // ตัด query string / hash ออกก่อน resolve เป็น path ไฟล์ (เช่น /play.html?space=...&map=... → /play.html)
+ // ไม่ตัด จะหาไฟล์ชื่อ "play.html?space=..." ไม่เจอ → 404 (เข้า node ตรง ๆ พร้อม query)
+ url = url.split('?')[0].split('#')[0] || '/index.html';
+ const filePath = path.join(__dirname, 'public', url);
+ const ext = path.extname(filePath);
+ const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
+ fs.readFile(filePath, (err, data) => {
+ if (err) return res.writeHead(404), res.end('Not Found');
+ res.writeHead(200, {
+ 'Content-Type': types[ext] || 'application/octet-stream',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+ res.end(data);
+ });
+});
+
+const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
+const spaces = new Map();
+
+const GAUNTLET_MAX_PLAYERS = 6;
+/** เข้าเส้นชัยแล้ว — หยุด 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);
+}
+
+/** มินิเกม "live แล้ว" (พ้น how-to/นับถอยหลัง — session engine เริ่มแล้ว หรือ gauntlet ปล่อยวิ่งแล้ว)?
+ ใช้ตอน rejoin: ถ้า live แล้ว client ต้องข้าม how-to แล้วเข้าเล่นต่อทันที (เดิมโชว์ how-to เสมอ → คน reconnect ค้าง)
+ signal = session.active (สร้างตอน 3-2-1 จบ, ล้างตอนกลับ LobbyB) — เชื่อถือได้กว่า missionLiveBeginsAt (ไม่เคย reset ต่อเกม) */
+function isMinigameLiveForRejoinResume(space) {
+ if (!space) return false;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (!md) return false;
+ switch (md.gameType) {
+ case 'gauntlet': return !!(space.gauntletRun && !space.gauntletRun.crownRunHeld);
+ case 'stack': return !!(space.stackSession && space.stackSession.active);
+ case 'jump_survive': return !!(space.jumpSurviveSession && space.jumpSurviveSession.active);
+ case 'space_shooter': return !!(space.spaceShooterSession && space.spaceShooterSession.active);
+ case 'balloon_boss': return !!(space.balloonBossSession && space.balloonBossSession.active);
+ case 'quiz': return !!(space.quizSession && space.quizSession.active);
+ case 'quiz_carry': return !!(space.quizCarrySession && space.quizCarrySession.active);
+ default: return false;
+ }
+}
+
+/* ── How-to / pre-live disconnect takeover (MG3–MG7) ───────────────────────────
+ มินิเกมเหล่านี้สร้าง session (bots/turnOrder) ตอน 3-2-1 จบเท่านั้น → ถ้าคนหลุดช่วง how-to /
+ นับถอยหลัง ยังไม่มี session ให้แปลงเป็นบอท เดิม avatar เลยหายไปเลย (ตกไป user-left).
+ แก้: เก็บ "pending takeover" ไว้บน space + แจ้ง client เก็บ avatar (*-player-to-bot) แล้ว
+ seed เป็นบอท server ตอน startXxxGame → บอทรับช่วงเล่นต่อทันทีเมื่อเกมเริ่ม เหมือน MG1/MG2 */
+function missionShellGameTypeForSpace(space) {
+ if (!space) return null;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gt = md && md.gameType;
+ if (!gt) return null;
+ if (gt === 'space_shooter' && isSpaceShooterMissionShellSpace(space)) return 'space_shooter';
+ if (gt === 'jump_survive' && isJumpSurviveMissionShellSpace(space)) return 'jump_survive';
+ if (gt === 'balloon_boss' && isBalloonBossMissionShellSpace(space)) return 'balloon_boss';
+ if (gt === 'stack' && isStackTowerMissionShellSpace(space)) return 'stack';
+ if (gt === 'quiz_carry') return 'quiz_carry';
+ return null;
+}
+function recordPendingMissionTakeover(space, gameType, socketId, info, playerKey, nick, wasElim) {
+ if (!space || !gameType || !socketId || !info) return;
+ if (!Array.isArray(space.pendingMissionTakeovers)) space.pendingMissionTakeovers = [];
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => r.id !== socketId);
+ const themeIdx = parseInt(info.lobbyColorThemeIndex, 10);
+ const skinIdx = parseInt(info.lobbySkinToneIndex, 10);
+ space.pendingMissionTakeovers.push({
+ gameType, id: socketId, playerKey: playerKey || '', nick: (nick || 'ผู้เล่น'), wasEliminated: !!wasElim,
+ x: Number(info.x), y: Number(info.y), direction: info.direction || 'down',
+ characterId: info.characterId != null ? info.characterId : null,
+ themeIdx: Number.isFinite(themeIdx) ? themeIdx : null,
+ skinIdx: Number.isFinite(skinIdx) ? skinIdx : null,
+ spaceShooterScore: Math.max(0, Number(info.spaceShooterScore) || 0),
+ spaceShooterEliminated: !!info.spaceShooterEliminated,
+ balloonBossScore: Math.max(0, Number(info.balloonBossScore) || 0),
+ balloonBossBossDmg: Math.max(0, Number(info.balloonBossBossDmg) || 0),
+ balloonBossBalloons: Math.max(0, Number(info.balloonBossBalloons) || 0),
+ balloonBossEliminated: !!info.balloonBossEliminated,
+ jumpSurviveEliminated: !!info.jumpSurviveEliminated,
+ });
+}
+/** seed บอท takeover จาก pending (เฉพาะ gameType นี้) — makeBot(rec) สร้างบอทใน session แล้วคืน true ถ้าสำเร็จ */
+function drainPendingMissionTakeovers(space, gameType, makeBot) {
+ if (!space || !Array.isArray(space.pendingMissionTakeovers) || typeof makeBot !== 'function') return 0;
+ const keep = [];
+ let n = 0;
+ for (const rec of space.pendingMissionTakeovers) {
+ if (rec.gameType !== gameType) { keep.push(rec); continue; }
+ try { if (makeBot(rec) !== false) n++; } catch (e) { /* ignore */ }
+ }
+ space.pendingMissionTakeovers = keep;
+ return n;
+}
+
+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_` 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,
+ gameoverAt: null, /* != null = อยู่ในช่วง grace "ตายหมด" (โชว์ game over ก่อนกลับ LobbyB) */
+ };
+}
+
+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.5; /* miss เมื่อ overlap < 50% บล็อก (เดิม 30% หลวมไป = ลงเยื้อง 3 ช่องยังไม่ miss หัวใจไม่ลด) — ต้องตรงกับ client */
+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 = 1200; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป (ลดจาก 1500 + swing เดินต่อระหว่าง busy → ไม่ค้าง) */
+const STACK_BOT_THINK_MIN_MS = 560;
+const STACK_BOT_THINK_MAX_MS = 1180;
+const STACK_SEATS = 6;
+/* เฟส client (ตอนกดวาง) ต้องใกล้เฟส server ไม่เกินนี้ (rad) ถึงจะใช้.
+ เดิม 1.2rad (~0.35s@0.55Hz) → latency/desync เกินนั้น server ทิ้งเฟส client ใช้ s.phase (เลื่อนไปแล้ว)
+ = "เล็งกลางแต่ลงเยื้อง/ร่วง" (intermittent ตาม latency). ขยายเป็น ~1รอบ (2π ≈ 1.8s@0.55Hz)
+ = ครอบ latency เล่นได้จริงทั้งหมด → ใช้เฟสที่ผู้เล่นเห็นเสมอ (ลงตรงจุดที่เห็น) เหลือไว้แค่กันค่า garbage.
+ เกม co-op สร้างตึกร่วมกัน ไม่ต้องกันโกงเฟส */
+const STACK_DROP_PHASE_TOL = Math.PI * 2;
+
+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));
+ }
+ /* rel-boost: ความกว้างสวิงตาม stackReleaseArea (ต้องตรงกับ client play.js ~12177-12180 เป๊ะ)
+ เดิม server ใช้แค่ land span → client (มี rel-boost) แกว่ง "กว้างกว่า" server: บล็อกห้อยกว้างแต่ตกแคบ
+ = ไถลเฉียงตอนปล่อย + logical ตกใกล้กลางเสมอจน "ทับ support ตลอด ไม่นับ miss → หัวใจไม่ลด".
+ ทำให้ 2 ฝั่งใช้สวิงเดียวกัน → ตกตรง (straight) + miss/หัวใจ ทำงานถูก */
+ const rel = stackBoundsServer(md.stackReleaseArea, w, h);
+ if (rel) {
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.32, 3.0)); /* ต้องตรงกับ client play.js — ลดจาก 0.42/4.2 (สวิงกว้างเกิน = หน่วงนิ้วนิดเดียวเยื้องเยอะ "ต่อตรงแต่ลงเยื้อง") แต่ยังกว้างพอให้ miss ได้เมื่อเยื้องจริง */
+ }
+ 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)
+ phaseOverride: ตา "คนจริง" ส่งเฟสที่เห็นตอนกดมาด้วย (client phase) → คำนวณจุดลงตามที่ผู้เล่นเห็น
+ ไม่ใช่เฟส server ณ ตอน req มาถึง (ช้ากว่า half-RTT = บล็อกลงเยื้อง). บอท/fallback ใช้ s.phase */
+function stackComputeDropServer(s, md, phaseOverride) {
+ const dropPhase = (typeof phaseOverride === 'number' && Number.isFinite(phaseOverride)) ? phaseOverride : s.phase;
+ const raw = s.swingAmp * Math.sin(dropPhase);
+ 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;
+ /* MISS = แค่ overlapMiss (บล็อกไม่ทับบล็อกล่างพอ) เท่านั้น.
+ เดิมมี driftMiss เทียบ placedCx กับ "กลางพื้นดินตายตัว" (anchorCx=land.cx) → ต่อเอียงเป็นบันได
+ (แต่ละชั้นทับชั้นล่างพอดี) พอ center สะสมห่างจากกลางพื้น >38% ฐาน บล็อกที่วางดีๆ ก็ถูกนับ miss
+ = อาการ "ตึกโยกตกทั้งที่ไม่ได้อยู่ริมๆ". ตัดออก — overlapMiss คือเงื่อนไขตกที่ถูกต้องอยู่แล้ว. */
+ const miss = overlapMiss;
+ 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;
+}
+/* สี block ของ "คนจริง" = lobbyColorThemeIndex (1-8) → ตรงกับสี avatar ทุกจอ
+ (เดิมโค้ด stack ใช้ p.lobbyThemeIndex ที่ไม่เคยถูกเซ็ต → null เสมอ; และ (x%8)+1 บนค่า 1-based = เลื่อนสีผิด 1 ช่อง) */
+function normStackTheme8(n) {
+ const v = Math.floor(Number(n));
+ return Number.isFinite(v) ? ((v - 1) % 8 + 8) % 8 + 1 : null;
+}
+function stackHumanThemeServer(p) {
+ return p ? normStackTheme8(p.lobbyColorThemeIndex) : null;
+}
+/* สี block ของบอท AI = สี avatar บอท slot นั้น (lobbyBotThemeBySlot[slot].themeIndex) ไม่ใช่ (slot%8)+1 */
+function stackBotThemeServer(space, slot) {
+ const m = space && space.lobbyBotThemeBySlot;
+ const t = m && m[slot] ? normStackTheme8(m[slot].themeIndex) : null;
+ return t || (((Math.floor(Number(slot)) % 8) + 8) % 8 + 1);
+}
+/* สร้างลำดับตา: humans (เรียง spawnJoinOrder) + bots เติมที่ว่าง ≤ 6 ที่นั่ง */
+function buildStackTurnOrderServer(space, s) {
+ try { syncLobbyBotThemeSlots(space); } catch (_e) { /* ให้ lobbyBotThemeBySlot ตรงกับสีที่ client ได้รับ → สี block บอทตรง avatar */ }
+ 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: stackHumanThemeServer(p) });
+ seat++;
+ }
+ /* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้ */
+ for (const [botId, b] of s.bots) {
+ if (!b || !b.takeover) continue;
+ if (seat > STACK_SEATS) break;
+ order.push({ kind: 'bot', seat, id: null, botId, theme: (b.theme != null ? b.theme : ((seat % 8) + 1)) });
+ 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: stackBotThemeServer(space, slot) });
+ 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,
+ /* full-sync หัวใจ + decrypt% → client ที่พลาด packet stack-drop (ช้า/churn) self-correct
+ (เดิมส่งเฉพาะตอน drop → พลาดแล้ว "หัวใจไม่ลด/% ไม่ตรง" ค้าง) */
+ teamMissesLeft: s.teamMissesLeft,
+ progressPct: s.progressPct,
+ });
+}
+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; }
+ }
+ });
+ /* [TC183] verify คะแนน MG3: team score + คะแนนต่อคน (ดูว่าสูงผิดปกติ/ซ้ำจากชื่อซ้ำไหม) */
+ try {
+ const per = s.turnOrder.map((e) => {
+ if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); return 'bot=' + Math.max(0, (b && b.score) | 0); }
+ if (e.id != null) { const p = space.peers.get(e.id); return ((p && p.nickname) || e.id).toString().slice(0, 6) + '=' + Math.max(0, s.humanPts[e.id] | 0); }
+ return '?';
+ });
+ glog(sid, space, 'stack-score', '[TC183] จบ MG3 outcome=' + outcome + ' team=' + (s.score | 0) + ' layers=' + s.layers.length + ' progress=' + Math.floor(s.progressPct || 0) + '% · ต่อคน: ' + per.join(' '));
+ } catch (e) { /* ignore */ }
+ 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) — ยังดรอปไม่ได้ (drop handler เช็ค busyUntil) แต่ให้ "บล็อกถัดไปแกว่งต่อ"
+ (advance phase + emit swing) → ตาถัดไปไม่เห็นบล็อกห้อยนิ่ง = ไม่ค้าง · client มี guard (!stackFall) ไม่ให้กระตุกตอนยังร่วง */
+ if (now < s.busyUntil) {
+ 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, t: now });
+ }
+ 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, t: now }); }
+ if (now >= s.botThinkUntil) {
+ /* คิดเสร็จ → ล็อกเฟสเป้าหมาย "ข้างหน้า" ครั้งเดียว แล้วรอแกว่งวิ่งมาถึงเองธรรมชาติ
+ (ไม่สแนปเฟส = ไม่กระตุก) ปล่อยเมื่อถึงเป้า หรือเลย deadline กันค้าง */
+ if (s.botAimPhase == null) {
+ s.botAimPhase = stackBotComputeAimPhaseServer(s, md, tier);
+ /* deadline ต้องยาวพอให้สวิง "ถึงเป้า" จริง = เวลาแกว่งจาก phase ปัจจุบันไปถึง aimPhase + buffer.
+ เดิม 2600ms ตายตัว → ตอน hz ต่ำ (สวิงช้า) กว่าจะถึงเป้าใช้ >2.6s → deadline เตะก่อน = บอทปล่อยผิด phase (เฉียง/miss) */
+ const _gap = Math.max(0, s.botAimPhase - s.phase);
+ const _radPerSec = Math.max(1e-4, s.phaseSpeed * Math.PI * 2);
+ s.botAimDeadline = now + Math.min(15000, Math.ceil((_gap / _radPerSec) * 1000) + 1500);
+ }
+ 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,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = now + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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, t: now }); }
+ }
+ /* 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);
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนจัดลำดับตา (buildStackTurnOrderServer จะใส่เป็น seat ให้) */
+ {
+ const s3 = space.stackSession;
+ drainPendingMissionTakeovers(space, 'stack', (rec) => {
+ s3.bots.set(rec.id, {
+ tier: 'avg', score: 0, takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ theme: normStackTheme8(rec.themeIdx),
+ });
+ });
+ }
+ 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 6), effectiveBotSlotCount(space)));
+ const bannedSlot = jsBannedBotSlot(space);
+ let poolIdx = 1 + humanCount + existing;
+ 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedJumpSurviveTakeoverBots(space, md);
+ createJumpSurviveBots(space, md);
+}
+function seedJumpSurviveTakeoverBots(space, md) {
+ const s = space.jumpSurviveSession;
+ if (!s) return;
+ const now = Date.now();
+ drainPendingMissionTakeovers(space, 'jump_survive', (rec) => {
+ s.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ vy: 0, onGround: false, dir: rec.direction || 'down', walking: false,
+ eliminated: !!rec.jumpSurviveEliminated,
+ wishX: 0, wishNext: now + 200, nextJumpAi: now + 200,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ยัง emit elim:true ต่อทุก tick (เดิม return ข้าม = ส่ง elim ครั้งเดียว → client พลาด = ค้างนิ่ง) */
+ if (b.eliminated) { outBots.push({ id, x: b.x, y: b.y, dir: b.dir || 'down', walking: false, elim: true }); 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 });
+ });
+ /* ผู้เล่นจริงที่สลับแท็บ (hidden) → จำลองการตกแทน timer ฝั่ง client ที่ถูก throttle → ส่งตำแหน่ง full-rate (คนอื่นเห็นลื่น).
+ ไม่มี input (ตก/ไหลตามแท่นเฉย ๆ) เหมือนคนปล่อยจอ. ตาย → mark eliminated + ตั้ง p.jumpSurviveEliminated (เข้าเช็ค all_dead/เกรด) */
+ if (s.players) {
+ for (const pid in s.players) {
+ const st = s.players[pid];
+ if (!st || !st.hidden || st.eliminated) continue;
+ if (!space.peers.has(pid)) { st.hidden = false; continue; } /* หลุดห้องแล้ว → ปล่อยให้ disconnect logic จัดการ */
+ const pxc = ((Number.isFinite(st.x) ? st.x : 1) + cw * 0.5) * ts;
+ const feetY = ((Number.isFinite(st.y) ? st.y : 1) + ch) * ts;
+ const r = jsPhysicsIntegrate(md, ts, sc, {
+ dt, ch, pxc0: pxc, feetY0: feetY, vy0: st.vy || 0, wasOnGround: !!st.onGround,
+ jumpQueued: false, moveLeft: false, moveRight: false,
+ });
+ st.x = r.pxc / ts - cw * 0.5;
+ st.y = r.feetY / ts - ch;
+ st.vy = r.vy; st.onGround = r.onGround;
+ if (r.died) {
+ st.eliminated = true; st.hidden = false; st.vy = 0;
+ const p = space.peers.get(pid); if (p) p.jumpSurviveEliminated = true;
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: false, elim: true });
+ } else {
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: Math.abs(r.vy) > 40, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" ในการจำกัด 6 ช่อง กันรวมเกิน 6 */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนสร้างบอท AI (createSpaceShooterBots นับ s.bots.size เป็น human-like) */
+ seedSpaceShooterTakeoverBots(space, md);
+ createSpaceShooterBots(space, md);
+}
+function seedSpaceShooterTakeoverBots(space, md) {
+ const s = space.spaceShooterSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ drainPendingMissionTakeovers(space, 'space_shooter', (rec) => {
+ const cx = Number.isFinite(rec.x) ? Math.max(ts * 0.5, Math.min(mw - ts * 0.5, (rec.x + 0.5) * ts)) : mw * 0.5;
+ const cy = ssClampCy(Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.5, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', cx, cy, dir: 'up',
+ score: rec.spaceShooterScore || 0, eliminated: !!rec.spaceShooterEliminated, 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() + 500,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ไม่เดิน/ไม่ยิง แต่ "ยัง emit elim:true ต่อ" — เดิม return ข้ามเลย (ส่ง elim ครั้งเดียวตอนตาย)
+ ถ้า client พลาด packet นั้น = ไม่รู้ว่าตาย → ยานค้างนิ่งบนจอ. ส่งซ้ำทุก tick → client ซ่อนได้แน่นอน */
+ if (!b.eliminated) {
+ 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: !!b.eliminated, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedBalloonBossTakeoverBots(space, md);
+ createBalloonBossBots(space, md);
+}
+function seedBalloonBossTakeoverBots(space, md) {
+ const s = space.balloonBossSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ const startBalloons = balloonBossBalloonsForMap(md);
+ drainPendingMissionTakeovers(space, 'balloon_boss', (rec) => {
+ const cx = Number.isFinite(rec.x) ? (rec.x + 0.5) * ts : mw * 0.5;
+ const cy = Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.4;
+ const cl = bbClampWorld(cx, cy, mw, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', homeCx: cl.cx, homeCy: cl.cy, cx: cl.cx, cy: cl.cy,
+ score: rec.balloonBossScore || 0, dmg: rec.balloonBossBossDmg || 0,
+ balloons: rec.balloonBossBalloons || startBalloons, eliminated: !!rec.balloonBossEliminated,
+ fireCd: 0.6 + Math.random() * 0.8, phase: Math.random() * Math.PI * 2,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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 });
+}
+/* ตำแหน่งบอส "เคลื่อนที่จริง" — port จาก client getBalloonBossBossLiveCenterPlay (anchor=liveBeginsAt เดียวกัน → ตรงกันทุกเครื่อง)
+ server เดิมคิดบอสเป็นจุดนิ่ง → บอทไม่รู้ว่าบอสขยับมาทับ. มีตัวนี้ บอทหลบบอสที่เคลื่อนที่ได้ + เล็งยิงตรงบอสจริง */
+function bbBossLiveCenter(md, ts, s, now) {
+ const base = bbBossCenter(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const anchor = (s && s.liveBeginsAt) ? s.liveBeginsAt : now;
+ const tSec = Math.max(0, (now - anchor) / 1000);
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return bbClampWorld(cx, cy, mw, mh);
+}
+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 = bbBossLiveCenter(md, ts, s, now); /* บอสเคลื่อนที่จริง (หลบ+เล็งตามตัวนี้) */
+ const fleeR = Math.max(ts * 3.4, 96); /* หลบบอสเมื่อ home เข้าใกล้ ~3 ช่อง */
+ const fleeSpd = 165; /* px/s ดริฟต์ home หนีบอส (เร็วกว่าบอส → ไม่ติด) */
+ const sepMin = ts * 2.4; /* ระยะ home ขั้นต่ำระหว่างบอท (หลบกันเอง) */
+ const outBots = [];
+ const alive = [];
+ s.bots.forEach((b, id) => {
+ if (b.eliminated) { outBots.push({ id, cx: b.cx, cy: b.cy, score: b.score, dmg: b.dmg, balloons: b.balloons, elim: true }); return; } /* ตายแล้ว: emit elim:true ต่อ (กัน client พลาด packet = ค้างนิ่ง) */
+ alive.push(b);
+ /* หลบบอส: ดริฟต์ "home" ออกจากบอสที่เคลื่อนเข้าใกล้ (แทน clamp ตำแหน่ง = ไม่ติดนิ่งขอบบอส) */
+ const hx = b.homeCx - boss.cx, hy = b.homeCy - boss.cy;
+ const hd = Math.hypot(hx, hy) || 1e-6;
+ if (hd < fleeR) { b.homeCx += (hx / hd) * fleeSpd * dt; b.homeCy += (hy / hd) * fleeSpd * dt; }
+ 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 });
+ });
+ /* หลบกันเอง: ดัน home ที่ใกล้กันเกินให้แยกออก (รอบถัดไปบอทจะ wobble รอบ home ใหม่ → ไม่ซ้อนกัน) */
+ for (let i = 0; i < alive.length; i++) {
+ for (let j = i + 1; j < alive.length; j++) {
+ const a = alive[i], c = alive[j];
+ const dx = a.homeCx - c.homeCx, dy = a.homeCy - c.homeCy;
+ const d = Math.hypot(dx, dy) || 1e-6;
+ if (d < sepMin) { const p = (sepMin - d) * 0.5, nx = dx / d, ny = dy / d; a.homeCx += nx * p; a.homeCy += ny * p; c.homeCx -= nx * p; c.homeCy -= ny * p; }
+ }
+ }
+ alive.forEach((b) => { const cl = bbClampWorld(b.homeCx, b.homeCy, mw, mh); b.homeCx = cl.cx; b.homeCy = cl.cy; }); /* กัน home ดริฟต์หลุดขอบ */
+ 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_' (ตรงกับ 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 };
+}
+
+/* คอมโพเนนต์เดินได้ "หลัก" (BFS จาก spawn slot) — กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated walkable) ที่กำแพงล้อม
+ cache ต่อ space (invalidate เมื่อ mapId เปลี่ยน) */
+function lobbyMainCompServer(md, space) {
+ const mid = space && space.mapId;
+ if (space && space._lobbyMainComp && space._lobbyMainCompMap === mid) return space._lobbyMainComp;
+ const w = md.width || 20, h = md.height || 15;
+ const isW = (x, y) => x >= 0 && x < w && y >= 0 && y < h && !(md.objects && md.objects[y] && md.objects[y][x] === 1);
+ const slots = parseLobbyPlayerSpawnsServer(md).filter(Boolean);
+ let seed = null;
+ for (const s of slots) { const sx = Math.floor(s.x), sy = Math.floor(s.y); if (isW(sx, sy)) { seed = [sx, sy]; break; } }
+ if (!seed && md.spawn && isW(Math.floor(md.spawn.x), Math.floor(md.spawn.y))) seed = [Math.floor(md.spawn.x), Math.floor(md.spawn.y)];
+ if (!seed) { for (let y = 0; y < h && !seed; y++) for (let x = 0; x < w; x++) { if (isW(x, y)) { seed = [x, y]; break; } } }
+ const comp = new Set();
+ if (seed) {
+ comp.add(seed[0] + ',' + seed[1]);
+ const q = [seed];
+ while (q.length) { const [a, b] = q.shift(); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = a + dx, ny = b + dy, k = nx + ',' + ny; if (!comp.has(k) && isW(nx, ny)) { comp.add(k); q.push([nx, ny]); } } }
+ }
+ if (space) { space._lobbyMainComp = comp; space._lobbyMainCompMap = mid; }
+ return comp;
+}
+/* เดินบอท 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;
+ const mainComp = lobbyMainCompServer(md, space);
+ const inMain = (x, y) => !mainComp.size || mainComp.has(Math.floor(x) + ',' + Math.floor(y));
+ bots.forEach((b, id) => {
+ /* กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated) → ดึงกลับ spawn หลัก (main component) */
+ if (!inMain(b.x, b.y)) {
+ const slot = parseInt(id.slice(LOBBY_BOT_PREFIX.length), 10);
+ const sp = pickLobbyBotSpawnServer(md, space, slot);
+ if (inMain(sp.x, sp.y)) { b.x = sp.x + 0.5; b.y = sp.y + 0.5; }
+ else { const first = mainComp.values().next().value; if (first) { const [mx, my] = first.split(',').map(Number); b.x = mx + 0.5; b.y = my + 0.5; } }
+ b.wTX = null; b.wTY = null; b.retargetAt = 0; b.pauseUntil = 0;
+ }
+ 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' ? 'หมดเวลา — จบมินิเกม'
+ : (reason === 'all_dead' ? 'ตกขอบหมดแล้ว — จบมินิเกม' : 'จบมินิเกม');
+ 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;
+ }
+
+ /* ตายหมดแล้ว (all_dead) — อยู่ในช่วง grace: หยุดเกม + ให้เวลา client วาดจอ result-gameover → GCM
+ ก่อน server ดึงกลับ LobbyB. เดิม all_dead จบ+return ทันที (0 grace) = client ยังไม่ทันวาด game over
+ ก็โดนดึงกลับ = "ตายหมดแต่ไม่ขึ้น game over". ใช้ grace เท่ากับทางชนะ (GAUNTLET_FINISH_WALK_MS) */
+ if (gr.gameoverAt != null) {
+ if (gr.obstacles && gr.obstacles.length) gr.obstacles = [];
+ if (Date.now() >= gr.gameoverAt) {
+ endGauntletGame(sid, space, 'all_dead');
+ return;
+ }
+ /* ไม่ emit sync ระหว่าง grace — ปล่อย client คุม overlay result เอง (เหมือน finish grace) */
+ return;
+ }
+
+ /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client
+ เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */
+ if (gr.finishPhase) {
+ if (gr.obstacles.length) { gr.obstacles = []; }
+ /* ตายหมดตั้งแต่ก่อน/ระหว่าง finish-walk grace → เข้า grace game over (แทน result-complete จากทางเส้นชัย)
+ (race: client ตัวหนึ่ง scroll ถึงเส้นชัยก่อน → set finishPhase → early-return ข้ามเช็ค all_dead ท้าย tick) */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ตายหมด (ระหว่าง finish-phase) → grace game over → กลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ return;
+ }
+ 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);
+ });
+ }
+
+ /* ผู้เล่น (คนจริง) ตกขอบหมด → เข้า grace game over (ไม่รอหมดเวลา) แล้วค่อย return LobbyB
+ ส่ง sync ครั้งสุดท้าย (ทุกคน eliminated) ให้ client ทริก overlay result-gameover ก่อนหมด grace */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ผู้เล่นตกขอบหมด → grace game over แล้วกลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ emitGauntletSync(sid, space);
+ return;
+ }
+
+ emitGauntletSync(sid, space);
+}
+
+/** MG2 crown/gauntlet: คนจริงที่ "ยังเล่นอยู่" (ไม่นับผู้ถูกแบน/ผู้ชม) ตกขอบหมด = จบเกม all_dead
+ บอทอยู่ใน gr.bots ไม่ใช่ space.peers → ไม่ถูกนับอยู่แล้ว (server ตัดสินจากคนจริงเท่านั้น)
+ - นับเฉพาะ crown map · ต้องมีคนเล่นจริง ≥1 คน (กันจบทันทีตอน 0 คน) */
+function gauntletAllHumansEliminated(space) {
+ if (!isGauntletCrownHeistMapBySpace(space) || !space.peers || space.peers.size === 0) return false;
+ let active = 0;
+ let alive = 0;
+ space.peers.forEach((p) => {
+ if (!p || p.bannedSpectator) return; /* ผู้ถูกแบน = เข้ามาดู ไม่ได้เล่น → ไม่บล็อกการจบเกม */
+ active++;
+ if (!p.gauntletEliminated) alive++;
+ });
+ return active > 0 && alive === 0;
+}
+
+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: clampTroublesomeOfferSec(runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) });
+}
+
+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) {
+ const rollMaxMs = Math.max(1000, Math.round(clampTroublesomeRollMaxSec(runtimeGameTiming && runtimeGameTiming.troublesomeRollMaxSec) * 1000));
+ space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), rollMaxMs);
+ }
+}
+
+/**
+ * ห้องทดสอบเอดิเตอร์ / 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) {
+ /* rejoin whitelist: เช็ค playerKey เป็นหลัก (กันเคส 2 คนชื่อซ้ำ — ลบ nick คนนึงตอนออก ทำให้อีกคน rejoin ไม่ได้)
+ fallback เป็นชื่อ (คนที่ไม่มี playerKey / ความเข้ากันได้เดิม) */
+ const jk = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? ('k:' + playerKey) : '';
+ const wlKeys = space.caseParticipantKeys;
+ const wlNicks = space.caseParticipantNicknames;
+ const okByKey = !!(jk && wlKeys && wlKeys.size > 0 && wlKeys.has(jk));
+ const okByNick = !!(joinNickNorm && wlNicks && wlNicks.size > 0 && wlNicks.has(joinNickNorm));
+ if (!okByKey && !okByNick) {
+ 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 }); });
+ }
+ /* MG3–MG7 pending takeover (คนหลุดช่วง how-to/นับถอยหลัง — ยังไม่มี session live): กลับมาทันก่อนเกมเริ่ม
+ → ล้าง pending ที่ playerKey ตรง กัน drainPendingMissionTakeovers seed เป็นบอทซ้ำตอน startXxxGame
+ ROOT CAUSE "บอทเข้าไปเล่นด้วย MG3/5/7": rejoin เดิมถอนบอทเฉพาะ session ที่ live แล้ว (บล็อกข้างบน)
+ แต่ shell (MG3-7) สร้าง session ตอน 3-2-1 จบ → ตอน rejoin ยังไม่มี session = ไม่มีอะไรถอน +
+ pending ค้าง → seed เป็น "บอทผี" คู่กับคนจริงที่ rejoin. emit user-left(id เก่า) ลบ avatar บอท placeholder */
+ if (Array.isArray(space.pendingMissionTakeovers) && space.pendingMissionTakeovers.length) {
+ const cancelled = space.pendingMissionTakeovers.filter((r) => r && r.playerKey && r.playerKey === joinPlayerKey);
+ if (cancelled.length) {
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => !(r && r.playerKey && r.playerKey === joinPlayerKey));
+ cancelled.forEach((r) => { if (r.id) io.to(spaceId).emit('user-left', { id: r.id }); });
+ glog(spaceId, space, 'join', '[TC173] rejoin ก่อนเกมเริ่ม → ยกเลิก pending bot takeover ' + cancelled.length + ' (กันบอทผี MG3/5/7 · pass)', nickname);
+ }
+ }
+ }
+ /* 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,
+ netReady: false, /* เพิ่ง join → กำลังโหลด จนกว่า client ส่ง client-ready (badge ให้คนอื่นเห็นว่า "กำลังโหลด" ไม่ใช่ค้าง) */
+ };
+ 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),
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ botSlotCount: effectiveBotSlotCount(space),
+ lobbyBotThemes: syncLobbyBotThemeSlots(space),
+ joinLocked: !!space.joinLocked,
+ missionLiveBeginsAt: space.missionLiveBeginsAt || null,
+ missionLive: isMinigameLiveForRejoinResume(space), /* rejoin กลางเกม live → client ข้าม how-to เข้าเล่นต่อ */
+ };
+ 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', (data) => {
+ 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;
+ /* ใช้เฟสที่ผู้เล่นเห็นจริงตอนกด (client ส่งมา) → บล็อกลงตรงจุดที่เห็นแกว่ง ไม่เยื้องตาม half-RTT/latency.
+ sanity bound กว้าง (STACK_DROP_PHASE_TOL = 2π) เหลือไว้กันค่า garbage เท่านั้น (ดู const) */
+ let dropPhase = s.phase;
+ const cph = data && Number(data.phase);
+ if (Number.isFinite(cph) && Math.abs(cph - s.phase) <= STACK_DROP_PHASE_TOL) dropPhase = cph;
+ const hit = stackComputeDropServer(s, md, dropPhase);
+ stackApplyHitServer(s, hit, { id: entry.id });
+ const peer = space.peers.get(socket.id);
+ const theme = stackHumanThemeServer(peer) || (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: dropPhase,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = Date.now() + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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 }] });
+ }
+ });
+
+ /* MG5 — ผู้เล่นสลับแท็บ (hidden): browser throttle setInterval เหลือ ~1Hz → emit move ~1/วิ = คนอื่นเห็นตกเป็นก้อน.
+ แก้: client แจ้ง hidden พร้อม state ปัจจุบัน → server จำลองการตก (jsPhysicsIntegrate) แทน ใน stepJumpSurviveBots
+ แล้ว broadcast ผ่าน jump-survive-bot-move (full rate) → คนอื่นเห็นลื่น. visible → hidden:false → client คุมเอง. */
+ socket.on('jump-survive-hidden', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !data) return;
+ const s = space.jumpSurviveSession;
+ if (!s || !s.active || s.ended) return;
+ if (!s.players[socket.id]) s.players[socket.id] = {};
+ const st = s.players[socket.id];
+ if (data.hidden) {
+ if (st.eliminated) return;
+ st.hidden = true;
+ st.x = Number.isFinite(data.x) ? data.x : (Number.isFinite(st.x) ? st.x : 1);
+ st.y = Number.isFinite(data.y) ? data.y : (Number.isFinite(st.y) ? st.y : 1);
+ st.vy = Number.isFinite(data.vy) ? data.vy : 0;
+ st.onGround = !!data.onGround;
+ st.dir = data.direction || st.dir || 'down';
+ } else {
+ st.hidden = false;
+ }
+ });
+
+ /** เปลี่ยนชื่อที่แสดงใน 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) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK เริ่มสืบสวน — รอผู้เล่นกลับเข้า LobbyB ครบ (' + waitInfo.present + '/' + waitInfo.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({
+ ok: false,
+ waitingPlayers: true,
+ present: waitInfo.present,
+ total: waitInfo.total,
+ error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')',
+ });
+ }
+ /* TC156: gate จนกว่าทุกคนอ่าน story (cutscene) ครบ — เฉพาะรอบแรก (storyReadComplete เป็น true หลังครบ/รอบถัดไป) */
+ if (!space.storyReadComplete) {
+ const stRead = detectiveStoryReadStatus(space);
+ if (!stRead.allRead) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'story-gate', '[TC156] BLOCK เริ่มสืบสวน — รอทุกคนอ่าน story ครบ (' + stRead.present + '/' + stRead.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingStory: true, present: stRead.present, total: stRead.total, error: 'รอผู้เล่นอ่านเนื้อเรื่องให้ครบก่อน (' + stRead.present + '/' + stRead.total + ')' });
+ }
+ space.storyReadComplete = true;
+ }
+ 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' });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'vote-gate', '[TC157] เริ่ม vote ban (ผู้เล่นเข้า LobbyB ครบ ' + waitInfo.present + '/' + waitInfo.total + ') — มินิเกมจะเริ่มหลังโหวตจบเท่านั้น (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ 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);
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ ' + waitInfo.total + ' คน · ผู้ต้องสงสัย #' + (selectedIndex + 1), (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ reply(started);
+ });
+
+ socket.on('detective-minigame-finished', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) {
+ return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ }
+ /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
+ const finPeer = space.peers.get(socket.id);
+ if (finPeer && data && typeof data === 'object') {
+ /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
+ หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */
+ if (Number.isFinite(Number(data.score))) {
+ let rs = Math.max(0, Math.floor(Number(data.score)));
+ if (rs > REPORTED_MINI_SCORE_MAX) {
+ console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX);
+ rs = REPORTED_MINI_SCORE_MAX;
+ }
+ finPeer.reportedMiniScore = rs;
+ }
+ if (data.survived === false) finPeer.reportedMiniSurvived = false;
+ }
+ if (!space.detectiveMinigameActive) {
+ if (serverMapIsPostCaseLobbyB(space)) {
+ const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
+ return reply({
+ ok: true,
+ alreadyOnLobbyB: true,
+ suspectIndex: grant.suspectIdx,
+ awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
+ awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
+ grade: grant.grade || null,
+ freeEvidenceCount: grant.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ }
+ return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
+ }
+ /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก"
+ จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด
+ ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */
+ const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) {
+ if (finPeer) finPeer.detectiveFinished = true;
+ const allDone = [...space.peers.values()].every((p) =>
+ p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished
+ );
+ if (!allDone) {
+ /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */
+ return reply({ ok: true, holdInScene: true });
+ }
+ /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */
+ }
+ // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
+ returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
+ const g = space.lastEvidenceGrant || {};
+ reply({
+ ok: true,
+ suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
+ awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
+ awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
+ grade: g.grade || null,
+ freeEvidenceCount: g.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ /**
+ * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที
+ * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)
+ */
+ socket.on('detective-debug-award-evidence', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ const idx = Math.floor(Number(data && data.suspectIndex));
+ if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' });
+ const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C');
+ if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' });
+ emitLobbyEvidenceSync(sid, space);
+ reply({
+ ok: true,
+ suspectIndex: idx,
+ awardedCard: card,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย)
+ socket.on('suspect-accuse-open', (_data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
+ const prog = playerSuspectProgressFromEvidence(space, socket.id);
+ if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
+ /* ชี้ตัวคนร้าย = เข้าพิจารณาคดี → ต้องรอผู้เล่นในคดีกลับเข้า LobbyB ครบก่อน (กันคนตกรอบโหวต) */
+ const accuseWait = detectiveLobbyBMissingParticipants(space);
+ if (accuseWait.missing > 0) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK ชี้ตัวคนร้าย — รอผู้เล่นเข้า LobbyB ครบ (' + accuseWait.present + '/' + accuseWait.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingPlayers: true, present: accuseWait.present, total: accuseWait.total, error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + accuseWait.present + '/' + accuseWait.total + ')' });
+ }
+ 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;
+ space.trialVoteAt = space.trialVoteAt || {};
+ space.trialVoteAt[socket.id] = Date.now(); /* [achv c4] เวลาโหวต → เช็ค "10 วิสุดท้าย" */
+ emitTrialVoteUpdate(sid, space);
+ const totalTV = trialEligibleVoterTotal(space);
+ const votedNow = Object.keys(space.trialVotes).length;
+ try { glog(sid, space, 'trial-vote', '[TC180] ยืนยันโหวต ' + votedNow + '/' + totalTV + (votedNow >= totalTV && totalTV > 0 ? ' — ครบ → reveal (pass)' : ' — รออีก')); } catch (e) { /* ignore */ }
+ if (votedNow >= 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)) {
+ /* ล็อก: host กำลังเปิด wizard เลือกระดับ/คดี → ปฏิเสธ set-ready (กัน client หลุด flow ตอน host เลือกอยู่) */
+ if (space.preplayOpen && space.hostId !== socket.id) {
+ const p = space.peers.get(socket.id);
+ io.to(socket.id).emit('peer-ready', { id: socket.id, ready: !!(p && p.ready) }); /* ย้ำสถานะเดิมกลับให้ผู้ส่ง */
+ try { glog(sid, space, 'preplay-lock', '[TC154] REJECT set-ready ระหว่าง host เลือกคดี — feature ทำงาน (pass): client ยกเลิกพร้อมไม่ได้', (p && p.nickname) || String(socket.id).slice(0, 6)); } catch (e) { /* ignore */ }
+ break;
+ }
+ const p = space.peers.get(socket.id);
+ if (p) p.ready = !!ready;
+ io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
+ break;
+ }
+ }
+ });
+
+ /* LobbyA: host เปิด/ปิด wizard เลือกระดับ/คดี → เก็บ state ที่ server + relay ทั้งห้อง (ล็อกปุ่มพร้อมฝั่ง client) */
+ socket.on('lobby-preplay-open', ({ open } = {}) => {
+ for (const [sid, space] of spaces) {
+ if (space.peers.has(socket.id) && space.hostId === socket.id) {
+ space.preplayOpen = !!open;
+ io.to(sid).emit('lobby-preplay-open', { open: !!open });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'preplay-lock', '[TC154] ' + (open ? 'host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง' : 'host ปิด wizard → ปลดล็อก set-ready') + ' (' + space.peers.size + ' คนในห้อง)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ break;
+ }
+ }
+ });
+
+ /* TC156: client อ่าน story (cutscene) จบ → บันทึกว่าคนนี้พร้อม · ครบทุกคน → ปลดล็อกเลือกผู้ต้องสงสัย */
+ socket.on('story-read-done', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.storyReadKeys) space.storyReadKeys = new Set();
+ const already = space.storyReadKeys.has(key);
+ space.storyReadKeys.add(key);
+ /* อ่าน story จบ = โหลด LobbyB เสร็จแน่นอน → นับ lobbyb-loaded ด้วย (reliable กว่า event lobbyb-loaded เดี่ยว) */
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ space.lobbyBLoadedKeys.add(key);
+ const st = detectiveStoryReadStatus(space);
+ if (!already) { try { glog(sid, space, 'story-gate', '[TC156] อ่าน story จบ ' + st.present + '/' + st.total, nick); } catch (e) { /* ignore */ } }
+ if (st.allRead && !space.storyReadComplete) {
+ space.storyReadComplete = true;
+ if (space.storyReadTimer) { clearTimeout(space.storyReadTimer); space.storyReadTimer = null; }
+ try { glog(sid, space, 'story-gate', '[TC156] ทุกคนอ่าน story ครบ (' + st.total + ') → ปลดล็อกเลือกผู้ต้องสงสัย (pass)'); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveStoryReadPresence(sid, space);
+ /* piggyback เพิ่ม lobbyBLoadedKeys แล้ว → ต้อง broadcast lobbyb-presence ใหม่ด้วย
+ ไม่งั้น client ค้างโชว์ "รอผู้เล่น (2/3)" (ค่าเก่า) → ปุ่ม host ถูก disable ทั้งที่ครบแล้ว */
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉาก LobbyB เสร็จจริง → นับเป็น "present" ได้ (กัน host เริ่มก่อนคนโหลดเสร็จ = ค้างตอนจบ) */
+ socket.on('lobbyb-loaded', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ if (!space.lobbyBLoadedKeys.has(key)) {
+ space.lobbyBLoadedKeys.add(key);
+ const info = detectiveLobbyBMissingParticipants(space);
+ try { glog(sid, space, 'lobbyb-gate', '[TC144] โหลด LobbyB เสร็จ ' + info.present + '/' + info.total, nick); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉากเสร็จ (ทุกฉาก lobby/minigame) → เอา badge "กำลังโหลด" ออก (คนอื่นเห็นว่าพร้อมแล้ว) */
+ socket.on('client-ready', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ const p = space.peers.get(socket.id);
+ if (p && p.netReady !== true) {
+ p.netReady = true;
+ io.to(sid).emit('peer-net', { id: socket.id, nr: true });
+ }
+ 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) {
+ space.preplayOpen = false; /* host เริ่มเกมแล้ว → ปลดล็อก (client เปลี่ยนฉากอยู่แล้ว แต่เคลียร์ state กัน stuck) */
+ 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();
+ const caseKeyWl = new Set();
+ space.peers.forEach((p) => {
+ caseNickWl.add(normalizeLobbyNickname(p.nickname)); /* ชื่อ — ใช้ whitelist rejoin (คงเดิม) */
+ const k = detectiveParticipantKey(p);
+ if (k) caseKeyWl.add(k); /* playerKey — ใช้ presence/story/gate (กันชื่อซ้ำนับเป็นคนเดียว) */
+ });
+ space.caseParticipantNicknames = caseNickWl;
+ space.caseParticipantKeys = caseKeyWl;
+ /* TC156: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story (cutscene) ครบ — reset ที่ initial LobbyA→LobbyB เท่านั้น
+ (รอบ investigate ถัดไปไม่มี cutscene → storyReadComplete คงเป็น true) · safety timer กัน deadlock ถ้า client ไม่ emit */
+ space.storyReadKeys = new Set();
+ space.storyReadComplete = false;
+ space.lobbyBLoadedKeys = new Set(); /* เริ่มคดี → ทุกคนต้องส่ง lobbyb-loaded ใหม่ ก่อนนับ present */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock */
+ if (space.storyReadTimer) clearTimeout(space.storyReadTimer);
+ space.storyReadTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (sp && !sp.storyReadComplete) {
+ sp.storyReadComplete = true;
+ try { glog(sid, sp, 'story-gate', '[TC156] safety-timeout 90s → force ปลดล็อก (client บางคนไม่ส่ง story-read-done)'); } catch (e) { /* ignore */ }
+ try { emitDetectiveStoryReadPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 90000);
+ 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 ยังมีอยู่ — รวมช่วง how-to / pre-run hold (crownRunHeld) ด้วย เหมือน MG1
+ (เดิมข้าม crownRunHeld → host หลุดตอน how-to = avatar หายไปเลย ไม่มีบอทรับช่วง; ตอนนี้แปลงเป็นบอททันที
+ บอทจะค้างในเลนตอน how-to แล้ววิ่งเองเมื่อเกมเริ่ม — emitGauntletSync ส่งบอท takeover ระหว่าง hold อยู่แล้ว) */
+ if (grT && md9b && md9b.gameType === 'gauntlet') {
+ 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 */ }
+ /* MG3–MG7 หลุดช่วง how-to / นับถอยหลัง (ยังไม่มี session live) → เก็บ pending takeover ไว้
+ แล้ว seed เป็นบอทตอนเกมเริ่ม + แจ้ง client เก็บ avatar (*-player-to-bot) เหมือน MG1/MG2
+ (เดิม: ไม่มี session = ไม่ takeover = avatar หายเลยตอน how-to) */
+ let shellTakeoverEvent = null;
+ try {
+ if (!mg1Takeover && !mg2Takeover && !mg3Takeover && !mg4Takeover && !mg5Takeover && !mg6Takeover && !mg7Takeover && space.detectiveMinigameActive) {
+ const gtShell = missionShellGameTypeForSpace(space);
+ const liveActive = !!(
+ (gtShell === 'space_shooter' && space.spaceShooterSession && space.spaceShooterSession.active) ||
+ (gtShell === 'jump_survive' && space.jumpSurviveSession && space.jumpSurviveSession.active) ||
+ (gtShell === 'balloon_boss' && space.balloonBossSession && space.balloonBossSession.active) ||
+ (gtShell === 'quiz_carry' && space.quizCarrySession && space.quizCarrySession.active) ||
+ (gtShell === 'stack' && space.stackSession && space.stackSession.active)
+ );
+ if (gtShell && !liveActive) {
+ recordPendingMissionTakeover(space, gtShell, socket.id, leftPeerInfo, leftPlayerKey, leftNick, leftWasEliminated);
+ shellTakeoverEvent =
+ gtShell === 'space_shooter' ? 'space-shooter-player-to-bot' :
+ gtShell === 'jump_survive' ? 'jump-survive-player-to-bot' :
+ gtShell === 'balloon_boss' ? 'balloon-boss-player-to-bot' :
+ gtShell === 'quiz_carry' ? 'quiz-carry-player-to-bot' :
+ gtShell === 'stack' ? 'stack-player-to-bot' : null;
+ }
+ }
+ } 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.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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.pendingMissionTakeovers = []; /* ห้องว่าง → ล้าง pending takeover ค้าง (กัน seed บอทผีรอบหน้า) */
+ 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) {
+ /* host หลุดกลางเปิด wizard เลือกคดี → ปลดล็อกปุ่มพร้อมให้คนที่เหลือ (กัน stuck lock) */
+ if (space.preplayOpen) { space.preplayOpen = false; io.to(sid).emit('lobby-preplay-open', { open: false }); try { glog(sid, space, 'preplay-lock', '[TC154] host หลุดกลางเปิด wizard → auto-ปลดล็อก set-ready (กัน stuck)', leftNick); } catch (e) { /* ignore */ } }
+ 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 if (shellTakeoverEvent) {
+ /* how-to/นับถอยหลัง: client เก็บ avatar + ติดป้าย (บอท) — บอท server จะ seed ตอนเกมเริ่ม (pendingMissionTakeovers) */
+ io.to(sid).emit(shellTakeoverEvent, { id: socket.id, nickname: leftNick });
+ glog(sid, space, 'leave', 'หลุดช่วง how-to → บอทรับช่วงตอนเกมเริ่ม · เหลือคน ' + 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 */ }
+ /* เอา participantKey ออกจาก roster gate ด้วย (playerKey — กัน gate รอคนที่ออกถาวร) */
+ try {
+ if (sp.caseParticipantKeys) {
+ if (leftPlayerKey && /^[a-zA-Z0-9_-]{8,128}$/.test(leftPlayerKey)) sp.caseParticipantKeys.delete('k:' + leftPlayerKey);
+ sp.caseParticipantKeys.delete('n:' + 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', (data) => {
+ 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;
+ const changed = quizCarryServerTryInteract(sid, space, socket.id, false, data);
+ /* กดแล้วไม่เกิดอะไร (หยิบไม่ได้/ส่งไม่ได้) → ยืนยัน held จริงกลับไปให้ "คนกด" เพื่อแก้ optimistic prediction
+ ที่อาจค้างผิด (เช่น ทำนายว่าหยิบได้แต่ server ปฏิเสธเพราะคนอื่นถืออยู่) */
+ if (!changed) {
+ const hp = s.players[socket.id];
+ io.to(socket.id).emit('quiz-carry-held', { id: socket.id, held: hp ? (hp.held == null ? null : hp.held) : null });
+ }
+ });
+
+ /* ตอบเวลา 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));
\ No newline at end of file
diff --git a/www/html/Game/server.js.bak-20260706-033305 b/www/html/Game/server.js.bak-20260706-033305
new file mode 100644
index 0000000..f13d5b6
--- /dev/null
+++ b/www/html/Game/server.js.bak-20260706-033305
@@ -0,0 +1,13266 @@
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { Server } = require('socket.io');
+
+const PORT = parseInt(process.env.PORT, 10) || 3001;
+/** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */
+const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0';
+const BASE_PATH = '/Game';
+const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
+const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
+const adminTokens = new Set();
+/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
+const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
+/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */
+const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
+/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */
+/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */
+const DETECTIVE_MINIGAME_POOL = [
+ { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' },
+ { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' },
+ { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' },
+ { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' },
+ { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' },
+ { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' },
+ { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' },
+];
+/** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */
+function normalizeForcedMinigameKeys(v) {
+ let arr;
+ if (Array.isArray(v)) arr = v;
+ else if (v != null && String(v).trim()) arr = [v, v, v];
+ else arr = [];
+ const one = (k) => {
+ const s = (k == null ? '' : String(k)).trim();
+ return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : '';
+ };
+ return [one(arr[0]), one(arr[1]), one(arr[2])];
+}
+const LOBBY_SLOT_TOTAL = 6;
+
+/* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้
+ ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */
+const LIVE_COUNTDOWN_MS = 3500;
+const LOBBY_THEME_COUNT = 8;
+const LOBBY_SKIN_COUNT = 3;
+
+/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
+function getTakenLobbyThemeIndices(space, excludePeerId) {
+ const taken = new Set();
+ if (!space) return taken;
+ space.peers.forEach((p, id) => {
+ if (excludePeerId && id === excludePeerId) return;
+ const ti = parseInt(p.lobbyColorThemeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ const bots = space.lobbyBotThemeBySlot;
+ if (bots && typeof bots === 'object') {
+ Object.keys(bots).forEach((k) => {
+ const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ }
+ return taken;
+}
+
+function pickFreeLobbyThemeIndex(space, excludePeerId) {
+ const taken = getTakenLobbyThemeIndices(space, excludePeerId);
+ for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
+ if (!taken.has(i)) return i;
+ }
+ const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
+ return ((n - 1) % LOBBY_THEME_COUNT) + 1;
+}
+
+function pickLobbySkinIndexForSlot(slotIndex) {
+ const s = Number(slotIndex);
+ if (!Number.isFinite(s)) return 1;
+ return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
+}
+
+/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น
+ 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;
+ /* เลือกผู้เล่น ≥ 4 คนตอนสร้างห้อง → ไม่เติมบอท (รอคนจริง) — เว้นแต่ host ตั้งบอทเองภายหลัง (n>0) */
+ if (n === 0 && maxP >= 4) return 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');
+const SOUND_SETTINGS_PATH = path.join(__dirname, 'data', 'sound-settings.json');
+const SOUND_SETTINGS_DEFAULT = { masterVol: 1.0, musicVol: 0.35, sfxVol: 0.75 };
+function loadSoundSettings() {
+ try {
+ if (fs.existsSync(SOUND_SETTINGS_PATH)) {
+ const j = JSON.parse(fs.readFileSync(SOUND_SETTINGS_PATH, 'utf8'));
+ const clamp = (v, d) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : d; };
+ return {
+ masterVol: clamp(j.masterVol, SOUND_SETTINGS_DEFAULT.masterVol),
+ musicVol: clamp(j.musicVol, SOUND_SETTINGS_DEFAULT.musicVol),
+ sfxVol: clamp(j.sfxVol, SOUND_SETTINGS_DEFAULT.sfxVol),
+ };
+ }
+ } catch (e) { console.error('loadSoundSettings', e.message); }
+ return Object.assign({}, SOUND_SETTINGS_DEFAULT);
+}
+function saveSoundSettings(s) {
+ try { fs.writeFileSync(SOUND_SETTINGS_PATH, JSON.stringify(s, null, 2)); } catch (e) { console.error('saveSoundSettings', e.message); }
+}
+
+/* ===== 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 นี้
+ ตั้ง 22 (เดิม 10) — จาก log 6 คน join ใช้เวลา 10-16 วิ (โหลดหนัก) → grace 10 หมดก่อน = bot takeover ผิด → "ตัวละครซ้ำ 2 ตัว" (บอท+คนที่กลับมาช้า) + คนหลุด */
+const DISCONNECT_GRACE_SEC = 22;
+
+/** โหมดเทสต์ (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) ต่อช่อง 1–16 */
+ carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
+ /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
+ carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
+ /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
+ carryChoicePlaqueMapScale: 1.25,
+ /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */
+ carryWalkSpeedMultForMapId: '',
+ carryWalkSpeedMult: null,
+ /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */
+ quizRoundQuestionCount: 10,
+ questions: [],
+ carryQuestions: [],
+ battleQuizMcq: [],
+ /** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */
+ specialQuizSets: sanitizeSpecialQuizSets({}),
+ };
+}
+
+function defaultCarryMapPanelTheme() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+}
+
+function sanitizeCssColorToken(input, fallback) {
+ const t = String(input == null ? '' : input).trim().slice(0, 120);
+ if (!t) return fallback;
+ if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) {
+ const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, '');
+ if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim();
+ }
+ return fallback;
+}
+
+function sanitizeCarryMapPanelTheme(raw) {
+ const d = defaultCarryMapPanelTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ let qMin = Number(raw.questionFontMinPx);
+ let qMax = Number(raw.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const t = qMin;
+ qMin = qMax;
+ qMax = t;
+ }
+ return {
+ panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg),
+ panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder),
+ borderWidthPx: (() => {
+ let w = Number(raw.borderWidthPx);
+ if (!Number.isFinite(w)) w = d.borderWidthPx;
+ return Math.max(0, Math.min(12, Math.round(w)));
+ })(),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+}
+
+/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
+function defaultCarryChoicePlaqueTheme() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ };
+}
+
+function sanitizeCarryChoicePlaqueTheme(raw) {
+ const d = defaultCarryChoicePlaqueTheme();
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
+ fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
+ };
+}
+
+const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
+
+function defaultCarryChoicePlaqueThemes() {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme({}));
+ }
+ return out;
+}
+
+/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
+function sanitizeCarryChoicePlaqueThemes(raw) {
+ if (Array.isArray(raw) && raw.length > 0) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
+ }
+ return out;
+ }
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
+ const one = sanitizeCarryChoicePlaqueTheme(raw);
+ return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
+ }
+ return defaultCarryChoicePlaqueThemes();
+}
+
+function sanitizeCarryChoiceImageUrl(input) {
+ const s = String(input == null ? '' : input).trim().slice(0, 512);
+ if (!s) return '';
+ if (/[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const u = new URL(s);
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+}
+
+function defaultCarryEmbedCountdownTheme() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+}
+
+function sanitizeCarryEmbedCountdownTheme(raw) {
+ const d = defaultCarryEmbedCountdownTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ return {
+ overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
+ innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+}
+
+/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
+const QUIZ_BATTLE_CATEGORY_IDS = new Set([
+ 'topic1', 'topic2', 'topic3', 'topic4', 'topic5',
+ 'topic6', 'topic7', 'topic8', 'topic9', 'topic10',
+]);
+
+/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
+function sanitizeBattleQuizMcq(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ let categoryId = String(q.categoryId || '').trim();
+ if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
+ categoryId = 'topic1';
+ }
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ const rawChoices = Array.isArray(q.choices) ? q.choices : [];
+ const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
+ if (choices.length !== 3 || !choices.every(Boolean)) continue;
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ out.push({ categoryId, text, choices, correctIndex: ci });
+ if (out.length >= 200) break;
+ }
+ return out;
+}
+
+/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
+function sanitizeCarryQuestions(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ let choices = Array.isArray(q.choices)
+ ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
+ : [];
+ if (choices.length < 2) continue;
+ if (choices.length > 16) choices = choices.slice(0, 16);
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
+ const row = { text, choices, correctIndex: ci };
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) row.choiceImageUrls = urls;
+ }
+ out.push(row);
+ if (out.length >= 80) break;
+ }
+ return out;
+}
+
+function clampQuizMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(1000, Math.min(300000, Math.floor(v)));
+}
+
+function clampQuizBetweenMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(300000, Math.floor(v)));
+}
+
+function clampCarrySessionLength(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+}
+
+/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
+function clampQuizRoundQuestionCount(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) {
+ const d = Number(def);
+ if (!Number.isFinite(d)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(d)));
+ }
+ return Math.max(1, Math.min(50, Math.floor(v)));
+}
+
+function clampCarryChoicePlaqueMapScale(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
+}
+
+/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
+function sanitizeCarryWalkSpeedMultForMapId(raw) {
+ const t = String(raw == null ? '' : raw).trim().slice(0, 64);
+ if (!t) return '';
+ if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
+ return t;
+}
+
+/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
+function clampCarryWalkSpeedMultSetting(n) {
+ if (n === null || n === undefined || n === '') return null;
+ const v = Number(n);
+ if (Number.isNaN(v)) return null;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+}
+
+function mergeQuizCarryFromMirrorIfSet(base) {
+ if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
+ try {
+ if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
+ const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
+ const j = JSON.parse(raw);
+ if (!j || typeof j !== 'object') return base;
+ if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
+ base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
+ }
+ if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
+ base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
+ }
+ if (j.carryReadMs != null) {
+ base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
+ }
+ if (j.carryAnswerMs != null) {
+ base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
+ }
+ if (j.carrySessionLength != null) {
+ base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
+ }
+ if (j.carryWalkSpeedMultForMapId != null) {
+ base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
+ }
+ if (j.carryWalkSpeedMult != null) {
+ const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
+ if (wm != null) base.carryWalkSpeedMult = wm;
+ }
+ if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
+ base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
+ }
+ if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ }
+ if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
+ base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
+ }
+ if (j.quizRoundQuestionCount != null) {
+ base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
+ }
+ return base;
+ } catch (e) {
+ console.error('mergeQuizCarryFromMirrorIfSet', e.message);
+ return base;
+ }
+}
+
+/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
+const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
+const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
+const EVIDENCE_CASE_COUNT = 15;
+
+/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
+const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
+const CASE_MEDIA_COUNT = 15;
+/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
+const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
+
+function caseMediaDefault(n) {
+ const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png');
+ const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png');
+ return {
+ name: 'คดีที่ ' + n,
+ art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
+ detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
+ story: [
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
+ ],
+ suspects,
+ culprits,
+ };
+}
+
+function sanitizeCaseMediaUrlList(arr, fallbackArr) {
+ const fb = Array.isArray(fallbackArr) ? fallbackArr : [];
+ const src = Array.isArray(arr) ? arr : [];
+ return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || ''));
+}
+
+/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
+function sanitizeCaseMediaUrl(v, fallback) {
+ if (typeof v !== 'string') return fallback;
+ const s = v.trim();
+ if (!s) return fallback;
+ if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
+ return s;
+}
+
+function sanitizeCaseMedia(obj) {
+ const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
+ const cases = {};
+ for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
+ const d = caseMediaDefault(n);
+ const c = src[n] || src[String(n)] || {};
+ const story = Array.isArray(c.story) ? c.story : [];
+ cases[n] = {
+ name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
+ art: sanitizeCaseMediaUrl(c.art, d.art),
+ detail: sanitizeCaseMediaUrl(c.detail, d.detail),
+ story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
+ suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
+ culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
+ };
+ }
+ return { cases };
+}
+
+function loadCaseMedia() {
+ try {
+ if (fs.existsSync(CASE_MEDIA_PATH)) {
+ return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
+ }
+ } catch (e) { /* ignore — คืนค่า default */ }
+ return sanitizeCaseMedia({});
+}
+
+function saveCaseMedia(d) {
+ try {
+ const clean = sanitizeCaseMedia(d);
+ fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
+ return { ok: true, cases: clean.cases };
+ } catch (e) {
+ let err = 'บันทึกไม่ได้';
+ if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
+ else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
+ return { ok: false, error: err };
+ }
+}
+
+/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
+function listCaseMediaImages() {
+ const out = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] };
+ ['case', 'cutscene', 'suspect-pick', 'culprit-prison'].forEach((sub) => {
+ try {
+ const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
+ out[sub] = fs.readdirSync(p)
+ .filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ });
+ return out;
+}
+const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
+/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
+const EVIDENCE_RARITY_RANGES = [
+ { rarity: 'common', min: 1, max: 22, stars: 1 },
+ { rarity: 'rare', min: 23, max: 44, stars: 2 },
+ { rarity: 'legendary', min: 45, max: 54, stars: 3 },
+];
+
+function evidenceRarityForNum(n) {
+ for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
+ return EVIDENCE_RARITY_RANGES[0];
+}
+
+function evidenceCardImageDefault(suspectIdx, cardIdx) {
+ return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
+}
+
+/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
+let _evidencePoolCache = null;
+let _evidencePoolCacheAt = 0;
+function evidenceImagePools() {
+ const now = Date.now();
+ if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
+ const pools = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const buckets = { common: [], rare: [], legendary: [] };
+ try {
+ const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
+ fs.readdirSync(p).forEach((f) => {
+ const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
+ if (!m) return;
+ const num = parseInt(m[1], 10);
+ const rr = evidenceRarityForNum(num);
+ buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
+ });
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
+ pools[dir] = buckets;
+ });
+ _evidencePoolCache = pools;
+ _evidencePoolCacheAt = now;
+ return pools;
+}
+
+function evidencePoolCounts() {
+ const pools = evidenceImagePools();
+ const out = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const b = pools[dir] || { common: [], rare: [], legendary: [] };
+ out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
+ });
+ return out;
+}
+
+/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
+function rollEvidenceRarityKey(grade) {
+ const r = Math.random();
+ if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
+ if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
+ return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
+}
+
+/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
+function evidenceImageDirForSuspect(space, suspectIndex) {
+ const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
+ try {
+ const cid = String((space && space.caseId) || '1');
+ const cfg = loadEvidenceCards();
+ const cse = cfg.cases && cfg.cases[cid];
+ const s = cse && cse.suspects && cse.suspects[suspectIndex];
+ if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
+ } catch (e) { /* ใช้ default */ }
+ return def;
+}
+
+/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
+function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
+ const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
+ const pools = evidenceImagePools();
+ const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
+ let want = rollEvidenceRarityKey(grade || 'C');
+ let pool = buckets[want];
+ if (!pool || !pool.length) {
+ const tryOrder = ['common', 'rare', 'legendary'];
+ for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
+ }
+ if (!pool || !pool.length) return null;
+ const pick = pool[Math.floor(Math.random() * pool.length)];
+ return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
+}
+
+function evidenceGradeFromPct(avgPct) {
+ return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
+}
+
+/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
+function evidencePlayerRawScore(space, p, gameType) {
+ switch (gameType) {
+ case 'gauntlet':
+ case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
+ case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
+ case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
+ default:
+ return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
+ }
+}
+
+/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
+function computeRunGradeForEvidence(space) {
+ try {
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gameType = md && md.gameType;
+ /* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C
+ quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */
+ if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
+ const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
+ const ids = Object.keys(space.quizSession.players);
+ if (total > 0 && ids.length) {
+ const avgPct = ids.reduce((s, id) => {
+ const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
+ return s + Math.min(100, (sc / total) * 100);
+ }, 0) / ids.length;
+ return evidenceGradeFromPct(avgPct);
+ }
+ }
+ /* 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); }
+ });
+}
+
+/* streak achievements (a6/d4/e1) — POST action=streak {playerKey,id,event:'hit'|'miss'}
+ PHP นับต่อ/รีเซ็ตเอง + set achievement = best streak (ไม่ลดลง) */
+function submitAchievementStreak(items) {
+ const secret = gameAwardSecret();
+ if (!secret || !Array.isArray(items) || !items.length) return;
+ items.forEach((it) => {
+ if (!it || !it.playerKey || !it.id || (it.event !== 'hit' && it.event !== 'miss')) return;
+ try {
+ fetch(ACHIEVEMENTS_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'streak', secret, playerKey: it.playerKey, id: it.id, event: it.event }),
+ }).then((r) => r.json()).then((d) => {
+ if (!d || !d.ok) console.error('[achv] streak failed', it.id, d);
+ }).catch((e) => console.error('[achv] streak error', e && e.message));
+ } catch (e) { console.error('[achv] streak 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_JUMP_HEIGHT_MULT = 0.52; /* MG2 ความสูงกระโดด = ×tile (จุดสูงสุดของส่วนโค้ง sin) — ปรับได้ใน Admin */
+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,
+ gauntletJumpHeightMult: GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT,
+ gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
+ troublesomeOfferSec: 15,
+ ...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,
+ troublesomeRollMaxSec: 8,
+ /** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */
+ specialQuizIconExpireSec: 10,
+ /** เวลาโหวตเลือกผู้เล่น (การ์ด Silence/Ban) วินาที */
+ pickVoteSec: 25,
+ /** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */
+ trialVoteSec: 30,
+ /** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
+ trialResultRevealMs: 7000,
+ };
+}
+
+/** อ่านเวลา 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; ค่าอื่น = 10–7200 */
+function clampJumpSurviveMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */
+function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = [];
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : '';
+ const out = [];
+ for (let i = 0; i < 3; i++) {
+ const o = arr[i];
+ const obj = o && typeof o === 'object' ? o : {};
+ let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : '');
+ if (i === 0 && !url && legacy) url = legacy;
+ let ww = Number(obj.w);
+ let hh = Number(obj.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ out.push({ url, w: ww, h: hh });
+ }
+ return out;
+}
+
+function clampSpaceShooterMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */
+function normalizeSpaceShooterShipImageUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < 6; i++) {
+ out.push(sanitizeGauntletAssetUrl(arr[i]));
+ }
+ return out;
+}
+
+/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */
+function normalizeSpaceShooterAsteroidSpriteUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < arr.length && out.length < 32; i++) {
+ const u = sanitizeGauntletAssetUrl(arr[i]);
+ if (u) out.push(u);
+ }
+ return out;
+}
+
+function clampSpaceShooterAsteroidExplodeFrameMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return 70;
+ return Math.max(30, Math.min(500, Math.round(v)));
+}
+
+const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040;
+function clampSpaceShooterAsteroidIntervalMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
+ return Math.max(200, Math.min(10000, Math.floor(v)));
+}
+
+/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */
+function normalizeSpaceShooterShipDamageOverlayUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = ['', '', ''];
+ for (let i = 0; i < 3; i++) {
+ out[i] = sanitizeGauntletAssetUrl(arr[i]);
+ }
+ return out;
+}
+
+function clampGauntletTickMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
+ return Math.max(80, Math.min(800, Math.floor(v)));
+}
+
+function clampGauntletJumpTicks(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
+ return Math.max(1, Math.min(40, Math.floor(v)));
+}
+function clampGauntletJumpHeightMult(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT;
+ return Math.max(0.2, Math.min(1.5, v));
+}
+/** เวลารับข้อเสนอ "ตัวป่วน" (วินาที) — admin ปรับได้ใน game-timing; 5–120, default 15 */
+function clampTroublesomeOfferSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 15;
+ return Math.max(5, Math.min(120, Math.floor(v)));
+}
+/* เวลาสูงสุด (วินาที) ที่ระบบรอให้ทุกคนรายงานพร้อม ก่อน "บังคับสุ่ม" ผู้รับบทตัวป่วน (เดิม hardcode 8000ms) */
+function clampTroublesomeRollMaxSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 8;
+ return Math.max(1, Math.min(30, 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(j.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(j, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(j.troublesomeOfferSec) : 15,
+ ...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,
+ troublesomeRollMaxSec: clampTroublesomeRollMaxSec(j.troublesomeRollMaxSec),
+ 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,
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
+ : 7000,
+ };
+ }
+ } 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(d.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(d.troublesomeOfferSec) : ((runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) || 15),
+ ...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,
+ troublesomeRollMaxSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeRollMaxSec')
+ ? clampTroublesomeRollMaxSec(d.troublesomeRollMaxSec)
+ : clampTroublesomeRollMaxSec(prev.troublesomeRollMaxSec),
+ 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),
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
+ : (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
+ /* 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,
+ nr: p.netReady !== false, /* net-ready: false = เพิ่ง join กำลังโหลด (client โชว์ badge) */
+ };
+ 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) {
+ const payload = {
+ readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady),
+ expectedActive: (space && space.minigameExpectedActiveCount) || 0,
+ peers: buildPeersSnapForSpace(space),
+ };
+ /* MG2: ส่งตำแหน่ง spawn ของบอทมาด้วยตอน pregame → client วางบอทที่จุดเกิด (เดิมไม่ส่ง = บอทค้าง (1,1) มุมบน) */
+ const gr = space && space.gauntletRun;
+ if (gr && gr.bots && gr.bots.size) {
+ const bots = [];
+ gr.bots.forEach((b, id) => {
+ if (!b) return;
+ bots.push({ id, x: b.x, y: b.y, direction: b.dir || 'right', gauntletScore: b.gauntletScore || 0, gauntletEliminated: !!b.gauntletEliminated });
+ });
+ if (bots.length) payload.bots = bots;
+ }
+ return payload;
+}
+
+/* เช็คว่าพร้อมเริ่มมินิเกม 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;
+}
+
+/* key ผู้เล่นในคดี — ใช้ playerKey (คงที่ต่อ browser ข้าม reconnect) กันเคส "2 คนตั้งชื่อซ้ำ = ถูกนับเป็นคนเดียว"
+ fallback เป็นชื่อ (n:) ถ้า peer ไม่มี playerKey ที่ valid */
+function detectiveParticipantKey(p) {
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ const n = normalizeLobbyNickname(p.nickname || '');
+ return n ? 'n:' + n : '';
+}
+
+/* นับผู้เล่นในคดี (caseParticipantKeys = playerKey) ที่กลับเข้า LobbyB + โหลดเสร็จ เทียบกับทั้งหมด
+ ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */
+function detectiveLobbyBMissingParticipants(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 };
+ /* present = participantKey ที่ "มี peer" และ "โหลด LobbyB เสร็จจริง" (lobbyb-loaded)
+ — กันเคส peer มีอยู่ (socket re-join ช่วงเปลี่ยนหน้า) แต่ client ยังค้างฉากเกมเก่า = นับครบผิด
+ fallback: ก่อนมีใครส่ง lobbyb-loaded (loadedSet ว่าง) ใช้ peer-count (กัน deadlock ช่วงแรกสุด) */
+ const loadedSet = space.lobbyBLoadedKeys;
+ const useLoaded = !!(loadedSet && loadedSet.size > 0);
+ const present = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (!k || !roster.has(k)) return;
+ if (useLoaded) { if (loadedSet.has(k)) present.add(k); }
+ else present.add(k);
+ });
+ 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,
+ });
+}
+
+/* TC166 safety: กัน lobbyb-loaded gate deadlock — ถ้า 25s แล้วยังไม่ครบ ให้ force นับ present จาก peer ที่อยู่จริงใน LobbyB */
+function armLobbyBLoadedSafety(sid, space) {
+ if (!sid || !space) return;
+ if (space.lobbyBLoadedTimer) { clearTimeout(space.lobbyBLoadedTimer); space.lobbyBLoadedTimer = null; }
+ space.lobbyBLoadedTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (!sp || !serverMapIsPostCaseLobbyB(sp)) return;
+ const roster = sp.caseParticipantKeys;
+ if (!roster || roster.size === 0) return;
+ if (!sp.lobbyBLoadedKeys) sp.lobbyBLoadedKeys = new Set();
+ let added = 0;
+ sp.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k) && !sp.lobbyBLoadedKeys.has(k)) { sp.lobbyBLoadedKeys.add(k); added++; }
+ });
+ if (added) {
+ try { glog(sid, sp, 'lobbyb-gate', '[TC166] safety 25s → force นับ present จาก peer ที่อยู่จริง (+' + added + ')'); } catch (e) { /* ignore */ }
+ try { emitDetectiveLobbyBPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 25000);
+}
+
+/* TC156 story-gate: นับผู้เล่นในคดี "ที่อ่าน story (cutscene) จบแล้ว" เทียบกับคนที่อยู่ในห้องตอนนี้
+ allRead = ทุก roster-nick ที่ยัง present ได้ส่ง story-read-done แล้ว (คนที่ left ไม่นับ — presence gate คุมแยก) */
+function detectiveStoryReadStatus(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { present: 0, total: 0, allRead: true };
+ const read = space.storyReadKeys || new Set();
+ const presentRosterKeys = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k)) presentRosterKeys.add(k);
+ });
+ let readCount = 0;
+ presentRosterKeys.forEach((k) => { if (read.has(k)) readCount++; });
+ const allRead = !!space.storyReadComplete || (presentRosterKeys.size > 0 && readCount >= presentRosterKeys.size);
+ return { present: readCount, total: presentRosterKeys.size, allRead };
+}
+function emitDetectiveStoryReadPresence(sid, space) {
+ if (!sid || !space) return;
+ if (!serverMapIsPostCaseLobbyB(space)) return;
+ const st = detectiveStoryReadStatus(space);
+ io.to(sid).emit('detective-story-read-presence', { present: st.present, total: st.total, allRead: st.allRead });
+}
+
+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) || '',
+ };
+ });
+ // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ (รวม evidenceByNick fallback — กันหลักฐานคดีเก่า leak ข้ามคดี)
+ space.playerEvidence = {};
+ space.evidenceByNick = {};
+ space.caseMinigameSuspects = {}; /* [achv b2] มินิเกมที่รันในคดีนี้ (suspectIndex) — เช็ค "เล่นครบทุกรอบ" */
+ space.caseCardTargetedKeys = {}; /* [achv d2] playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ /* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
+ ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
+ space.suspectTeamProgress = [0, 0, 0];
+ // สุ่มคนร้ายตัวจริง 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 */
+/* คีย์เก็บหลักฐานต่อผู้เล่น — ใช้ playerKey (unique ต่อ browser) เป็นหลัก กัน "ชื่อซ้ำ"
+ (เช่น 2-3 คนชื่อ MONE) ใช้ evidence ก้อนเดียวกัน = ถูกแจกซ้ำ/เกิน. fallback ชื่อเฉพาะกรณีไม่มี key */
+function peerNickKey(space, playerId) {
+ if (!space || !playerId) return '';
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ if (!p.nickname) return '';
+ return 'n:' + 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;
+}
+
+/* [achv Phase A 2026-07-05] นับหลักฐานที่ผู้เล่นเก็บได้ → achievements
+ b4_data_miner(รวมทุกใบ ×100) · b1_evidence_collector(common ×20) · a2_sharp_eye(rare/Silver ×10) · b3_hidden_fragment(legendary ครั้งแรก)
+ เฉพาะผู้เล่นจริง (มี playerKey) — ข้ามบอท + เรียกจาก "จุดแจกจริง" 2 ที่เท่านั้น (ไม่รวม debug hotkey) */
+function trackEvidenceCollectAchv(space, playerId, card) {
+ if (!card || !card.rarity) return;
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ const pkey = p && typeof p.playerKey === 'string' ? p.playerKey : null;
+ if (!pkey) return;
+ const achv = [{ playerKey: pkey, id: 'b4_data_miner', inc: 1 }];
+ if (card.rarity === 'common') achv.push({ playerKey: pkey, id: 'b1_evidence_collector', inc: 1 });
+ else if (card.rarity === 'rare') achv.push({ playerKey: pkey, id: 'a2_sharp_eye', inc: 1 });
+ else if (card.rarity === 'legendary') achv.push({ playerKey: pkey, id: 'b3_hidden_fragment', inc: 1 });
+ submitAchievementProgress(achv);
+}
+
+function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
+ const out = {};
+ /* [achv b2] บันทึกว่า suspect นี้มีมินิเกมรันในคดีนี้ (ใช้เช็ค "เล่นมินิเกมครบทุกรอบ") */
+ if (suspectIndex >= 0 && suspectIndex <= 2) {
+ space.caseMinigameSuspects = space.caseMinigameSuspects || {};
+ space.caseMinigameSuspects[suspectIndex] = true;
+ }
+ const grantedKeys = new Set(); /* แจก 1 การ์ด/playerKey ต่อเกม — กัน "หลายแท็บ/ชื่อซ้ำ" key เดียวได้เกิน */
+ (playerIds || []).forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && grantedKeys.has(k)) return; /* key นี้ได้การ์ดรอบนี้แล้ว (evidence เก็บก้อนเดียวกันตาม key) */
+ const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
+ if (card != null) { out[pid] = card; if (k) grantedKeys.add(k); trackEvidenceCollectAchv(space, 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++) {
+ const freeKeys = new Set(); /* 1 ใบ/playerKey ต่อรอบ (กันชื่อซ้ำ/หลายแท็บได้เกิน) */
+ ids.forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && freeKeys.has(k)) return;
+ const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
+ if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; if (k) freeKeys.add(k); trackEvidenceCollectAchv(space, pid, extra); }
+ });
+ }
+ pend.consumed = true;
+ }
+ space.detectiveAwardDoneForRun = true;
+ space.lastDetectiveInvestigatedSuspect = suspectIdx;
+ space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
+ /* [TC182] verify: จำนวนใบต่อ playerKey ที่แจกรอบนี้ — ควรได้ 1 ใบ/key/เกม (กันชื่อซ้ำแจกเกิน) */
+ try {
+ const keyCounts = {};
+ Object.keys(awardedList).forEach((pid) => {
+ const k = peerNickKey(space, pid) || String(pid);
+ keyCounts[k] = (keyCounts[k] || 0) + (awardedList[pid] ? awardedList[pid].length : 0);
+ });
+ const vals = Object.values(keyCounts);
+ const over = vals.some((n) => n > 1);
+ glog(sid, space, 'evidence-grant', '[TC182] แจกหลักฐาน suspect#' + (suspectIdx + 1) + ' → ' + vals.length + ' key · ใบ/key=' + JSON.stringify(vals) + (over ? ' ⚠️เกิน' : ' (1/key · pass)'));
+ } catch (e) { /* ignore */ }
+ 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.lobbyBLoadedKeys = new Set(); /* ออกไปเล่นมินิเกม → ล้าง loaded; ตอนกลับ LobbyB ทุกคนต้องส่ง lobbyb-loaded ใหม่ */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock ถ้า signal ไม่ครบ */
+ 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;
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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;
+ /* #6: นับความคืบหน้าการสืบ (shared) +1 ต่อการเล่นมินิเกม 1 รอบของผู้ต้องสงสัยนั้น (สูงสุด 3) */
+ if (!Array.isArray(space.suspectTeamProgress)) space.suspectTeamProgress = [0, 0, 0];
+ if (suspectIdx >= 0 && suspectIdx <= 2) {
+ space.suspectTeamProgress[suspectIdx] = Math.min(3, (space.suspectTeamProgress[suspectIdx] || 0) + 1);
+ try { glog(sid, space, 'suspect-progress', '[TC179] สืบผู้ต้องสงสัย #' + (suspectIdx + 1) + ' → team progress ' + space.suspectTeamProgress.join('/') + ' (shared เท่ากันทุกจอ · pass)'); } catch (e) { /* ignore */ }
+ }
+ 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 || [],
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ 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 = [];
+ /* Phase B: majority vote (เช็ค lone-wolf) + มีคนโหวตไหม */
+ let mvAchv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mvAchv]) mvAchv = k; }
+ const anyVotesAchv = (counts[0] + counts[1] + counts[2]) > 0;
+ /* 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 });
+ /* ===== Phase B (2026-07-05) — เงื่อนไขต่อผู้โหวตถูก ===== */
+ /* c5 Lone Wolf: โหวตถูกแต่เสียงส่วนใหญ่ผิด (สวนทางทีม) */
+ if (anyVotesAchv && mvAchv !== culprit) achv.push({ playerKey: p.playerKey, id: 'c5_lone_wolf', inc: 1 });
+ /* หลักฐานในมือคดีนี้ (ต่อผู้ต้องสงสัย 3 กอง) */
+ const evRows = clonePlayerEvidence(space, id);
+ const evTotal = evRows.reduce((s, row) => s + (Array.isArray(row) ? row.length : 0), 0);
+ /* c2 High Stakes: โหวตถูกโดยมีหลักฐาน ≤3 ใบ */
+ if (evTotal <= 3) achv.push({ playerKey: p.playerKey, id: 'c2_high_stakes', inc: 1 });
+ /* c1 Early Accusation: โหวตถูกโดยมีหลักฐาน ≤1 ใบ (ชี้ตัวก่อนเปิดหลักฐานครบ) */
+ if (evTotal <= 1) achv.push({ playerKey: p.playerKey, id: 'c1_early_accusation', inc: 1 });
+ /* a4 Logic Over Luck: โหวตถูกโดยไม่มีหลักฐาน legendary (ชี้ชัด) เลย */
+ const hasLegendary = evRows.some((row) => Array.isArray(row) && row.some((c) => c && c.rarity === 'legendary'));
+ if (!hasLegendary) achv.push({ playerKey: p.playerKey, id: 'a4_logic_over_luck', inc: 1 });
+ /* c4 Clutch Mind: โหวตถูกใน 10 วิสุดท้ายก่อนหมดเวลา */
+ const voteAt = space.trialVoteAt && space.trialVoteAt[id];
+ if (voteAt && space.trialVoteEndsAt) {
+ const remain = space.trialVoteEndsAt - voteAt;
+ if (remain >= 0 && remain <= 10000) achv.push({ playerKey: p.playerKey, id: 'c4_clutch_mind', 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 */
+ }
+ }
+ /* ===== Phase C/D (2026-07-06) — ต่อผู้เล่นที่จบคดี (ไม่ใช่แค่ผู้โหวตถูก) ===== */
+ const streakItems = [];
+ /* c3 Quick Draw: ผู้โหวตถูกที่ "โหวตเร็วที่สุด" ในทีม (ใช้ trialVoteAt) */
+ {
+ let fastId = null, fastAt = Infinity;
+ winners.forEach((id) => {
+ const at = space.trialVoteAt && space.trialVoteAt[id];
+ const wp = space.peers.get(id);
+ if (at && wp && wp.playerKey && at < fastAt) { fastAt = at; fastId = id; }
+ });
+ if (fastId) { const fp = space.peers.get(fastId); if (fp && fp.playerKey) achv.push({ playerKey: fp.playerKey, id: 'c3_quick_draw', inc: 1 }); }
+ }
+ /* วนผู้เล่นจริงทุกคนที่จบคดี (ข้ามบอท/ผู้ถูกแบน spectator) */
+ const caseMgSuspects = Object.keys(space.caseMinigameSuspects || {}).map((n) => parseInt(n, 10));
+ const targetedKeys = space.caseCardTargetedKeys || {};
+ space.peers.forEach((p, pid) => {
+ if (!p || !p.playerKey || p.bannedSpectator) return;
+ const evRows = clonePlayerEvidence(space, pid);
+ const rarSet = {};
+ evRows.forEach((row) => { if (Array.isArray(row)) row.forEach((c) => { if (c && c.rarity) rarSet[c.rarity] = true; }); });
+ /* a3 Mind Architect: มีหลักฐานครบ 3 ระดับในคดีนี้ (common+rare+legendary) */
+ if (rarSet.common && rarSet.rare && rarSet.legendary) achv.push({ playerKey: p.playerKey, id: 'a3_mind_architect', inc: 1 });
+ /* b2 Relentless Investigator: เก็บหลักฐานครบทุก suspect ที่มีมินิเกมรันในคดีนี้ (= เล่นครบทุกรอบ) */
+ if (caseMgSuspects.length > 0 && caseMgSuspects.every((si) => Array.isArray(evRows[si]) && evRows[si].length > 0)) {
+ achv.push({ playerKey: p.playerKey, id: 'b2_relentless_investigator', inc: 1 });
+ }
+ /* d2 Silent Guardian: ไม่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ if (!targetedKeys[p.playerKey]) achv.push({ playerKey: p.playerKey, id: 'd2_silent_guardian', inc: 1 });
+ /* e1 The Observer (streak): จบคดีนี้โดยไม่แตะ legendary = hit, แตะ = รีเซ็ต */
+ streakItems.push({ playerKey: p.playerKey, id: 'e1_the_observer', event: rarSet.legendary ? 'miss' : 'hit' });
+ });
+ /* a6 Unbreakable Logic (โหวตถูกติดกัน) + d4 Flawless Diver (ไม่โหวตผิดติดกัน) — เฉพาะคนที่โหวตจริง */
+ Object.keys(votes).forEach((id) => {
+ const p = space.peers.get(id);
+ if (!p || !p.playerKey) return;
+ const ok = votes[id] === culprit;
+ streakItems.push({ playerKey: p.playerKey, id: 'a6_unbreakable_logic', event: ok ? 'hit' : 'miss' });
+ streakItems.push({ playerKey: p.playerKey, id: 'd4_flawless_diver', event: ok ? 'hit' : 'miss' });
+ });
+ submitAchievementProgress(achv);
+ submitAchievementStreak(streakItems);
+ } 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.trialVoteAt = {}; /* [achv c4] รีเซ็ตเวลาโหวตทุกครั้งที่เปิดโหวต (รวม revote) */
+ 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 });
+ /* [achv d2] บันทึก playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ (→ ไม่ได้ d2_silent_guardian) */
+ if (target && (pv.purpose === 'silence' || pv.purpose === 'ban')) {
+ const tp = space.peers.get(target);
+ if (tp && tp.playerKey) {
+ space.caseCardTargetedKeys = space.caseCardTargetedKeys || {};
+ space.caseCardTargetedKeys[tp.playerKey] = true;
+ }
+ }
+ 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 = [];
+ const tc191Rows = []; /* [TC191] verify: เทียบผลตัดสิน "แถวเท้า" vs "หัว(เดิม)" ต่อคน */
+
+ space.peers.forEach((p, peerId) => {
+ const st = sess.players[peerId];
+ if (!st) return;
+ eligibleCount++;
+ /* จับที่ "แถวเท้า" (bottom row ของ footprint) เท่านั้น — หัว/ตัวโผล่เข้าโซนแต่เท้ายังไม่ถึง = ไม่นับ
+ (ต้องตรงกับ client quizResolveChoiceFromStanding) */
+ const { cw: qCw, ch: qCh } = getCharacterFootprintWHForMove(mdLive);
+ const qBaseTx = Math.floor(Number(p.x));
+ const qFeetTy = Math.floor(Number(p.y)) + qCh - 1;
+ let inT = false, inF = false;
+ for (let qdx = 0; qdx < qCw; qdx++) {
+ const qftx = qBaseTx + qdx;
+ if (!inT && quizCellOn(tGrid, qftx, qFeetTy)) inT = true;
+ if (!inF && quizCellOn(fGrid, qftx, qFeetTy)) inF = true;
+ }
+ 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,
+ });
+ }
+
+ /* [TC191] เก็บบรรทัดเทียบ: เท้า(ที่ใช้จริง) vs หัว(สูตรเดิม single-tile) → พิสูจน์ว่าตัดสินที่แถวเท้า */
+ {
+ const headY = Math.floor(Number(p.y));
+ tc191Rows.push((p.nickname || '?').slice(0, 8) + ' fY=' + qFeetTy + ' hY=' + headY
+ + ' เท้า[T' + (inT ? 1 : 0) + 'F' + (inF ? 1 : 0) + ']'
+ + ' หัวเดิม[T' + (quizCellOn(tGrid, qBaseTx, headY) ? 1 : 0) + 'F' + (quizCellOn(fGrid, qBaseTx, headY) ? 1 : 0) + ']'
+ + ' → ' + (choice === true ? 'จริง' : choice === false ? 'เท็จ' : '-') + (right ? '✓' : '✗'));
+ }
+ results.push({
+ id: peerId,
+ nickname: p.nickname || '',
+ right,
+ choice,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : 0,
+ });
+ });
+
+ if (tc191Rows.length) {
+ try { glog(sid, space, 'mg1-feet', '[TC191] Q' + (idx + 1) + ' ตัดสินแถวเท้า: ' + tc191Rows.join(' | ')); } catch (e) { /* ignore */ }
+ }
+
+ 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_` 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;
+ /* โต๊ะ submit กลาง (interactive/เขียว) = สิ่งกีดขวาง ห้ามยืนทับ/เดินทะลุ (ส่งคำตอบจากช่องข้างๆ ได้ — quizCarryCanSubmitAtServer รับ adjacent) */
+ if (quizCellOn(md.interactive || [], 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;
+}
+
+/* ช่องที่บอท "เดินถึงได้จริง" (footprint-aware BFS) จากโซน option/spawn — กันบอทสุ่มเกิด/ติดใน
+ "คอนโซลกลาง" ที่ยืนได้แต่ออกไม่ได้ (footprint 3×4 ลอดช่องแคบ/ทางที่ติดกำแพง+hub+interactive ไม่ได้
+ = อาการ "บอทยืนบนโต๊ะ"). cache ต่อ md (โครงสร้างแมปคงที่ต่อรอบเกม) */
+function quizCarryReachableTilesServer(md) {
+ if (md.__qcReach) return md.__qcReach;
+ const w = md.width || 20, h = md.height || 15;
+ const oa = md.quizCarryOptionArea, sa = md.spawnArea;
+ const seeds = [];
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const isSeed = (Array.isArray(oa) && oa[y] && Number(oa[y][x]) >= 1) || (Array.isArray(sa) && quizCellOn(sa, x, y));
+ if (isSeed && quizCarryEntityCanStandServer(md, x, y)) seeds.push([x, y]);
+ }
+ }
+ const reach = new Set();
+ if (!seeds.length) {
+ /* ไม่มี seed (แมปไม่มี option/spawn) → ไม่กรอง: ทุก standable นับว่าถึงได้ */
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (quizCarryEntityCanStandServer(md, x, y)) reach.add(x + ',' + y);
+ md.__qcReach = reach;
+ return reach;
+ }
+ const q = [];
+ for (const s of seeds) { const k = s[0] + ',' + s[1]; if (!reach.has(k)) { reach.add(k); q.push(s); } }
+ while (q.length) {
+ const cur = q.shift();
+ const nb = [[cur[0] + 1, cur[1]], [cur[0] - 1, cur[1]], [cur[0], cur[1] + 1], [cur[0], cur[1] - 1]];
+ for (const n of nb) {
+ const k = n[0] + ',' + n[1];
+ if (!reach.has(k) && quizCarryEntityCanStandServer(md, n[0], n[1])) { reach.add(k); q.push(n); }
+ }
+ }
+ md.__qcReach = reach;
+ return reach;
+}
+function quizCarryTileReachableServer(md, x, y) {
+ return quizCarryReachableTilesServer(md).has(Math.floor(x) + ',' + Math.floor(y));
+}
+
+/** รวมรายชื่อช่องว่าง (เดินยืนได้ + ถึงได้จริง + ไม่ใช่ 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;
+ if (!quizCarryTileReachableServer(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 ถ้ามีการเปลี่ยนสถานะ · clientPos = ตำแหน่งที่ client รายงานตอนกด grab (ใช้ถ้าใกล้ server ≤1.25 tile) */
+function quizCarryServerTryInteract(sid, space, actorId, isBot, clientPos) {
+ 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);
+ /* client รายงานตำแหน่งตอนกด F — ถ้าใกล้ server พอ ใช้ client เพื่อลด desync หยิบไม่ติด/เด้ง */
+ if (clientPos && typeof clientPos === 'object') {
+ const cx = Number(clientPos.x), cy = Number(clientPos.y);
+ if (Number.isFinite(cx) && Number.isFinite(cy) && Number.isFinite(px) && Number.isFinite(py)) {
+ if (Math.hypot(cx - px, cy - py) <= 1.25) { px = cx; py = cy; }
+ }
+ }
+ }
+ 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 });
+ /* แจ้งผลถูก/ผิด "บรอดแคสต์ให้ทุกคน" → โชว์ไอคอน ✓/✗ เหนือหัวทั้งคนจริงและบอท (ทุกจอเห็นพร้อมกัน) */
+ io.to(sid).emit('quiz-carry-answer-feedback', { id: actorId, correct: ok });
+ 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;
+ /* resolve ชื่อผู้ชนะ (ให้ client โชว์ชื่อจริง ไม่ใช่ 'ผู้เล่น' ตอน churn) + diag: ใครตอบถูก (คน/บอท) */
+ let winnerNick = '', winnerIsBot = false;
+ if (winnerId) {
+ if (s.bots && s.bots.has(winnerId)) { winnerIsBot = true; }
+ else { const wp = space.peers.get(winnerId); if (wp && wp.nickname) winnerNick = wp.nickname; }
+ }
+ try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId ? ('[TC169] ' + (winnerNick || (winnerIsBot ? 'BOT ' + winnerId : winnerId))) : '-(หมดเวลา ไม่มีคนถูก)') + ' correctIdx=' + (s.current ? s.current.correctIndex : '?') + ' willEnd=' + willEnd); } catch (e) { /* ignore */ }
+ io.to(sid).emit('quiz-carry-result', {
+ questionIndex: s.qIndex + 1,
+ correctIndex: s.current ? s.current.correctIndex : null,
+ winnerId: winnerId || null,
+ winnerNick,
+ winnerIsBot,
+ 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.lastAdvanceAt = Date.now();
+ try { glog(sid, space, 'mg4-read', 'q=' + s.qIndex + ' deck=' + (s.deck ? s.deck.length : 0) + ' rounds=' + s.roundsCompleted + '/' + s.sessionLength); } catch (e) { /* ignore */ }
+ 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';
+ s.lastAdvanceAt = Date.now();
+ 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);
+ /* 0 = ไม่ได้ตั้งค่า → จบเมื่อ "ถามครบทุกข้อในชุด" รอบเดียว (ไม่วนซ้ำ) ตรงกับที่ผู้เล่นคาดหวังว่า
+ "พอจบทุกคำถาม เกมต้องจบ"; ถ้า pool ว่างค่อย fallback 10 (กันหารด้วยศูนย์/ค้าง) */
+ if (!(sessionLength > 0)) sessionLength = Math.max(1, pool.length || 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover (BFS เล่นต่อ) — แทนคนที่ออก ใช้ socket.id เดิม ไม่ชน id บอท AI */
+ {
+ const sC = space.quizCarrySession;
+ drainPendingMissionTakeovers(space, 'quiz_carry', (rec) => {
+ sC.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ dir: rec.direction || 'down', isWalking: false,
+ held: null, score: 0,
+ botPath: [], pathfindAfter: 0, wantIdx: null,
+ wanderTX: null, wanderTY: null, retargetAt: 0,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+ }
+ if (!pool.length) { endQuizCarryGame(sid, space); return; }
+ /* watchdog กันเกมค้างไม่จบ: เก็บนอก space.quizTimers (เพราะ resolveQuizCarryRound เคลียร์ array นั้นทุกข้อ)
+ ถ้าไม่มีความคืบหน้า (lastAdvanceAt) นานเกิน → force resolve/จบ — ป้องกัน "เล่นจนครบแต่เกมไม่ยอมจบ" */
+ {
+ const sC = space.quizCarrySession;
+ sC.lastAdvanceAt = Date.now();
+ const perRound = Math.max(0, sC.carryReadMs) + Math.max(1000, sC.carryAnswerMs) + QUIZ_CARRY_BETWEEN_MS + 1500;
+ const stallMs = perRound + 20000; /* ช่วงเวลาที่ "ยอมรับได้" ต่อข้อ ก่อนถือว่าค้าง */
+ if (space.quizCarryWatchdog) clearInterval(space.quizCarryWatchdog);
+ space.quizCarryWatchdog = setInterval(() => {
+ const sp = spaces.get(sid);
+ const ss = sp && sp.quizCarrySession;
+ if (!sp || !ss || !ss.active || ss.ended) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; return; }
+ const idle = Date.now() - (ss.lastAdvanceAt || 0);
+ if (idle > stallMs) {
+ try { glog(sid, sp, 'mg4-watchdog', 'STALL idle=' + idle + 'ms phase=' + ss.phase + ' round=' + ss.roundsCompleted + '/' + ss.sessionLength); } catch (e) { /* ignore */ }
+ ss.lastAdvanceAt = Date.now();
+ /* ดันให้ไปต่อ: ถ้ายังตอบ/อ่านค้าง → จบรอบนี้ (ไม่ให้คะแนน) แล้วระบบจะเลื่อนข้อหรือจบเองตาม willEnd */
+ if (!ss.roundResolved) { try { resolveQuizCarryRound(sid, sp, null); } catch (e) { /* ignore */ } }
+ else { try { scheduleQuizCarryReadPhase(sid, sp); } catch (e) { /* ignore */ } }
+ }
+ }, 5000);
+ }
+ scheduleQuizCarryReadPhase(sid, space);
+}
+
+function endQuizCarryGame(sid, space) {
+ const s = space.quizCarrySession;
+ if (s) { s.active = false; s.ended = true; }
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; }
+ try { glog(sid, space, 'mg4-end', 'END rounds=' + (s ? s.roundsCompleted : '?') + '/' + (s ? s.sessionLength : '?')); } catch (e) { /* ignore */ }
+ 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;
+
+ /* ก้าวไป (x,y) ได้ไหม: ต้อง "ยืนได้ทั้ง footprint" (canStand = ทุกช่องของตัวละครไม่ใช่กำแพง/ไม่ทับ hub/ไม่ทับโต๊ะ interactive)
+ — เดิมเช็คแค่ quizTileWalkableServer ที่ "ช่อง anchor จุดเดียว" ทำให้ footprint ขอบตัวเลื่อนคร่อมกำแพงได้ →
+ ตกไปช่องที่ reachability-BFS (footprint-aware) ถือว่า "ออกไม่ได้" → โดน push-out teleport ทุก tick = อาการ "บอทว๊าป".
+ ใช้ canStand ให้ตรงกับ pathfind (ที่วางเส้นบน canStand อยู่แล้ว) → บอทไม่เดินเข้าช่องที่จะถูกดันออก */
+ const canStepTo = (x, y) => quizCarryEntityCanStandServer(md, Math.floor(x), Math.floor(y));
+
+ 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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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) => {
+ /* ช่วงอ่านคำถาม (read) → บอท freeze อยู่ตรงไหนตรงนั้น (ตรงกับผู้เล่นที่ freeze ฝั่ง client) */
+ if (s.phase === 'read') { b.isWalking = false; return; }
+ /* ทับ hub / โต๊ะ submit กลาง (interactive) / หรือ "ติดในคอนโซลกลาง" (ยืนได้แต่เดินออกไม่ได้) → ดันออกไปช่องว่างที่ถึงได้
+ หมายเหตุ: หลังแก้ canStepTo → canStand แล้ว บอทไม่ควรเดินเข้าช่องแบบนี้เอง teleport นี้จึงเป็น safety-net (ควรยิงน้อยมาก)
+ — diag: glog ทุกครั้งที่ teleport จริง เพื่อยืนยันว่า "ว๊าป" ลดลง (ถอด log ออกเมื่อยืนยันแล้ว) */
+ if (quizCarryFootprintOverlapsHubServer(md, b.x, b.y) || quizCarryFootprintOverlapsInteractiveServer(md, b.x, b.y) || !quizCarryTileReachableServer(md, b.x, b.y)) {
+ const tiles = quizCarrySpawnTilesServer(md);
+ if (tiles.length) {
+ const fromX = b.x, fromY = b.y;
+ const t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = [];
+ try { glog(sid, space, 'mg4-warp', '[TC158] push-out teleport ' + bid + ' (' + fromX.toFixed(2) + ',' + fromY.toFixed(2) + ')->(' + b.x.toFixed(2) + ',' + b.y.toFixed(2) + ') — event นี้ = ยังว๊าป (fail); ไม่มี event ตลอดเกม = pass'); } catch (e) { /* ignore */ }
+ }
+ }
+ /* ยังไม่มีเส้นทาง → ลองหยิบ/ส่งที่จุดยืนก่อน */
+ 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)) {
+ /* ช่องที่ "ยืนได้จริง + ชิดโต๊ะ interactive" (ไม่ใช่ยืนทับโต๊ะ — โต๊ะเป็นสิ่งกีดขวางแล้ว) → ส่งจากข้างๆ */
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!quizCarryEntityCanStandServer(md, x, y)) continue;
+ if (quizCarryFootprintAdjacentInteractiveServer(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 — เลข 2–16หาย) */
+function normalizeQuizCarryLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_carry') return;
+ const w = m.width || 20, h = m.height || 15;
+ const normHub = () => {
+ const src = m.quizCarryHubArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizCarryHubArea = rows;
+ };
+ const normOptions = () => {
+ const src = m.quizCarryOptionArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ m.quizCarryOptionArea = rows;
+ };
+ const normCountdown = () => {
+ const src = m.carryEmbedCountdownArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ rows.push(row);
+ }
+ m.carryEmbedCountdownArea = rows;
+ };
+ normHub();
+ normOptions();
+ normCountdown();
+}
+
+/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
+function normalizeQuizBattleDomeAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattleDomeArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattleDomeArea = rows;
+}
+
+/** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */
+function normalizeQuizBattleDomeCompOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const grid = m.quizBattleDomeArea;
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ let nextId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue;
+ nextId++;
+ const stack = [[x, y]];
+ comp[y][x] = nextId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue;
+ comp[ny][nx] = nextId;
+ stack.push([nx, ny]);
+ }
+ }
+ }
+ }
+ m.quizBattleDomeComp = comp;
+}
+
+/**
+ * ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน
+ * ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน
+ */
+function hasQuizBattleManualPathGates(m) {
+ const mg = m && m.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+}
+
+function buildQuizBattlePathGateFromManualOnMap(m) {
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const manual = m.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ /* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function buildQuizBattlePathGateRequiredOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) {
+ if (m) m.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGates(m)) {
+ buildQuizBattlePathGateFromManualOnMap(m);
+ return;
+ }
+ normalizeQuizBattleDomeCompOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const domeComp = m.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function finalizeQuizBattleMapLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ normalizeQuizBattlePathAreaOnMap(m);
+ normalizeQuizBattleDomeCompOnMap(m);
+ buildQuizBattlePathGateRequiredOnMap(m);
+}
+
+function normalizeQuizBattlePathAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattlePathArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattlePathArea = rows;
+}
+
+function quizBattlePathModeActiveServer(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
+ const g = m.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattleFootprintFullyOnPathServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ const g = m.quizBattlePathArea;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+/** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */
+function quizBattlePositionAllowedServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true;
+ const sa = m.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+function quizBattlePlayerSolvedCompsServer(space, socketId) {
+ if (!space || !space.qbwSolved) return new Set();
+ const set = space.qbwSolved.get(socketId);
+ return set || new Set();
+}
+
+function quizBattlePathGateRequiredAtServer(m, px, py) {
+ const gate = m && m.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActiveServer(m)) return 0;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ const path = m.quizBattlePathArea;
+ let maxReq = 0;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+}
+
+function quizBattleFootprintOnDomeServer(m, px, py) {
+ const dome = m && m.quizBattleDomeArea;
+ if (!dome) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattlePathGateAllowsServer(m, space, socketId, px, py) {
+ if (!m || !quizBattlePathModeActiveServer(m)) return true;
+ if (quizBattleFootprintOnDomeServer(m, px, py)) return true;
+ const maxReq = quizBattlePathGateRequiredAtServer(m, px, py);
+ if (maxReq <= 0) return true;
+ const solved = quizBattlePlayerSolvedCompsServer(space, socketId);
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solved.has(c)) return false;
+ }
+ return true;
+}
+
+function serverFootprintClearOfWalls(md, px, py) {
+ const w = md.width || 20, h = md.height || 15;
+ const keys = serverWallCollisionTileKeys(md, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects && md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+}
+
+/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
+function snapPositionOntoQuizBattlePathServer(md, px, py) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidServer(md, px, py)) return { x: px, y: py };
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidServer(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+}
+
+function collectQuizBattleValidSpawnCentersServer(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+}
+
+function quizBattleSnapTargetValidServer(md, x, y) {
+ if (!md || !md.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ if (!serverFootprintClearOfWalls(md, x, y)) return false;
+ if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false;
+ return true;
+}
+
+function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersServer(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+}
+
+function collectQuizBattleSpawnPoolServer(md) {
+ const out = [];
+ if (!md) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActiveServer(md)) {
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ } else if (!isMapTileWalkableForSpawn(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActiveServer(md)) {
+ return collectQuizBattleValidSpawnCentersServer(md);
+ }
+ return out;
+}
+
+function pickQuizBattleRandomSpawnWorldServer(md) {
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y);
+ }
+ const idx = typeof crypto.randomInt === 'function'
+ ? crypto.randomInt(0, pool.length)
+ : Math.floor(Math.random() * pool.length);
+ const pick = pool[idx];
+ return { x: pick.x, y: pick.y };
+}
+
+function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMap(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldServer(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldServer(md, sx, sy);
+ }
+ return pickQuizBattleRandomSpawnWorldServer(md);
+}
+
+function pickQuizBattleSpawnFromMap(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+}
+
+function normalizeJumpSurvivePlatformAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformArea = rows;
+}
+
+function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const pa = m.jumpSurvivePlatformArea || [];
+ const src = m.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformVariantArea = rows;
+}
+
+function normalizeJumpSurviveHazardAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurviveHazardArea = rows;
+}
+
+/**
+ * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
+ */
+function applyStackTowerMissionMapPlayPolicy(safeId, m) {
+ if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
+ const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
+ const spNum = Number(raw.speedPxPerSec);
+ const speedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ const sel = Math.floor(Number(raw.stepEveryLayers));
+ const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
+ const ssp = Math.floor(Number(raw.stepScrollPx));
+ const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
+ const sam = Math.floor(Number(raw.stepAnimMs));
+ const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
+ const st = {
+ enabled: raw.enabled === true,
+ speedPxPerSec,
+ scrollDirection,
+ stepEveryLayers,
+ stepScrollPx,
+ stepAnimMs,
+ };
+ if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
+ if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
+ const rg = Number(raw.releaseGapWorldPx);
+ if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
+ m.stackTowerBgScroll = st;
+ const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
+ const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
+ const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
+ m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
+}
+
+function loadMaps() {
+ try {
+ if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(MAPS_DIR);
+ for (const f of files) {
+ if (!f.endsWith('.json')) continue;
+ const id = f.slice(0, -5);
+ if (!safeMapId(id)) continue;
+ try {
+ const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(id, data);
+ maps.set(id, data);
+ } catch (e) { console.error('load map', f, e.message); }
+ }
+ } catch (e) { console.error('loadMaps', e.message); }
+}
+
+/**
+ * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
+ * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
+ */
+function rehydrateSingleMapFromDisk(safeId) {
+ if (!safeId) return false;
+ const fp = path.join(MAPS_DIR, safeId + '.json');
+ if (!fs.existsSync(fp)) return false;
+ try {
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(safeId, data);
+ maps.set(safeId, data);
+ return true;
+ } catch (e) {
+ console.error('rehydrate map', safeId, e.message);
+ return false;
+ }
+}
+
+/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
+function ensurePostCaseLobbyMapLoaded() {
+ if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
+ try {
+ const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
+ if (!fs.existsSync(fp)) return false;
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ maps.set(POST_CASE_LOBBY_SPACE_ID, data);
+ return true;
+ } catch (e) {
+ console.error('ensurePostCaseLobbyMapLoaded', e.message);
+ return false;
+ }
+}
+
+function saveMap(id, m) {
+ const safe = safeMapId(id);
+ if (!safe) return;
+ try {
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
+ } catch (e) { console.error('saveMap', id, e.message); }
+}
+
+function isMapNameTaken(name, excludeId) {
+ const n = (name || '').trim().toLowerCase();
+ for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
+ return false;
+}
+
+function applyDevCors(req, res) {
+ const origin = req.headers.origin;
+ if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.setHeader('Vary', 'Origin');
+ }
+}
+
+const server = http.createServer((req, res) => {
+ applyDevCors(req, res);
+ if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ let url = req.url;
+ if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
+ if (url.startsWith(BASE_PATH + '/api/maps')) {
+ let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
+ if (id.includes('?')) id = id.split('?')[0];
+ if (req.method === 'GET' && id) {
+ const sid = safeMapId(decodeURIComponent(id));
+ if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ rehydrateSingleMapFromDisk(sid);
+ const m = maps.get(sid);
+ if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
+ }
+ if (req.method === 'GET' && !id) {
+ const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
+ }
+ if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || 'ฉากใหม่';
+ if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const id = Date.now().toString(36);
+ const m = { ...defaultMap(), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ if (req.method === 'PUT' && id && maps.has(id)) {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || maps.get(id).name;
+ if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const m = { ...maps.get(id), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ applyStackTowerMissionMapPlayPolicy(id, m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ }
+ const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ const quizCarryDiskPaths = new Set([
+ BASE_PATH + '/api/quiz-carry-from-disk',
+ BASE_PATH + '/api-quiz-carry-from-disk.php',
+ ]);
+ if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
+ if (req.method === 'GET') {
+ const g = loadQuizSettings();
+ const slice = {
+ carryMapPanelTheme: g.carryMapPanelTheme,
+ carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
+ carryReadMs: g.carryReadMs,
+ carryAnswerMs: g.carryAnswerMs,
+ carrySessionLength: g.carrySessionLength,
+ carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
+ carryWalkSpeedMult: g.carryWalkSpeedMult,
+ carryQuestions: g.carryQuestions,
+ };
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(slice));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(getSystemStats()));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadQuizSettings()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveQuizSettings(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ battleQuizMcqSaved: saved.battleQuizMcqSaved,
+ carryQuestionsSaved: saved.carryQuestionsSaved,
+ specialQuizSets: saved.specialQuizSets,
+ carryMapPanelTheme: saved.carryMapPanelTheme,
+ carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadEvidenceCards()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveEvidenceCards(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadCaseMedia()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveCaseMedia(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ });
+ return res.end(JSON.stringify({ ...runtimeGameTiming }));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveGameTimingToFile(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ const { ok: _ok, error: _err, ...timingRest } = saved;
+ return res.end(JSON.stringify({ ok: true, ...timingRest }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureGauntletAssetsDir();
+ const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
+ let fname;
+ if (replaceFilename) {
+ const s = safeGauntletStoredFilename(replaceFilename);
+ if (!s) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
+ }
+ const cur = path.join(GAUNTLET_ASSETS_DIR, s);
+ if (!fs.existsSync(cur)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
+ }
+ const norm = (e) => {
+ let x = String(e || '').toLowerCase();
+ if (x.startsWith('.')) x = x.slice(1);
+ return x === 'jpeg' ? 'jpg' : x;
+ };
+ if (norm(path.extname(s)) !== norm(ext)) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
+ }
+ fname = s;
+ } else {
+ fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ }
+ fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
+ const meta = loadGauntletAssetsMeta();
+ const prev = meta[fname] || {};
+ meta[fname] = {
+ ...prev,
+ label: labelIn || prev.label || fname,
+ updatedAt: Date.now(),
+ };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/gauntlet-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureQuizCarryPlaqueAssetsDir();
+ const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const fname = safeGauntletStoredFilename(d.filename);
+ if (!fname) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
+ }
+ const meta = loadGauntletAssetsMeta();
+ const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
+ try {
+ const q = url.includes('?') ? url.split('?')[1] : '';
+ const sp = new URLSearchParams(q);
+ const rawFn = sp.get('file');
+ const fname = safeGauntletStoredFilename(rawFn);
+ if (!fname) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
+ const meta = loadGauntletAssetsMeta();
+ delete meta[fname];
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
+ }
+ }
+ if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const ids = new Set();
+ const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
+ files.forEach(f => {
+ let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ });
+ const idArr = [...ids];
+ const oldestMsById = {};
+ idArr.forEach((id) => {
+ let oldest = Infinity;
+ const prefix = id + '_';
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ try {
+ const st = fs.statSync(path.join(CHARACTERS_DIR, f));
+ const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
+ if (t < oldest) oldest = t;
+ } catch (e) { /* ignore */ }
+ });
+ oldestMsById[id] = oldest === Infinity ? 0 : oldest;
+ });
+ idArr.sort((a, b) => {
+ const ta = oldestMsById[a] || 0;
+ const tb = oldestMsById[b] || 0;
+ if (ta !== tb) return ta - tb;
+ return String(a).localeCompare(String(b));
+ });
+ const list = idArr.map((id) => {
+ const prefix = id + '_';
+ const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
+ const layerNameSet = new Set();
+ if (hasLayerFiles) {
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) {
+ layerNameSet.add(m[1]);
+ return;
+ }
+ m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) layerNameSet.add(m[1]);
+ });
+ }
+ const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
+ const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
+ return { id, name: id, hasLayerFiles, layers, layerManifest };
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(list));
+ } catch (e) {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify([]));
+ }
+ }
+ const urlPath = url.split('?')[0];
+ {
+ const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
+ if (lm && req.method === 'GET') {
+ const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const manifest = buildCharacterLayerManifest(id, files);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(manifest));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
+ }
+ }
+ }
+ if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
+ if (/\/layer-manifest$/i.test(urlPath)) {
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
+ }
+ const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
+ const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ let removed = 0;
+ files.forEach(f => {
+ if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
+ try {
+ fs.unlinkSync(path.join(CHARACTERS_DIR, f));
+ removed++;
+ } catch (e) {
+ // ignore single file errors
+ }
+ }
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, removed }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ return;
+ }
+ if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ if (!body || body.length < 2) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
+ }
+ const d = JSON.parse(body);
+ const dirs = ['up', 'down', 'left', 'right'];
+ const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
+ let walkWritten = 0;
+ for (const dir of dirs) {
+ let data = d[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ // เขียนไฟล์เฟรม (สำหรับ animation)
+ const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ walkWritten++;
+ frameIndex++;
+ }
+ }
+ let layerWritten = 0;
+ // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
+ const lf = d.layerFrames;
+ if (lf && typeof lf === 'object') {
+ for (const dir of dirs) {
+ const arr = lf[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const lfIdle = d.layerFramesIdle;
+ if (lfIdle && typeof lfIdle === 'object') {
+ for (const dir of dirs) {
+ const arr = lfIdle[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_idle_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
+ if (idleObj) {
+ for (const dir of dirs) {
+ let data = idleObj[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameFrame = list.length > 1
+ ? (id + '_' + dir + '_idle_' + frameIndex + '.png')
+ : (id + '_' + dir + '_idle.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '_idle.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ frameIndex++;
+ }
+ }
+ }
+ if (walkWritten === 0 && layerWritten === 0) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, characterId: id }));
+ } catch (e) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/api/spaces')) {
+ const parts = url.slice((BASE_PATH + '/api/spaces').length).split('?')[0].split('#')[0].split('/').filter(Boolean);
+ const spaceId = parts[0];
+ if (req.method === 'GET' && !spaceId) {
+ const now = Date.now();
+ for (const [id, s] of [...spaces.entries()]) {
+ if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
+ const n = s.peers ? s.peers.size : 0;
+ if (n !== 0) continue;
+ /* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */
+ const since = s.emptySince || s.createdAt;
+ const stale = since == null || now - since > SPACE_EMPTY_TTL_MS;
+ if (stale) spaces.delete(id);
+ }
+ const list = [...spaces]
+ .filter(([, s]) => !s.isPrivate)
+ .filter(([, s]) => (s.peers && s.peers.size > 0))
+ .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;
+ }
+ /* เสียง: volume master/music/sfx — GET เปิดสาธารณะ (sound.js ทุก client อ่าน) · PUT เฉพาะแอดมิน */
+ if (url === BASE_PATH + '/api/sound-settings' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(loadSoundSettings()));
+ return;
+ }
+ if (url === BASE_PATH + '/api/sound-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 = loadSoundSettings();
+ const clamp = (v, cur) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : cur; };
+ if (d.masterVol !== undefined) s.masterVol = clamp(d.masterVol, s.masterVol);
+ if (d.musicVol !== undefined) s.musicVol = clamp(d.musicVol, s.musicVol);
+ if (d.sfxVol !== undefined) s.sfxVol = clamp(d.sfxVol, s.sfxVol);
+ saveSoundSettings(s);
+ res.writeHead(200);
+ res.end(JSON.stringify({ ok: true, settings: s }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const message = (d.message || d.text || '').trim();
+ if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
+ const s = loadAiSettings();
+ if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
+ const model = s.model || 'gpt-4o-mini';
+ const messages = [];
+ const systemParts = [];
+ if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
+ if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
+ systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
+ }
+ if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
+ messages.push({ role: 'user', content: message });
+ const payload = JSON.stringify({
+ model: model,
+ messages: messages,
+ max_tokens: 500,
+ });
+ const urlObj = new URL('https://api.openai.com/v1/chat/completions');
+ const opts = {
+ hostname: urlObj.hostname,
+ path: urlObj.pathname,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + s.openai_api_key,
+ 'Content-Length': Buffer.byteLength(payload, 'utf8'),
+ },
+ };
+ const reqOut = require('https').request(opts, (resOut) => {
+ let data = '';
+ resOut.on('data', chunk => data += chunk);
+ resOut.on('end', () => {
+ try {
+ const parsed = JSON.parse(data);
+ const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
+ if (text != null) {
+ res.writeHead(200);
+ res.end(JSON.stringify({ response: String(text).trim() }));
+ } else {
+ res.writeHead(502);
+ res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
+ }
+ } catch (e) {
+ res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
+ }
+ });
+ });
+ reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
+ reqOut.write(payload);
+ reqOut.end();
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/img/characters/') && (req.method === 'GET' || req.method === 'HEAD')) {
+ const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
+ const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
+ if (fname && fname.length > 0) {
+ const fp = path.join(CHARACTERS_DIR, fname);
+ if (fs.existsSync(fp)) {
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': 'image/png' });
+ if (req.method === 'HEAD') return res.end();
+ res.end(data);
+ });
+ }
+ }
+ }
+ if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
+ const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
+ // production: nginx เสิร์ฟ /Game/img/* เป็น static ทั้งหมด — local node จึงต้องเสิร์ฟไฟล์ที่มีอยู่จริงในโฟลเดอร์ด้วย
+ // (ไม่จำกัดเฉพาะไฟล์อัปโหลด gauntlet-) ไม่งั้น asset ดีไซน์ (popup-Howto, Avartar, btn-ready…) จะ 400 เฉพาะตอนรันผ่าน node ตรง ๆ
+ const fname = safeGauntletStoredFilename(sub) || path.basename(sub); // basename กัน path traversal
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fname || !fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end();
+ }
+ const ext = path.extname(fname);
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
+ res.end(data);
+ });
+ }
+ if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
+ // ตัด query string / hash ออกก่อน resolve เป็น path ไฟล์ (เช่น /play.html?space=...&map=... → /play.html)
+ // ไม่ตัด จะหาไฟล์ชื่อ "play.html?space=..." ไม่เจอ → 404 (เข้า node ตรง ๆ พร้อม query)
+ url = url.split('?')[0].split('#')[0] || '/index.html';
+ const filePath = path.join(__dirname, 'public', url);
+ const ext = path.extname(filePath);
+ const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
+ fs.readFile(filePath, (err, data) => {
+ if (err) return res.writeHead(404), res.end('Not Found');
+ res.writeHead(200, {
+ 'Content-Type': types[ext] || 'application/octet-stream',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+ res.end(data);
+ });
+});
+
+const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
+const spaces = new Map();
+
+const GAUNTLET_MAX_PLAYERS = 6;
+/** เข้าเส้นชัยแล้ว — หยุด 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);
+}
+
+/** มินิเกม "live แล้ว" (พ้น how-to/นับถอยหลัง — session engine เริ่มแล้ว หรือ gauntlet ปล่อยวิ่งแล้ว)?
+ ใช้ตอน rejoin: ถ้า live แล้ว client ต้องข้าม how-to แล้วเข้าเล่นต่อทันที (เดิมโชว์ how-to เสมอ → คน reconnect ค้าง)
+ signal = session.active (สร้างตอน 3-2-1 จบ, ล้างตอนกลับ LobbyB) — เชื่อถือได้กว่า missionLiveBeginsAt (ไม่เคย reset ต่อเกม) */
+function isMinigameLiveForRejoinResume(space) {
+ if (!space) return false;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (!md) return false;
+ switch (md.gameType) {
+ case 'gauntlet': return !!(space.gauntletRun && !space.gauntletRun.crownRunHeld);
+ case 'stack': return !!(space.stackSession && space.stackSession.active);
+ case 'jump_survive': return !!(space.jumpSurviveSession && space.jumpSurviveSession.active);
+ case 'space_shooter': return !!(space.spaceShooterSession && space.spaceShooterSession.active);
+ case 'balloon_boss': return !!(space.balloonBossSession && space.balloonBossSession.active);
+ case 'quiz': return !!(space.quizSession && space.quizSession.active);
+ case 'quiz_carry': return !!(space.quizCarrySession && space.quizCarrySession.active);
+ default: return false;
+ }
+}
+
+/* ── How-to / pre-live disconnect takeover (MG3–MG7) ───────────────────────────
+ มินิเกมเหล่านี้สร้าง session (bots/turnOrder) ตอน 3-2-1 จบเท่านั้น → ถ้าคนหลุดช่วง how-to /
+ นับถอยหลัง ยังไม่มี session ให้แปลงเป็นบอท เดิม avatar เลยหายไปเลย (ตกไป user-left).
+ แก้: เก็บ "pending takeover" ไว้บน space + แจ้ง client เก็บ avatar (*-player-to-bot) แล้ว
+ seed เป็นบอท server ตอน startXxxGame → บอทรับช่วงเล่นต่อทันทีเมื่อเกมเริ่ม เหมือน MG1/MG2 */
+function missionShellGameTypeForSpace(space) {
+ if (!space) return null;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gt = md && md.gameType;
+ if (!gt) return null;
+ if (gt === 'space_shooter' && isSpaceShooterMissionShellSpace(space)) return 'space_shooter';
+ if (gt === 'jump_survive' && isJumpSurviveMissionShellSpace(space)) return 'jump_survive';
+ if (gt === 'balloon_boss' && isBalloonBossMissionShellSpace(space)) return 'balloon_boss';
+ if (gt === 'stack' && isStackTowerMissionShellSpace(space)) return 'stack';
+ if (gt === 'quiz_carry') return 'quiz_carry';
+ return null;
+}
+function recordPendingMissionTakeover(space, gameType, socketId, info, playerKey, nick, wasElim) {
+ if (!space || !gameType || !socketId || !info) return;
+ if (!Array.isArray(space.pendingMissionTakeovers)) space.pendingMissionTakeovers = [];
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => r.id !== socketId);
+ const themeIdx = parseInt(info.lobbyColorThemeIndex, 10);
+ const skinIdx = parseInt(info.lobbySkinToneIndex, 10);
+ space.pendingMissionTakeovers.push({
+ gameType, id: socketId, playerKey: playerKey || '', nick: (nick || 'ผู้เล่น'), wasEliminated: !!wasElim,
+ x: Number(info.x), y: Number(info.y), direction: info.direction || 'down',
+ characterId: info.characterId != null ? info.characterId : null,
+ themeIdx: Number.isFinite(themeIdx) ? themeIdx : null,
+ skinIdx: Number.isFinite(skinIdx) ? skinIdx : null,
+ spaceShooterScore: Math.max(0, Number(info.spaceShooterScore) || 0),
+ spaceShooterEliminated: !!info.spaceShooterEliminated,
+ balloonBossScore: Math.max(0, Number(info.balloonBossScore) || 0),
+ balloonBossBossDmg: Math.max(0, Number(info.balloonBossBossDmg) || 0),
+ balloonBossBalloons: Math.max(0, Number(info.balloonBossBalloons) || 0),
+ balloonBossEliminated: !!info.balloonBossEliminated,
+ jumpSurviveEliminated: !!info.jumpSurviveEliminated,
+ });
+}
+/** seed บอท takeover จาก pending (เฉพาะ gameType นี้) — makeBot(rec) สร้างบอทใน session แล้วคืน true ถ้าสำเร็จ */
+function drainPendingMissionTakeovers(space, gameType, makeBot) {
+ if (!space || !Array.isArray(space.pendingMissionTakeovers) || typeof makeBot !== 'function') return 0;
+ const keep = [];
+ let n = 0;
+ for (const rec of space.pendingMissionTakeovers) {
+ if (rec.gameType !== gameType) { keep.push(rec); continue; }
+ try { if (makeBot(rec) !== false) n++; } catch (e) { /* ignore */ }
+ }
+ space.pendingMissionTakeovers = keep;
+ return n;
+}
+
+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_` 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,
+ gameoverAt: null, /* != null = อยู่ในช่วง grace "ตายหมด" (โชว์ game over ก่อนกลับ LobbyB) */
+ };
+}
+
+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.5; /* miss เมื่อ overlap < 50% บล็อก (เดิม 30% หลวมไป = ลงเยื้อง 3 ช่องยังไม่ miss หัวใจไม่ลด) — ต้องตรงกับ client */
+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 = 1200; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป (ลดจาก 1500 + swing เดินต่อระหว่าง busy → ไม่ค้าง) */
+const STACK_BOT_THINK_MIN_MS = 560;
+const STACK_BOT_THINK_MAX_MS = 1180;
+const STACK_SEATS = 6;
+/* เฟส client (ตอนกดวาง) ต้องใกล้เฟส server ไม่เกินนี้ (rad) ถึงจะใช้.
+ เดิม 1.2rad (~0.35s@0.55Hz) → latency/desync เกินนั้น server ทิ้งเฟส client ใช้ s.phase (เลื่อนไปแล้ว)
+ = "เล็งกลางแต่ลงเยื้อง/ร่วง" (intermittent ตาม latency). ขยายเป็น ~1รอบ (2π ≈ 1.8s@0.55Hz)
+ = ครอบ latency เล่นได้จริงทั้งหมด → ใช้เฟสที่ผู้เล่นเห็นเสมอ (ลงตรงจุดที่เห็น) เหลือไว้แค่กันค่า garbage.
+ เกม co-op สร้างตึกร่วมกัน ไม่ต้องกันโกงเฟส */
+const STACK_DROP_PHASE_TOL = Math.PI * 2;
+
+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));
+ }
+ /* rel-boost: ความกว้างสวิงตาม stackReleaseArea (ต้องตรงกับ client play.js ~12177-12180 เป๊ะ)
+ เดิม server ใช้แค่ land span → client (มี rel-boost) แกว่ง "กว้างกว่า" server: บล็อกห้อยกว้างแต่ตกแคบ
+ = ไถลเฉียงตอนปล่อย + logical ตกใกล้กลางเสมอจน "ทับ support ตลอด ไม่นับ miss → หัวใจไม่ลด".
+ ทำให้ 2 ฝั่งใช้สวิงเดียวกัน → ตกตรง (straight) + miss/หัวใจ ทำงานถูก */
+ const rel = stackBoundsServer(md.stackReleaseArea, w, h);
+ if (rel) {
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.32, 3.0)); /* ต้องตรงกับ client play.js — ลดจาก 0.42/4.2 (สวิงกว้างเกิน = หน่วงนิ้วนิดเดียวเยื้องเยอะ "ต่อตรงแต่ลงเยื้อง") แต่ยังกว้างพอให้ miss ได้เมื่อเยื้องจริง */
+ }
+ 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)
+ phaseOverride: ตา "คนจริง" ส่งเฟสที่เห็นตอนกดมาด้วย (client phase) → คำนวณจุดลงตามที่ผู้เล่นเห็น
+ ไม่ใช่เฟส server ณ ตอน req มาถึง (ช้ากว่า half-RTT = บล็อกลงเยื้อง). บอท/fallback ใช้ s.phase */
+function stackComputeDropServer(s, md, phaseOverride) {
+ const dropPhase = (typeof phaseOverride === 'number' && Number.isFinite(phaseOverride)) ? phaseOverride : s.phase;
+ const raw = s.swingAmp * Math.sin(dropPhase);
+ 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;
+ /* MISS = แค่ overlapMiss (บล็อกไม่ทับบล็อกล่างพอ) เท่านั้น.
+ เดิมมี driftMiss เทียบ placedCx กับ "กลางพื้นดินตายตัว" (anchorCx=land.cx) → ต่อเอียงเป็นบันได
+ (แต่ละชั้นทับชั้นล่างพอดี) พอ center สะสมห่างจากกลางพื้น >38% ฐาน บล็อกที่วางดีๆ ก็ถูกนับ miss
+ = อาการ "ตึกโยกตกทั้งที่ไม่ได้อยู่ริมๆ". ตัดออก — overlapMiss คือเงื่อนไขตกที่ถูกต้องอยู่แล้ว. */
+ const miss = overlapMiss;
+ 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;
+}
+/* สี block ของ "คนจริง" = lobbyColorThemeIndex (1-8) → ตรงกับสี avatar ทุกจอ
+ (เดิมโค้ด stack ใช้ p.lobbyThemeIndex ที่ไม่เคยถูกเซ็ต → null เสมอ; และ (x%8)+1 บนค่า 1-based = เลื่อนสีผิด 1 ช่อง) */
+function normStackTheme8(n) {
+ const v = Math.floor(Number(n));
+ return Number.isFinite(v) ? ((v - 1) % 8 + 8) % 8 + 1 : null;
+}
+function stackHumanThemeServer(p) {
+ return p ? normStackTheme8(p.lobbyColorThemeIndex) : null;
+}
+/* สี block ของบอท AI = สี avatar บอท slot นั้น (lobbyBotThemeBySlot[slot].themeIndex) ไม่ใช่ (slot%8)+1 */
+function stackBotThemeServer(space, slot) {
+ const m = space && space.lobbyBotThemeBySlot;
+ const t = m && m[slot] ? normStackTheme8(m[slot].themeIndex) : null;
+ return t || (((Math.floor(Number(slot)) % 8) + 8) % 8 + 1);
+}
+/* สร้างลำดับตา: humans (เรียง spawnJoinOrder) + bots เติมที่ว่าง ≤ 6 ที่นั่ง */
+function buildStackTurnOrderServer(space, s) {
+ try { syncLobbyBotThemeSlots(space); } catch (_e) { /* ให้ lobbyBotThemeBySlot ตรงกับสีที่ client ได้รับ → สี block บอทตรง avatar */ }
+ 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: stackHumanThemeServer(p) });
+ seat++;
+ }
+ /* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้ */
+ for (const [botId, b] of s.bots) {
+ if (!b || !b.takeover) continue;
+ if (seat > STACK_SEATS) break;
+ order.push({ kind: 'bot', seat, id: null, botId, theme: (b.theme != null ? b.theme : ((seat % 8) + 1)) });
+ 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: stackBotThemeServer(space, slot) });
+ 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,
+ /* full-sync หัวใจ + decrypt% → client ที่พลาด packet stack-drop (ช้า/churn) self-correct
+ (เดิมส่งเฉพาะตอน drop → พลาดแล้ว "หัวใจไม่ลด/% ไม่ตรง" ค้าง) */
+ teamMissesLeft: s.teamMissesLeft,
+ progressPct: s.progressPct,
+ });
+}
+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; }
+ }
+ });
+ /* [TC183] verify คะแนน MG3: team score + คะแนนต่อคน (ดูว่าสูงผิดปกติ/ซ้ำจากชื่อซ้ำไหม) */
+ try {
+ const per = s.turnOrder.map((e) => {
+ if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); return 'bot=' + Math.max(0, (b && b.score) | 0); }
+ if (e.id != null) { const p = space.peers.get(e.id); return ((p && p.nickname) || e.id).toString().slice(0, 6) + '=' + Math.max(0, s.humanPts[e.id] | 0); }
+ return '?';
+ });
+ glog(sid, space, 'stack-score', '[TC183] จบ MG3 outcome=' + outcome + ' team=' + (s.score | 0) + ' layers=' + s.layers.length + ' progress=' + Math.floor(s.progressPct || 0) + '% · ต่อคน: ' + per.join(' '));
+ } catch (e) { /* ignore */ }
+ 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) — ยังดรอปไม่ได้ (drop handler เช็ค busyUntil) แต่ให้ "บล็อกถัดไปแกว่งต่อ"
+ (advance phase + emit swing) → ตาถัดไปไม่เห็นบล็อกห้อยนิ่ง = ไม่ค้าง · client มี guard (!stackFall) ไม่ให้กระตุกตอนยังร่วง */
+ if (now < s.busyUntil) {
+ 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, t: now });
+ }
+ 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, t: now }); }
+ if (now >= s.botThinkUntil) {
+ /* คิดเสร็จ → ล็อกเฟสเป้าหมาย "ข้างหน้า" ครั้งเดียว แล้วรอแกว่งวิ่งมาถึงเองธรรมชาติ
+ (ไม่สแนปเฟส = ไม่กระตุก) ปล่อยเมื่อถึงเป้า หรือเลย deadline กันค้าง */
+ if (s.botAimPhase == null) {
+ s.botAimPhase = stackBotComputeAimPhaseServer(s, md, tier);
+ /* deadline ต้องยาวพอให้สวิง "ถึงเป้า" จริง = เวลาแกว่งจาก phase ปัจจุบันไปถึง aimPhase + buffer.
+ เดิม 2600ms ตายตัว → ตอน hz ต่ำ (สวิงช้า) กว่าจะถึงเป้าใช้ >2.6s → deadline เตะก่อน = บอทปล่อยผิด phase (เฉียง/miss) */
+ const _gap = Math.max(0, s.botAimPhase - s.phase);
+ const _radPerSec = Math.max(1e-4, s.phaseSpeed * Math.PI * 2);
+ s.botAimDeadline = now + Math.min(15000, Math.ceil((_gap / _radPerSec) * 1000) + 1500);
+ }
+ 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,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = now + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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, t: now }); }
+ }
+ /* 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);
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนจัดลำดับตา (buildStackTurnOrderServer จะใส่เป็น seat ให้) */
+ {
+ const s3 = space.stackSession;
+ drainPendingMissionTakeovers(space, 'stack', (rec) => {
+ s3.bots.set(rec.id, {
+ tier: 'avg', score: 0, takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ theme: normStackTheme8(rec.themeIdx),
+ });
+ });
+ }
+ 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 6), effectiveBotSlotCount(space)));
+ const bannedSlot = jsBannedBotSlot(space);
+ let poolIdx = 1 + humanCount + existing;
+ 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedJumpSurviveTakeoverBots(space, md);
+ createJumpSurviveBots(space, md);
+}
+function seedJumpSurviveTakeoverBots(space, md) {
+ const s = space.jumpSurviveSession;
+ if (!s) return;
+ const now = Date.now();
+ drainPendingMissionTakeovers(space, 'jump_survive', (rec) => {
+ s.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ vy: 0, onGround: false, dir: rec.direction || 'down', walking: false,
+ eliminated: !!rec.jumpSurviveEliminated,
+ wishX: 0, wishNext: now + 200, nextJumpAi: now + 200,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ยัง emit elim:true ต่อทุก tick (เดิม return ข้าม = ส่ง elim ครั้งเดียว → client พลาด = ค้างนิ่ง) */
+ if (b.eliminated) { outBots.push({ id, x: b.x, y: b.y, dir: b.dir || 'down', walking: false, elim: true }); 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 });
+ });
+ /* ผู้เล่นจริงที่สลับแท็บ (hidden) → จำลองการตกแทน timer ฝั่ง client ที่ถูก throttle → ส่งตำแหน่ง full-rate (คนอื่นเห็นลื่น).
+ ไม่มี input (ตก/ไหลตามแท่นเฉย ๆ) เหมือนคนปล่อยจอ. ตาย → mark eliminated + ตั้ง p.jumpSurviveEliminated (เข้าเช็ค all_dead/เกรด) */
+ if (s.players) {
+ for (const pid in s.players) {
+ const st = s.players[pid];
+ if (!st || !st.hidden || st.eliminated) continue;
+ if (!space.peers.has(pid)) { st.hidden = false; continue; } /* หลุดห้องแล้ว → ปล่อยให้ disconnect logic จัดการ */
+ const pxc = ((Number.isFinite(st.x) ? st.x : 1) + cw * 0.5) * ts;
+ const feetY = ((Number.isFinite(st.y) ? st.y : 1) + ch) * ts;
+ const r = jsPhysicsIntegrate(md, ts, sc, {
+ dt, ch, pxc0: pxc, feetY0: feetY, vy0: st.vy || 0, wasOnGround: !!st.onGround,
+ jumpQueued: false, moveLeft: false, moveRight: false,
+ });
+ st.x = r.pxc / ts - cw * 0.5;
+ st.y = r.feetY / ts - ch;
+ st.vy = r.vy; st.onGround = r.onGround;
+ if (r.died) {
+ st.eliminated = true; st.hidden = false; st.vy = 0;
+ const p = space.peers.get(pid); if (p) p.jumpSurviveEliminated = true;
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: false, elim: true });
+ } else {
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: Math.abs(r.vy) > 40, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" ในการจำกัด 6 ช่อง กันรวมเกิน 6 */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนสร้างบอท AI (createSpaceShooterBots นับ s.bots.size เป็น human-like) */
+ seedSpaceShooterTakeoverBots(space, md);
+ createSpaceShooterBots(space, md);
+}
+function seedSpaceShooterTakeoverBots(space, md) {
+ const s = space.spaceShooterSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ drainPendingMissionTakeovers(space, 'space_shooter', (rec) => {
+ const cx = Number.isFinite(rec.x) ? Math.max(ts * 0.5, Math.min(mw - ts * 0.5, (rec.x + 0.5) * ts)) : mw * 0.5;
+ const cy = ssClampCy(Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.5, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', cx, cy, dir: 'up',
+ score: rec.spaceShooterScore || 0, eliminated: !!rec.spaceShooterEliminated, 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() + 500,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ไม่เดิน/ไม่ยิง แต่ "ยัง emit elim:true ต่อ" — เดิม return ข้ามเลย (ส่ง elim ครั้งเดียวตอนตาย)
+ ถ้า client พลาด packet นั้น = ไม่รู้ว่าตาย → ยานค้างนิ่งบนจอ. ส่งซ้ำทุก tick → client ซ่อนได้แน่นอน */
+ if (!b.eliminated) {
+ 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: !!b.eliminated, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedBalloonBossTakeoverBots(space, md);
+ createBalloonBossBots(space, md);
+}
+function seedBalloonBossTakeoverBots(space, md) {
+ const s = space.balloonBossSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ const startBalloons = balloonBossBalloonsForMap(md);
+ drainPendingMissionTakeovers(space, 'balloon_boss', (rec) => {
+ const cx = Number.isFinite(rec.x) ? (rec.x + 0.5) * ts : mw * 0.5;
+ const cy = Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.4;
+ const cl = bbClampWorld(cx, cy, mw, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', homeCx: cl.cx, homeCy: cl.cy, cx: cl.cx, cy: cl.cy,
+ score: rec.balloonBossScore || 0, dmg: rec.balloonBossBossDmg || 0,
+ balloons: rec.balloonBossBalloons || startBalloons, eliminated: !!rec.balloonBossEliminated,
+ fireCd: 0.6 + Math.random() * 0.8, phase: Math.random() * Math.PI * 2,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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 });
+}
+/* ตำแหน่งบอส "เคลื่อนที่จริง" — port จาก client getBalloonBossBossLiveCenterPlay (anchor=liveBeginsAt เดียวกัน → ตรงกันทุกเครื่อง)
+ server เดิมคิดบอสเป็นจุดนิ่ง → บอทไม่รู้ว่าบอสขยับมาทับ. มีตัวนี้ บอทหลบบอสที่เคลื่อนที่ได้ + เล็งยิงตรงบอสจริง */
+function bbBossLiveCenter(md, ts, s, now) {
+ const base = bbBossCenter(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const anchor = (s && s.liveBeginsAt) ? s.liveBeginsAt : now;
+ const tSec = Math.max(0, (now - anchor) / 1000);
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return bbClampWorld(cx, cy, mw, mh);
+}
+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 = bbBossLiveCenter(md, ts, s, now); /* บอสเคลื่อนที่จริง (หลบ+เล็งตามตัวนี้) */
+ const fleeR = Math.max(ts * 3.4, 96); /* หลบบอสเมื่อ home เข้าใกล้ ~3 ช่อง */
+ const fleeSpd = 165; /* px/s ดริฟต์ home หนีบอส (เร็วกว่าบอส → ไม่ติด) */
+ const sepMin = ts * 2.4; /* ระยะ home ขั้นต่ำระหว่างบอท (หลบกันเอง) */
+ const outBots = [];
+ const alive = [];
+ s.bots.forEach((b, id) => {
+ if (b.eliminated) { outBots.push({ id, cx: b.cx, cy: b.cy, score: b.score, dmg: b.dmg, balloons: b.balloons, elim: true }); return; } /* ตายแล้ว: emit elim:true ต่อ (กัน client พลาด packet = ค้างนิ่ง) */
+ alive.push(b);
+ /* หลบบอส: ดริฟต์ "home" ออกจากบอสที่เคลื่อนเข้าใกล้ (แทน clamp ตำแหน่ง = ไม่ติดนิ่งขอบบอส) */
+ const hx = b.homeCx - boss.cx, hy = b.homeCy - boss.cy;
+ const hd = Math.hypot(hx, hy) || 1e-6;
+ if (hd < fleeR) { b.homeCx += (hx / hd) * fleeSpd * dt; b.homeCy += (hy / hd) * fleeSpd * dt; }
+ 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 });
+ });
+ /* หลบกันเอง: ดัน home ที่ใกล้กันเกินให้แยกออก (รอบถัดไปบอทจะ wobble รอบ home ใหม่ → ไม่ซ้อนกัน) */
+ for (let i = 0; i < alive.length; i++) {
+ for (let j = i + 1; j < alive.length; j++) {
+ const a = alive[i], c = alive[j];
+ const dx = a.homeCx - c.homeCx, dy = a.homeCy - c.homeCy;
+ const d = Math.hypot(dx, dy) || 1e-6;
+ if (d < sepMin) { const p = (sepMin - d) * 0.5, nx = dx / d, ny = dy / d; a.homeCx += nx * p; a.homeCy += ny * p; c.homeCx -= nx * p; c.homeCy -= ny * p; }
+ }
+ }
+ alive.forEach((b) => { const cl = bbClampWorld(b.homeCx, b.homeCy, mw, mh); b.homeCx = cl.cx; b.homeCy = cl.cy; }); /* กัน home ดริฟต์หลุดขอบ */
+ 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_' (ตรงกับ 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 };
+}
+
+/* คอมโพเนนต์เดินได้ "หลัก" (BFS จาก spawn slot) — กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated walkable) ที่กำแพงล้อม
+ cache ต่อ space (invalidate เมื่อ mapId เปลี่ยน) */
+function lobbyMainCompServer(md, space) {
+ const mid = space && space.mapId;
+ if (space && space._lobbyMainComp && space._lobbyMainCompMap === mid) return space._lobbyMainComp;
+ const w = md.width || 20, h = md.height || 15;
+ const isW = (x, y) => x >= 0 && x < w && y >= 0 && y < h && !(md.objects && md.objects[y] && md.objects[y][x] === 1);
+ const slots = parseLobbyPlayerSpawnsServer(md).filter(Boolean);
+ let seed = null;
+ for (const s of slots) { const sx = Math.floor(s.x), sy = Math.floor(s.y); if (isW(sx, sy)) { seed = [sx, sy]; break; } }
+ if (!seed && md.spawn && isW(Math.floor(md.spawn.x), Math.floor(md.spawn.y))) seed = [Math.floor(md.spawn.x), Math.floor(md.spawn.y)];
+ if (!seed) { for (let y = 0; y < h && !seed; y++) for (let x = 0; x < w; x++) { if (isW(x, y)) { seed = [x, y]; break; } } }
+ const comp = new Set();
+ if (seed) {
+ comp.add(seed[0] + ',' + seed[1]);
+ const q = [seed];
+ while (q.length) { const [a, b] = q.shift(); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = a + dx, ny = b + dy, k = nx + ',' + ny; if (!comp.has(k) && isW(nx, ny)) { comp.add(k); q.push([nx, ny]); } } }
+ }
+ if (space) { space._lobbyMainComp = comp; space._lobbyMainCompMap = mid; }
+ return comp;
+}
+/* เดินบอท 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;
+ const mainComp = lobbyMainCompServer(md, space);
+ const inMain = (x, y) => !mainComp.size || mainComp.has(Math.floor(x) + ',' + Math.floor(y));
+ bots.forEach((b, id) => {
+ /* กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated) → ดึงกลับ spawn หลัก (main component) */
+ if (!inMain(b.x, b.y)) {
+ const slot = parseInt(id.slice(LOBBY_BOT_PREFIX.length), 10);
+ const sp = pickLobbyBotSpawnServer(md, space, slot);
+ if (inMain(sp.x, sp.y)) { b.x = sp.x + 0.5; b.y = sp.y + 0.5; }
+ else { const first = mainComp.values().next().value; if (first) { const [mx, my] = first.split(',').map(Number); b.x = mx + 0.5; b.y = my + 0.5; } }
+ b.wTX = null; b.wTY = null; b.retargetAt = 0; b.pauseUntil = 0;
+ }
+ 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' ? 'หมดเวลา — จบมินิเกม'
+ : (reason === 'all_dead' ? 'ตกขอบหมดแล้ว — จบมินิเกม' : 'จบมินิเกม');
+ 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;
+ }
+
+ /* ตายหมดแล้ว (all_dead) — อยู่ในช่วง grace: หยุดเกม + ให้เวลา client วาดจอ result-gameover → GCM
+ ก่อน server ดึงกลับ LobbyB. เดิม all_dead จบ+return ทันที (0 grace) = client ยังไม่ทันวาด game over
+ ก็โดนดึงกลับ = "ตายหมดแต่ไม่ขึ้น game over". ใช้ grace เท่ากับทางชนะ (GAUNTLET_FINISH_WALK_MS) */
+ if (gr.gameoverAt != null) {
+ if (gr.obstacles && gr.obstacles.length) gr.obstacles = [];
+ if (Date.now() >= gr.gameoverAt) {
+ endGauntletGame(sid, space, 'all_dead');
+ return;
+ }
+ /* ไม่ emit sync ระหว่าง grace — ปล่อย client คุม overlay result เอง (เหมือน finish grace) */
+ return;
+ }
+
+ /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client
+ เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */
+ if (gr.finishPhase) {
+ if (gr.obstacles.length) { gr.obstacles = []; }
+ /* ตายหมดตั้งแต่ก่อน/ระหว่าง finish-walk grace → เข้า grace game over (แทน result-complete จากทางเส้นชัย)
+ (race: client ตัวหนึ่ง scroll ถึงเส้นชัยก่อน → set finishPhase → early-return ข้ามเช็ค all_dead ท้าย tick) */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ตายหมด (ระหว่าง finish-phase) → grace game over → กลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ return;
+ }
+ 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);
+ });
+ }
+
+ /* ผู้เล่น (คนจริง) ตกขอบหมด → เข้า grace game over (ไม่รอหมดเวลา) แล้วค่อย return LobbyB
+ ส่ง sync ครั้งสุดท้าย (ทุกคน eliminated) ให้ client ทริก overlay result-gameover ก่อนหมด grace */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ผู้เล่นตกขอบหมด → grace game over แล้วกลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ emitGauntletSync(sid, space);
+ return;
+ }
+
+ emitGauntletSync(sid, space);
+}
+
+/** MG2 crown/gauntlet: คนจริงที่ "ยังเล่นอยู่" (ไม่นับผู้ถูกแบน/ผู้ชม) ตกขอบหมด = จบเกม all_dead
+ บอทอยู่ใน gr.bots ไม่ใช่ space.peers → ไม่ถูกนับอยู่แล้ว (server ตัดสินจากคนจริงเท่านั้น)
+ - นับเฉพาะ crown map · ต้องมีคนเล่นจริง ≥1 คน (กันจบทันทีตอน 0 คน) */
+function gauntletAllHumansEliminated(space) {
+ if (!isGauntletCrownHeistMapBySpace(space) || !space.peers || space.peers.size === 0) return false;
+ let active = 0;
+ let alive = 0;
+ space.peers.forEach((p) => {
+ if (!p || p.bannedSpectator) return; /* ผู้ถูกแบน = เข้ามาดู ไม่ได้เล่น → ไม่บล็อกการจบเกม */
+ active++;
+ if (!p.gauntletEliminated) alive++;
+ });
+ return active > 0 && alive === 0;
+}
+
+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: clampTroublesomeOfferSec(runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) });
+}
+
+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) {
+ const rollMaxMs = Math.max(1000, Math.round(clampTroublesomeRollMaxSec(runtimeGameTiming && runtimeGameTiming.troublesomeRollMaxSec) * 1000));
+ space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), rollMaxMs);
+ }
+}
+
+/**
+ * ห้องทดสอบเอดิเตอร์ / 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) {
+ /* rejoin whitelist: เช็ค playerKey เป็นหลัก (กันเคส 2 คนชื่อซ้ำ — ลบ nick คนนึงตอนออก ทำให้อีกคน rejoin ไม่ได้)
+ fallback เป็นชื่อ (คนที่ไม่มี playerKey / ความเข้ากันได้เดิม) */
+ const jk = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? ('k:' + playerKey) : '';
+ const wlKeys = space.caseParticipantKeys;
+ const wlNicks = space.caseParticipantNicknames;
+ const okByKey = !!(jk && wlKeys && wlKeys.size > 0 && wlKeys.has(jk));
+ const okByNick = !!(joinNickNorm && wlNicks && wlNicks.size > 0 && wlNicks.has(joinNickNorm));
+ if (!okByKey && !okByNick) {
+ 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 }); });
+ }
+ /* MG3–MG7 pending takeover (คนหลุดช่วง how-to/นับถอยหลัง — ยังไม่มี session live): กลับมาทันก่อนเกมเริ่ม
+ → ล้าง pending ที่ playerKey ตรง กัน drainPendingMissionTakeovers seed เป็นบอทซ้ำตอน startXxxGame
+ ROOT CAUSE "บอทเข้าไปเล่นด้วย MG3/5/7": rejoin เดิมถอนบอทเฉพาะ session ที่ live แล้ว (บล็อกข้างบน)
+ แต่ shell (MG3-7) สร้าง session ตอน 3-2-1 จบ → ตอน rejoin ยังไม่มี session = ไม่มีอะไรถอน +
+ pending ค้าง → seed เป็น "บอทผี" คู่กับคนจริงที่ rejoin. emit user-left(id เก่า) ลบ avatar บอท placeholder */
+ if (Array.isArray(space.pendingMissionTakeovers) && space.pendingMissionTakeovers.length) {
+ const cancelled = space.pendingMissionTakeovers.filter((r) => r && r.playerKey && r.playerKey === joinPlayerKey);
+ if (cancelled.length) {
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => !(r && r.playerKey && r.playerKey === joinPlayerKey));
+ cancelled.forEach((r) => { if (r.id) io.to(spaceId).emit('user-left', { id: r.id }); });
+ glog(spaceId, space, 'join', '[TC173] rejoin ก่อนเกมเริ่ม → ยกเลิก pending bot takeover ' + cancelled.length + ' (กันบอทผี MG3/5/7 · pass)', nickname);
+ }
+ }
+ }
+ /* 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,
+ netReady: false, /* เพิ่ง join → กำลังโหลด จนกว่า client ส่ง client-ready (badge ให้คนอื่นเห็นว่า "กำลังโหลด" ไม่ใช่ค้าง) */
+ };
+ 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),
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ botSlotCount: effectiveBotSlotCount(space),
+ lobbyBotThemes: syncLobbyBotThemeSlots(space),
+ joinLocked: !!space.joinLocked,
+ missionLiveBeginsAt: space.missionLiveBeginsAt || null,
+ missionLive: isMinigameLiveForRejoinResume(space), /* rejoin กลางเกม live → client ข้าม how-to เข้าเล่นต่อ */
+ };
+ 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', (data) => {
+ 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;
+ /* ใช้เฟสที่ผู้เล่นเห็นจริงตอนกด (client ส่งมา) → บล็อกลงตรงจุดที่เห็นแกว่ง ไม่เยื้องตาม half-RTT/latency.
+ sanity bound กว้าง (STACK_DROP_PHASE_TOL = 2π) เหลือไว้กันค่า garbage เท่านั้น (ดู const) */
+ let dropPhase = s.phase;
+ const cph = data && Number(data.phase);
+ if (Number.isFinite(cph) && Math.abs(cph - s.phase) <= STACK_DROP_PHASE_TOL) dropPhase = cph;
+ const hit = stackComputeDropServer(s, md, dropPhase);
+ stackApplyHitServer(s, hit, { id: entry.id });
+ const peer = space.peers.get(socket.id);
+ const theme = stackHumanThemeServer(peer) || (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: dropPhase,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = Date.now() + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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 }] });
+ }
+ });
+
+ /* MG5 — ผู้เล่นสลับแท็บ (hidden): browser throttle setInterval เหลือ ~1Hz → emit move ~1/วิ = คนอื่นเห็นตกเป็นก้อน.
+ แก้: client แจ้ง hidden พร้อม state ปัจจุบัน → server จำลองการตก (jsPhysicsIntegrate) แทน ใน stepJumpSurviveBots
+ แล้ว broadcast ผ่าน jump-survive-bot-move (full rate) → คนอื่นเห็นลื่น. visible → hidden:false → client คุมเอง. */
+ socket.on('jump-survive-hidden', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !data) return;
+ const s = space.jumpSurviveSession;
+ if (!s || !s.active || s.ended) return;
+ if (!s.players[socket.id]) s.players[socket.id] = {};
+ const st = s.players[socket.id];
+ if (data.hidden) {
+ if (st.eliminated) return;
+ st.hidden = true;
+ st.x = Number.isFinite(data.x) ? data.x : (Number.isFinite(st.x) ? st.x : 1);
+ st.y = Number.isFinite(data.y) ? data.y : (Number.isFinite(st.y) ? st.y : 1);
+ st.vy = Number.isFinite(data.vy) ? data.vy : 0;
+ st.onGround = !!data.onGround;
+ st.dir = data.direction || st.dir || 'down';
+ } else {
+ st.hidden = false;
+ }
+ });
+
+ /** เปลี่ยนชื่อที่แสดงใน 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) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK เริ่มสืบสวน — รอผู้เล่นกลับเข้า LobbyB ครบ (' + waitInfo.present + '/' + waitInfo.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({
+ ok: false,
+ waitingPlayers: true,
+ present: waitInfo.present,
+ total: waitInfo.total,
+ error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')',
+ });
+ }
+ /* TC156: gate จนกว่าทุกคนอ่าน story (cutscene) ครบ — เฉพาะรอบแรก (storyReadComplete เป็น true หลังครบ/รอบถัดไป) */
+ if (!space.storyReadComplete) {
+ const stRead = detectiveStoryReadStatus(space);
+ if (!stRead.allRead) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'story-gate', '[TC156] BLOCK เริ่มสืบสวน — รอทุกคนอ่าน story ครบ (' + stRead.present + '/' + stRead.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingStory: true, present: stRead.present, total: stRead.total, error: 'รอผู้เล่นอ่านเนื้อเรื่องให้ครบก่อน (' + stRead.present + '/' + stRead.total + ')' });
+ }
+ space.storyReadComplete = true;
+ }
+ 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' });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'vote-gate', '[TC157] เริ่ม vote ban (ผู้เล่นเข้า LobbyB ครบ ' + waitInfo.present + '/' + waitInfo.total + ') — มินิเกมจะเริ่มหลังโหวตจบเท่านั้น (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ 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);
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ ' + waitInfo.total + ' คน · ผู้ต้องสงสัย #' + (selectedIndex + 1), (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ reply(started);
+ });
+
+ socket.on('detective-minigame-finished', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) {
+ return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ }
+ /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
+ const finPeer = space.peers.get(socket.id);
+ if (finPeer && data && typeof data === 'object') {
+ /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
+ หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */
+ if (Number.isFinite(Number(data.score))) {
+ let rs = Math.max(0, Math.floor(Number(data.score)));
+ if (rs > REPORTED_MINI_SCORE_MAX) {
+ console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX);
+ rs = REPORTED_MINI_SCORE_MAX;
+ }
+ finPeer.reportedMiniScore = rs;
+ }
+ if (data.survived === false) finPeer.reportedMiniSurvived = false;
+ }
+ if (!space.detectiveMinigameActive) {
+ if (serverMapIsPostCaseLobbyB(space)) {
+ const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
+ return reply({
+ ok: true,
+ alreadyOnLobbyB: true,
+ suspectIndex: grant.suspectIdx,
+ awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
+ awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
+ grade: grant.grade || null,
+ freeEvidenceCount: grant.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ }
+ return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
+ }
+ /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก"
+ จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด
+ ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */
+ const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) {
+ if (finPeer) finPeer.detectiveFinished = true;
+ const allDone = [...space.peers.values()].every((p) =>
+ p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished
+ );
+ if (!allDone) {
+ /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */
+ return reply({ ok: true, holdInScene: true });
+ }
+ /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */
+ }
+ // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
+ returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
+ const g = space.lastEvidenceGrant || {};
+ reply({
+ ok: true,
+ suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
+ awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
+ awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
+ grade: g.grade || null,
+ freeEvidenceCount: g.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ /**
+ * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที
+ * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)
+ */
+ socket.on('detective-debug-award-evidence', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ const idx = Math.floor(Number(data && data.suspectIndex));
+ if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' });
+ const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C');
+ if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' });
+ emitLobbyEvidenceSync(sid, space);
+ reply({
+ ok: true,
+ suspectIndex: idx,
+ awardedCard: card,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย)
+ socket.on('suspect-accuse-open', (_data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
+ const prog = playerSuspectProgressFromEvidence(space, socket.id);
+ if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
+ /* ชี้ตัวคนร้าย = เข้าพิจารณาคดี → ต้องรอผู้เล่นในคดีกลับเข้า LobbyB ครบก่อน (กันคนตกรอบโหวต) */
+ const accuseWait = detectiveLobbyBMissingParticipants(space);
+ if (accuseWait.missing > 0) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK ชี้ตัวคนร้าย — รอผู้เล่นเข้า LobbyB ครบ (' + accuseWait.present + '/' + accuseWait.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingPlayers: true, present: accuseWait.present, total: accuseWait.total, error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + accuseWait.present + '/' + accuseWait.total + ')' });
+ }
+ 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;
+ space.trialVoteAt = space.trialVoteAt || {};
+ space.trialVoteAt[socket.id] = Date.now(); /* [achv c4] เวลาโหวต → เช็ค "10 วิสุดท้าย" */
+ emitTrialVoteUpdate(sid, space);
+ const totalTV = trialEligibleVoterTotal(space);
+ const votedNow = Object.keys(space.trialVotes).length;
+ try { glog(sid, space, 'trial-vote', '[TC180] ยืนยันโหวต ' + votedNow + '/' + totalTV + (votedNow >= totalTV && totalTV > 0 ? ' — ครบ → reveal (pass)' : ' — รออีก')); } catch (e) { /* ignore */ }
+ if (votedNow >= 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)) {
+ /* ล็อก: host กำลังเปิด wizard เลือกระดับ/คดี → ปฏิเสธ set-ready (กัน client หลุด flow ตอน host เลือกอยู่) */
+ if (space.preplayOpen && space.hostId !== socket.id) {
+ const p = space.peers.get(socket.id);
+ io.to(socket.id).emit('peer-ready', { id: socket.id, ready: !!(p && p.ready) }); /* ย้ำสถานะเดิมกลับให้ผู้ส่ง */
+ try { glog(sid, space, 'preplay-lock', '[TC154] REJECT set-ready ระหว่าง host เลือกคดี — feature ทำงาน (pass): client ยกเลิกพร้อมไม่ได้', (p && p.nickname) || String(socket.id).slice(0, 6)); } catch (e) { /* ignore */ }
+ break;
+ }
+ const p = space.peers.get(socket.id);
+ if (p) p.ready = !!ready;
+ io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
+ break;
+ }
+ }
+ });
+
+ /* LobbyA: host เปิด/ปิด wizard เลือกระดับ/คดี → เก็บ state ที่ server + relay ทั้งห้อง (ล็อกปุ่มพร้อมฝั่ง client) */
+ socket.on('lobby-preplay-open', ({ open } = {}) => {
+ for (const [sid, space] of spaces) {
+ if (space.peers.has(socket.id) && space.hostId === socket.id) {
+ space.preplayOpen = !!open;
+ io.to(sid).emit('lobby-preplay-open', { open: !!open });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'preplay-lock', '[TC154] ' + (open ? 'host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง' : 'host ปิด wizard → ปลดล็อก set-ready') + ' (' + space.peers.size + ' คนในห้อง)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ break;
+ }
+ }
+ });
+
+ /* TC156: client อ่าน story (cutscene) จบ → บันทึกว่าคนนี้พร้อม · ครบทุกคน → ปลดล็อกเลือกผู้ต้องสงสัย */
+ socket.on('story-read-done', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.storyReadKeys) space.storyReadKeys = new Set();
+ const already = space.storyReadKeys.has(key);
+ space.storyReadKeys.add(key);
+ /* อ่าน story จบ = โหลด LobbyB เสร็จแน่นอน → นับ lobbyb-loaded ด้วย (reliable กว่า event lobbyb-loaded เดี่ยว) */
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ space.lobbyBLoadedKeys.add(key);
+ const st = detectiveStoryReadStatus(space);
+ if (!already) { try { glog(sid, space, 'story-gate', '[TC156] อ่าน story จบ ' + st.present + '/' + st.total, nick); } catch (e) { /* ignore */ } }
+ if (st.allRead && !space.storyReadComplete) {
+ space.storyReadComplete = true;
+ if (space.storyReadTimer) { clearTimeout(space.storyReadTimer); space.storyReadTimer = null; }
+ try { glog(sid, space, 'story-gate', '[TC156] ทุกคนอ่าน story ครบ (' + st.total + ') → ปลดล็อกเลือกผู้ต้องสงสัย (pass)'); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveStoryReadPresence(sid, space);
+ /* piggyback เพิ่ม lobbyBLoadedKeys แล้ว → ต้อง broadcast lobbyb-presence ใหม่ด้วย
+ ไม่งั้น client ค้างโชว์ "รอผู้เล่น (2/3)" (ค่าเก่า) → ปุ่ม host ถูก disable ทั้งที่ครบแล้ว */
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉาก LobbyB เสร็จจริง → นับเป็น "present" ได้ (กัน host เริ่มก่อนคนโหลดเสร็จ = ค้างตอนจบ) */
+ socket.on('lobbyb-loaded', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ if (!space.lobbyBLoadedKeys.has(key)) {
+ space.lobbyBLoadedKeys.add(key);
+ const info = detectiveLobbyBMissingParticipants(space);
+ try { glog(sid, space, 'lobbyb-gate', '[TC144] โหลด LobbyB เสร็จ ' + info.present + '/' + info.total, nick); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉากเสร็จ (ทุกฉาก lobby/minigame) → เอา badge "กำลังโหลด" ออก (คนอื่นเห็นว่าพร้อมแล้ว) */
+ socket.on('client-ready', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ const p = space.peers.get(socket.id);
+ if (p && p.netReady !== true) {
+ p.netReady = true;
+ io.to(sid).emit('peer-net', { id: socket.id, nr: true });
+ }
+ 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) {
+ space.preplayOpen = false; /* host เริ่มเกมแล้ว → ปลดล็อก (client เปลี่ยนฉากอยู่แล้ว แต่เคลียร์ state กัน stuck) */
+ 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();
+ const caseKeyWl = new Set();
+ space.peers.forEach((p) => {
+ caseNickWl.add(normalizeLobbyNickname(p.nickname)); /* ชื่อ — ใช้ whitelist rejoin (คงเดิม) */
+ const k = detectiveParticipantKey(p);
+ if (k) caseKeyWl.add(k); /* playerKey — ใช้ presence/story/gate (กันชื่อซ้ำนับเป็นคนเดียว) */
+ });
+ space.caseParticipantNicknames = caseNickWl;
+ space.caseParticipantKeys = caseKeyWl;
+ /* TC156: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story (cutscene) ครบ — reset ที่ initial LobbyA→LobbyB เท่านั้น
+ (รอบ investigate ถัดไปไม่มี cutscene → storyReadComplete คงเป็น true) · safety timer กัน deadlock ถ้า client ไม่ emit */
+ space.storyReadKeys = new Set();
+ space.storyReadComplete = false;
+ space.lobbyBLoadedKeys = new Set(); /* เริ่มคดี → ทุกคนต้องส่ง lobbyb-loaded ใหม่ ก่อนนับ present */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock */
+ if (space.storyReadTimer) clearTimeout(space.storyReadTimer);
+ space.storyReadTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (sp && !sp.storyReadComplete) {
+ sp.storyReadComplete = true;
+ try { glog(sid, sp, 'story-gate', '[TC156] safety-timeout 90s → force ปลดล็อก (client บางคนไม่ส่ง story-read-done)'); } catch (e) { /* ignore */ }
+ try { emitDetectiveStoryReadPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 90000);
+ 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 ยังมีอยู่ — รวมช่วง how-to / pre-run hold (crownRunHeld) ด้วย เหมือน MG1
+ (เดิมข้าม crownRunHeld → host หลุดตอน how-to = avatar หายไปเลย ไม่มีบอทรับช่วง; ตอนนี้แปลงเป็นบอททันที
+ บอทจะค้างในเลนตอน how-to แล้ววิ่งเองเมื่อเกมเริ่ม — emitGauntletSync ส่งบอท takeover ระหว่าง hold อยู่แล้ว) */
+ if (grT && md9b && md9b.gameType === 'gauntlet') {
+ 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 */ }
+ /* MG3–MG7 หลุดช่วง how-to / นับถอยหลัง (ยังไม่มี session live) → เก็บ pending takeover ไว้
+ แล้ว seed เป็นบอทตอนเกมเริ่ม + แจ้ง client เก็บ avatar (*-player-to-bot) เหมือน MG1/MG2
+ (เดิม: ไม่มี session = ไม่ takeover = avatar หายเลยตอน how-to) */
+ let shellTakeoverEvent = null;
+ try {
+ if (!mg1Takeover && !mg2Takeover && !mg3Takeover && !mg4Takeover && !mg5Takeover && !mg6Takeover && !mg7Takeover && space.detectiveMinigameActive) {
+ const gtShell = missionShellGameTypeForSpace(space);
+ const liveActive = !!(
+ (gtShell === 'space_shooter' && space.spaceShooterSession && space.spaceShooterSession.active) ||
+ (gtShell === 'jump_survive' && space.jumpSurviveSession && space.jumpSurviveSession.active) ||
+ (gtShell === 'balloon_boss' && space.balloonBossSession && space.balloonBossSession.active) ||
+ (gtShell === 'quiz_carry' && space.quizCarrySession && space.quizCarrySession.active) ||
+ (gtShell === 'stack' && space.stackSession && space.stackSession.active)
+ );
+ if (gtShell && !liveActive) {
+ recordPendingMissionTakeover(space, gtShell, socket.id, leftPeerInfo, leftPlayerKey, leftNick, leftWasEliminated);
+ shellTakeoverEvent =
+ gtShell === 'space_shooter' ? 'space-shooter-player-to-bot' :
+ gtShell === 'jump_survive' ? 'jump-survive-player-to-bot' :
+ gtShell === 'balloon_boss' ? 'balloon-boss-player-to-bot' :
+ gtShell === 'quiz_carry' ? 'quiz-carry-player-to-bot' :
+ gtShell === 'stack' ? 'stack-player-to-bot' : null;
+ }
+ }
+ } 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.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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.pendingMissionTakeovers = []; /* ห้องว่าง → ล้าง pending takeover ค้าง (กัน seed บอทผีรอบหน้า) */
+ 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) {
+ /* host หลุดกลางเปิด wizard เลือกคดี → ปลดล็อกปุ่มพร้อมให้คนที่เหลือ (กัน stuck lock) */
+ if (space.preplayOpen) { space.preplayOpen = false; io.to(sid).emit('lobby-preplay-open', { open: false }); try { glog(sid, space, 'preplay-lock', '[TC154] host หลุดกลางเปิด wizard → auto-ปลดล็อก set-ready (กัน stuck)', leftNick); } catch (e) { /* ignore */ } }
+ 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 if (shellTakeoverEvent) {
+ /* how-to/นับถอยหลัง: client เก็บ avatar + ติดป้าย (บอท) — บอท server จะ seed ตอนเกมเริ่ม (pendingMissionTakeovers) */
+ io.to(sid).emit(shellTakeoverEvent, { id: socket.id, nickname: leftNick });
+ glog(sid, space, 'leave', 'หลุดช่วง how-to → บอทรับช่วงตอนเกมเริ่ม · เหลือคน ' + 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 */ }
+ /* เอา participantKey ออกจาก roster gate ด้วย (playerKey — กัน gate รอคนที่ออกถาวร) */
+ try {
+ if (sp.caseParticipantKeys) {
+ if (leftPlayerKey && /^[a-zA-Z0-9_-]{8,128}$/.test(leftPlayerKey)) sp.caseParticipantKeys.delete('k:' + leftPlayerKey);
+ sp.caseParticipantKeys.delete('n:' + 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', (data) => {
+ 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;
+ const changed = quizCarryServerTryInteract(sid, space, socket.id, false, data);
+ /* กดแล้วไม่เกิดอะไร (หยิบไม่ได้/ส่งไม่ได้) → ยืนยัน held จริงกลับไปให้ "คนกด" เพื่อแก้ optimistic prediction
+ ที่อาจค้างผิด (เช่น ทำนายว่าหยิบได้แต่ server ปฏิเสธเพราะคนอื่นถืออยู่) */
+ if (!changed) {
+ const hp = s.players[socket.id];
+ io.to(socket.id).emit('quiz-carry-held', { id: socket.id, held: hp ? (hp.held == null ? null : hp.held) : null });
+ }
+ });
+
+ /* ตอบเวลา 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));
\ No newline at end of file
diff --git a/www/html/Game/server.js.bak-20260706-034307 b/www/html/Game/server.js.bak-20260706-034307
new file mode 100644
index 0000000..c1f628e
--- /dev/null
+++ b/www/html/Game/server.js.bak-20260706-034307
@@ -0,0 +1,13271 @@
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { Server } = require('socket.io');
+
+const PORT = parseInt(process.env.PORT, 10) || 3001;
+/** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */
+const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0';
+const BASE_PATH = '/Game';
+const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
+const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
+const adminTokens = new Set();
+/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
+const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
+/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */
+const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
+/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */
+/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */
+const DETECTIVE_MINIGAME_POOL = [
+ { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' },
+ { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' },
+ { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' },
+ { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' },
+ { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' },
+ { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' },
+ { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' },
+];
+/** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */
+function normalizeForcedMinigameKeys(v) {
+ let arr;
+ if (Array.isArray(v)) arr = v;
+ else if (v != null && String(v).trim()) arr = [v, v, v];
+ else arr = [];
+ const one = (k) => {
+ const s = (k == null ? '' : String(k)).trim();
+ return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : '';
+ };
+ return [one(arr[0]), one(arr[1]), one(arr[2])];
+}
+const LOBBY_SLOT_TOTAL = 6;
+
+/* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้
+ ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */
+const LIVE_COUNTDOWN_MS = 3500;
+const LOBBY_THEME_COUNT = 8;
+const LOBBY_SKIN_COUNT = 3;
+
+/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
+function getTakenLobbyThemeIndices(space, excludePeerId) {
+ const taken = new Set();
+ if (!space) return taken;
+ space.peers.forEach((p, id) => {
+ if (excludePeerId && id === excludePeerId) return;
+ const ti = parseInt(p.lobbyColorThemeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ const bots = space.lobbyBotThemeBySlot;
+ if (bots && typeof bots === 'object') {
+ Object.keys(bots).forEach((k) => {
+ const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
+ if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
+ });
+ }
+ return taken;
+}
+
+function pickFreeLobbyThemeIndex(space, excludePeerId) {
+ const taken = getTakenLobbyThemeIndices(space, excludePeerId);
+ for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
+ if (!taken.has(i)) return i;
+ }
+ const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
+ return ((n - 1) % LOBBY_THEME_COUNT) + 1;
+}
+
+function pickLobbySkinIndexForSlot(slotIndex) {
+ const s = Number(slotIndex);
+ if (!Number.isFinite(s)) return 1;
+ return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
+}
+
+/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น
+ 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;
+ /* เลือกผู้เล่น ≥ 4 คนตอนสร้างห้อง → ไม่เติมบอท (รอคนจริง) — เว้นแต่ host ตั้งบอทเองภายหลัง (n>0) */
+ if (n === 0 && maxP >= 4) return 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');
+const SOUND_SETTINGS_PATH = path.join(__dirname, 'data', 'sound-settings.json');
+const SOUND_SETTINGS_DEFAULT = { masterVol: 1.0, musicVol: 0.35, sfxVol: 0.75 };
+function loadSoundSettings() {
+ try {
+ if (fs.existsSync(SOUND_SETTINGS_PATH)) {
+ const j = JSON.parse(fs.readFileSync(SOUND_SETTINGS_PATH, 'utf8'));
+ const clamp = (v, d) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : d; };
+ return {
+ masterVol: clamp(j.masterVol, SOUND_SETTINGS_DEFAULT.masterVol),
+ musicVol: clamp(j.musicVol, SOUND_SETTINGS_DEFAULT.musicVol),
+ sfxVol: clamp(j.sfxVol, SOUND_SETTINGS_DEFAULT.sfxVol),
+ };
+ }
+ } catch (e) { console.error('loadSoundSettings', e.message); }
+ return Object.assign({}, SOUND_SETTINGS_DEFAULT);
+}
+function saveSoundSettings(s) {
+ try { fs.writeFileSync(SOUND_SETTINGS_PATH, JSON.stringify(s, null, 2)); } catch (e) { console.error('saveSoundSettings', e.message); }
+}
+
+/* ===== 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 นี้
+ ตั้ง 22 (เดิม 10) — จาก log 6 คน join ใช้เวลา 10-16 วิ (โหลดหนัก) → grace 10 หมดก่อน = bot takeover ผิด → "ตัวละครซ้ำ 2 ตัว" (บอท+คนที่กลับมาช้า) + คนหลุด */
+const DISCONNECT_GRACE_SEC = 22;
+
+/** โหมดเทสต์ (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) ต่อช่อง 1–16 */
+ carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(),
+ /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */
+ carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(),
+ /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */
+ carryChoicePlaqueMapScale: 1.25,
+ /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */
+ carryWalkSpeedMultForMapId: '',
+ carryWalkSpeedMult: null,
+ /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */
+ quizRoundQuestionCount: 10,
+ questions: [],
+ carryQuestions: [],
+ battleQuizMcq: [],
+ /** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */
+ specialQuizSets: sanitizeSpecialQuizSets({}),
+ };
+}
+
+function defaultCarryMapPanelTheme() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+}
+
+function sanitizeCssColorToken(input, fallback) {
+ const t = String(input == null ? '' : input).trim().slice(0, 120);
+ if (!t) return fallback;
+ if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) {
+ const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, '');
+ if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim();
+ }
+ return fallback;
+}
+
+function sanitizeCarryMapPanelTheme(raw) {
+ const d = defaultCarryMapPanelTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ let qMin = Number(raw.questionFontMinPx);
+ let qMax = Number(raw.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const t = qMin;
+ qMin = qMax;
+ qMax = t;
+ }
+ return {
+ panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg),
+ panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder),
+ borderWidthPx: (() => {
+ let w = Number(raw.borderWidthPx);
+ if (!Number.isFinite(w)) w = d.borderWidthPx;
+ return Math.max(0, Math.min(12, Math.round(w)));
+ })(),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+}
+
+/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */
+function defaultCarryChoicePlaqueTheme() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ };
+}
+
+function sanitizeCarryChoicePlaqueTheme(raw) {
+ const d = defaultCarryChoicePlaqueTheme();
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({});
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder),
+ fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg),
+ textColor: sanitizeCssColorToken(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl),
+ };
+}
+
+const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16;
+
+function defaultCarryChoicePlaqueThemes() {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme({}));
+ }
+ return out;
+}
+
+/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */
+function sanitizeCarryChoicePlaqueThemes(raw) {
+ if (Array.isArray(raw) && raw.length > 0) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) {
+ out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {}));
+ }
+ return out;
+ }
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
+ const one = sanitizeCarryChoicePlaqueTheme(raw);
+ return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one }));
+ }
+ return defaultCarryChoicePlaqueThemes();
+}
+
+function sanitizeCarryChoiceImageUrl(input) {
+ const s = String(input == null ? '' : input).trim().slice(0, 512);
+ if (!s) return '';
+ if (/[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const u = new URL(s);
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+}
+
+function defaultCarryEmbedCountdownTheme() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+}
+
+function sanitizeCarryEmbedCountdownTheme(raw) {
+ const d = defaultCarryEmbedCountdownTheme();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ return {
+ overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg),
+ innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+}
+
+/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
+const QUIZ_BATTLE_CATEGORY_IDS = new Set([
+ 'topic1', 'topic2', 'topic3', 'topic4', 'topic5',
+ 'topic6', 'topic7', 'topic8', 'topic9', 'topic10',
+]);
+
+/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
+function sanitizeBattleQuizMcq(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ let categoryId = String(q.categoryId || '').trim();
+ if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
+ categoryId = 'topic1';
+ }
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ const rawChoices = Array.isArray(q.choices) ? q.choices : [];
+ const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
+ if (choices.length !== 3 || !choices.every(Boolean)) continue;
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ out.push({ categoryId, text, choices, correctIndex: ci });
+ if (out.length >= 200) break;
+ }
+ return out;
+}
+
+/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
+function sanitizeCarryQuestions(arr) {
+ if (!Array.isArray(arr)) return [];
+ const out = [];
+ for (const q of arr) {
+ if (!q || typeof q !== 'object') continue;
+ const text = String(q.text || '').trim().slice(0, 500);
+ if (!text) continue;
+ let choices = Array.isArray(q.choices)
+ ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
+ : [];
+ if (choices.length < 2) continue;
+ if (choices.length > 16) choices = choices.slice(0, 16);
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
+ const row = { text, choices, correctIndex: ci };
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) row.choiceImageUrls = urls;
+ }
+ out.push(row);
+ if (out.length >= 80) break;
+ }
+ return out;
+}
+
+function clampQuizMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(1000, Math.min(300000, Math.floor(v)));
+}
+
+function clampQuizBetweenMs(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(300000, Math.floor(v)));
+}
+
+function clampCarrySessionLength(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+}
+
+/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
+function clampQuizRoundQuestionCount(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) {
+ const d = Number(def);
+ if (!Number.isFinite(d)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(d)));
+ }
+ return Math.max(1, Math.min(50, Math.floor(v)));
+}
+
+function clampCarryChoicePlaqueMapScale(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
+}
+
+/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
+function sanitizeCarryWalkSpeedMultForMapId(raw) {
+ const t = String(raw == null ? '' : raw).trim().slice(0, 64);
+ if (!t) return '';
+ if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
+ return t;
+}
+
+/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
+function clampCarryWalkSpeedMultSetting(n) {
+ if (n === null || n === undefined || n === '') return null;
+ const v = Number(n);
+ if (Number.isNaN(v)) return null;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+}
+
+function mergeQuizCarryFromMirrorIfSet(base) {
+ if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
+ try {
+ if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
+ const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
+ const j = JSON.parse(raw);
+ if (!j || typeof j !== 'object') return base;
+ if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
+ base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
+ }
+ if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
+ base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
+ }
+ if (j.carryReadMs != null) {
+ base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
+ }
+ if (j.carryAnswerMs != null) {
+ base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
+ }
+ if (j.carrySessionLength != null) {
+ base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
+ }
+ if (j.carryWalkSpeedMultForMapId != null) {
+ base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
+ }
+ if (j.carryWalkSpeedMult != null) {
+ const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
+ if (wm != null) base.carryWalkSpeedMult = wm;
+ }
+ if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
+ base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
+ }
+ if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
+ base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
+ base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
+ }
+ if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
+ base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
+ }
+ if (j.quizRoundQuestionCount != null) {
+ base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
+ }
+ return base;
+ } catch (e) {
+ console.error('mergeQuizCarryFromMirrorIfSet', e.message);
+ return base;
+ }
+}
+
+/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
+const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
+const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
+const EVIDENCE_CASE_COUNT = 15;
+
+/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
+const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
+const CASE_MEDIA_COUNT = 15;
+/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
+const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
+
+function caseMediaDefault(n) {
+ const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png');
+ const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png');
+ return {
+ name: 'คดีที่ ' + n,
+ art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
+ detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
+ story: [
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
+ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
+ ],
+ suspects,
+ culprits,
+ };
+}
+
+function sanitizeCaseMediaUrlList(arr, fallbackArr) {
+ const fb = Array.isArray(fallbackArr) ? fallbackArr : [];
+ const src = Array.isArray(arr) ? arr : [];
+ return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || ''));
+}
+
+/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
+function sanitizeCaseMediaUrl(v, fallback) {
+ if (typeof v !== 'string') return fallback;
+ const s = v.trim();
+ if (!s) return fallback;
+ if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
+ return s;
+}
+
+function sanitizeCaseMedia(obj) {
+ const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
+ const cases = {};
+ for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
+ const d = caseMediaDefault(n);
+ const c = src[n] || src[String(n)] || {};
+ const story = Array.isArray(c.story) ? c.story : [];
+ cases[n] = {
+ name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
+ art: sanitizeCaseMediaUrl(c.art, d.art),
+ detail: sanitizeCaseMediaUrl(c.detail, d.detail),
+ story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
+ suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects),
+ culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits),
+ };
+ }
+ return { cases };
+}
+
+function loadCaseMedia() {
+ try {
+ if (fs.existsSync(CASE_MEDIA_PATH)) {
+ return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
+ }
+ } catch (e) { /* ignore — คืนค่า default */ }
+ return sanitizeCaseMedia({});
+}
+
+function saveCaseMedia(d) {
+ try {
+ const clean = sanitizeCaseMedia(d);
+ fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
+ return { ok: true, cases: clean.cases };
+ } catch (e) {
+ let err = 'บันทึกไม่ได้';
+ if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
+ else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
+ return { ok: false, error: err };
+ }
+}
+
+/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
+function listCaseMediaImages() {
+ const out = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] };
+ ['case', 'cutscene', 'suspect-pick', 'culprit-prison'].forEach((sub) => {
+ try {
+ const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
+ out[sub] = fs.readdirSync(p)
+ .filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ });
+ return out;
+}
+const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
+/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
+const EVIDENCE_RARITY_RANGES = [
+ { rarity: 'common', min: 1, max: 22, stars: 1 },
+ { rarity: 'rare', min: 23, max: 44, stars: 2 },
+ { rarity: 'legendary', min: 45, max: 54, stars: 3 },
+];
+
+function evidenceRarityForNum(n) {
+ for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
+ return EVIDENCE_RARITY_RANGES[0];
+}
+
+function evidenceCardImageDefault(suspectIdx, cardIdx) {
+ return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
+}
+
+/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
+let _evidencePoolCache = null;
+let _evidencePoolCacheAt = 0;
+function evidenceImagePools() {
+ const now = Date.now();
+ if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
+ const pools = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const buckets = { common: [], rare: [], legendary: [] };
+ try {
+ const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
+ fs.readdirSync(p).forEach((f) => {
+ const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
+ if (!m) return;
+ const num = parseInt(m[1], 10);
+ const rr = evidenceRarityForNum(num);
+ buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
+ });
+ } catch (e) { /* โฟลเดอร์อาจไม่มี */ }
+ Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
+ pools[dir] = buckets;
+ });
+ _evidencePoolCache = pools;
+ _evidencePoolCacheAt = now;
+ return pools;
+}
+
+function evidencePoolCounts() {
+ const pools = evidenceImagePools();
+ const out = {};
+ EVIDENCE_IMAGE_DIRS.forEach((dir) => {
+ const b = pools[dir] || { common: [], rare: [], legendary: [] };
+ out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
+ });
+ return out;
+}
+
+/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
+function rollEvidenceRarityKey(grade) {
+ const r = Math.random();
+ if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
+ if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
+ return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
+}
+
+/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
+function evidenceImageDirForSuspect(space, suspectIndex) {
+ const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
+ try {
+ const cid = String((space && space.caseId) || '1');
+ const cfg = loadEvidenceCards();
+ const cse = cfg.cases && cfg.cases[cid];
+ const s = cse && cse.suspects && cse.suspects[suspectIndex];
+ if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
+ } catch (e) { /* ใช้ default */ }
+ return def;
+}
+
+/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
+function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
+ const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
+ const pools = evidenceImagePools();
+ const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
+ let want = rollEvidenceRarityKey(grade || 'C');
+ let pool = buckets[want];
+ if (!pool || !pool.length) {
+ const tryOrder = ['common', 'rare', 'legendary'];
+ for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
+ }
+ if (!pool || !pool.length) return null;
+ const pick = pool[Math.floor(Math.random() * pool.length)];
+ return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
+}
+
+function evidenceGradeFromPct(avgPct) {
+ return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
+}
+
+/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
+function evidencePlayerRawScore(space, p, gameType) {
+ switch (gameType) {
+ case 'gauntlet':
+ case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
+ case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
+ case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
+ default:
+ return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
+ }
+}
+
+/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
+function computeRunGradeForEvidence(space) {
+ try {
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gameType = md && md.gameType;
+ /* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C
+ quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */
+ if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
+ const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
+ const ids = Object.keys(space.quizSession.players);
+ if (total > 0 && ids.length) {
+ const avgPct = ids.reduce((s, id) => {
+ const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
+ return s + Math.min(100, (sc / total) * 100);
+ }, 0) / ids.length;
+ return evidenceGradeFromPct(avgPct);
+ }
+ }
+ /* 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); }
+ });
+}
+
+/* streak achievements (a6/d4/e1) — POST action=streak {playerKey,id,event:'hit'|'miss'}
+ PHP นับต่อ/รีเซ็ตเอง + set achievement = best streak (ไม่ลดลง) */
+function submitAchievementStreak(items) {
+ const secret = gameAwardSecret();
+ if (!secret || !Array.isArray(items) || !items.length) return;
+ items.forEach((it) => {
+ if (!it || !it.playerKey || !it.id || (it.event !== 'hit' && it.event !== 'miss')) return;
+ try {
+ fetch(ACHIEVEMENTS_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'streak', secret, playerKey: it.playerKey, id: it.id, event: it.event }),
+ }).then((r) => r.json()).then((d) => {
+ if (!d || !d.ok) console.error('[achv] streak failed', it.id, d);
+ }).catch((e) => console.error('[achv] streak error', e && e.message));
+ } catch (e) { console.error('[achv] streak 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_JUMP_HEIGHT_MULT = 0.52; /* MG2 ความสูงกระโดด = ×tile (จุดสูงสุดของส่วนโค้ง sin) — ปรับได้ใน Admin */
+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,
+ gauntletJumpHeightMult: GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT,
+ gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
+ troublesomeOfferSec: 15,
+ ...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,
+ troublesomeRollMaxSec: 8,
+ /** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */
+ specialQuizIconExpireSec: 10,
+ /** เวลาโหวตเลือกผู้เล่น (การ์ด Silence/Ban) วินาที */
+ pickVoteSec: 25,
+ /** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */
+ trialVoteSec: 30,
+ /** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
+ trialResultRevealMs: 7000,
+ };
+}
+
+/** อ่านเวลา 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; ค่าอื่น = 10–7200 */
+function clampJumpSurviveMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */
+function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = [];
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : '';
+ const out = [];
+ for (let i = 0; i < 3; i++) {
+ const o = arr[i];
+ const obj = o && typeof o === 'object' ? o : {};
+ let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : '');
+ if (i === 0 && !url && legacy) url = legacy;
+ let ww = Number(obj.w);
+ let hh = Number(obj.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ out.push({ url, w: ww, h: hh });
+ }
+ return out;
+}
+
+function clampSpaceShooterMissionTimeSec(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return 0;
+ return Math.max(10, Math.min(7200, Math.floor(v)));
+}
+
+/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */
+function normalizeSpaceShooterShipImageUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < 6; i++) {
+ out.push(sanitizeGauntletAssetUrl(arr[i]));
+ }
+ return out;
+}
+
+/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */
+function normalizeSpaceShooterAsteroidSpriteUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = [];
+ for (let i = 0; i < arr.length && out.length < 32; i++) {
+ const u = sanitizeGauntletAssetUrl(arr[i]);
+ if (u) out.push(u);
+ }
+ return out;
+}
+
+function clampSpaceShooterAsteroidExplodeFrameMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return 70;
+ return Math.max(30, Math.min(500, Math.round(v)));
+}
+
+const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040;
+function clampSpaceShooterAsteroidIntervalMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
+ return Math.max(200, Math.min(10000, Math.floor(v)));
+}
+
+/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */
+function normalizeSpaceShooterShipDamageOverlayUrls(val) {
+ let arr = val;
+ if (typeof arr === 'string') {
+ try {
+ const p = JSON.parse(arr);
+ if (Array.isArray(p)) arr = p;
+ } catch (e) {
+ arr = arr.split(/[\n\r]+/);
+ }
+ }
+ if (!Array.isArray(arr)) arr = [];
+ const out = ['', '', ''];
+ for (let i = 0; i < 3; i++) {
+ out[i] = sanitizeGauntletAssetUrl(arr[i]);
+ }
+ return out;
+}
+
+function clampGauntletTickMs(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
+ return Math.max(80, Math.min(800, Math.floor(v)));
+}
+
+function clampGauntletJumpTicks(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
+ return Math.max(1, Math.min(40, Math.floor(v)));
+}
+function clampGauntletJumpHeightMult(n) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_HEIGHT_MULT;
+ return Math.max(0.2, Math.min(1.5, v));
+}
+/** เวลารับข้อเสนอ "ตัวป่วน" (วินาที) — admin ปรับได้ใน game-timing; 5–120, default 15 */
+function clampTroublesomeOfferSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 15;
+ return Math.max(5, Math.min(120, Math.floor(v)));
+}
+/* เวลาสูงสุด (วินาที) ที่ระบบรอให้ทุกคนรายงานพร้อม ก่อน "บังคับสุ่ม" ผู้รับบทตัวป่วน (เดิม hardcode 8000ms) */
+function clampTroublesomeRollMaxSec(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return 8;
+ return Math.max(1, Math.min(30, 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(j.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(j, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(j.troublesomeOfferSec) : 15,
+ ...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,
+ troublesomeRollMaxSec: clampTroublesomeRollMaxSec(j.troublesomeRollMaxSec),
+ 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,
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
+ : 7000,
+ };
+ }
+ } 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),
+ gauntletJumpHeightMult: clampGauntletJumpHeightMult(d.gauntletJumpHeightMult),
+ gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
+ troublesomeOfferSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeOfferSec') ? clampTroublesomeOfferSec(d.troublesomeOfferSec) : ((runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) || 15),
+ ...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,
+ troublesomeRollMaxSec: Object.prototype.hasOwnProperty.call(d, 'troublesomeRollMaxSec')
+ ? clampTroublesomeRollMaxSec(d.troublesomeRollMaxSec)
+ : clampTroublesomeRollMaxSec(prev.troublesomeRollMaxSec),
+ 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),
+ trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
+ ? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
+ : (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
+ /* 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,
+ nr: p.netReady !== false, /* net-ready: false = เพิ่ง join กำลังโหลด (client โชว์ badge) */
+ };
+ 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) {
+ const payload = {
+ readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady),
+ expectedActive: (space && space.minigameExpectedActiveCount) || 0,
+ peers: buildPeersSnapForSpace(space),
+ };
+ /* MG2: ส่งตำแหน่ง spawn ของบอทมาด้วยตอน pregame → client วางบอทที่จุดเกิด (เดิมไม่ส่ง = บอทค้าง (1,1) มุมบน) */
+ const gr = space && space.gauntletRun;
+ if (gr && gr.bots && gr.bots.size) {
+ const bots = [];
+ gr.bots.forEach((b, id) => {
+ if (!b) return;
+ bots.push({ id, x: b.x, y: b.y, direction: b.dir || 'right', gauntletScore: b.gauntletScore || 0, gauntletEliminated: !!b.gauntletEliminated });
+ });
+ if (bots.length) payload.bots = bots;
+ }
+ return payload;
+}
+
+/* เช็คว่าพร้อมเริ่มมินิเกม 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;
+}
+
+/* key ผู้เล่นในคดี — ใช้ playerKey (คงที่ต่อ browser ข้าม reconnect) กันเคส "2 คนตั้งชื่อซ้ำ = ถูกนับเป็นคนเดียว"
+ fallback เป็นชื่อ (n:) ถ้า peer ไม่มี playerKey ที่ valid */
+function detectiveParticipantKey(p) {
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ const n = normalizeLobbyNickname(p.nickname || '');
+ return n ? 'n:' + n : '';
+}
+
+/* นับผู้เล่นในคดี (caseParticipantKeys = playerKey) ที่กลับเข้า LobbyB + โหลดเสร็จ เทียบกับทั้งหมด
+ ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */
+function detectiveLobbyBMissingParticipants(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 };
+ /* present = participantKey ที่ "มี peer" และ "โหลด LobbyB เสร็จจริง" (lobbyb-loaded)
+ — กันเคส peer มีอยู่ (socket re-join ช่วงเปลี่ยนหน้า) แต่ client ยังค้างฉากเกมเก่า = นับครบผิด
+ fallback: ก่อนมีใครส่ง lobbyb-loaded (loadedSet ว่าง) ใช้ peer-count (กัน deadlock ช่วงแรกสุด) */
+ const loadedSet = space.lobbyBLoadedKeys;
+ const useLoaded = !!(loadedSet && loadedSet.size > 0);
+ const present = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (!k || !roster.has(k)) return;
+ if (useLoaded) { if (loadedSet.has(k)) present.add(k); }
+ else present.add(k);
+ });
+ 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,
+ });
+}
+
+/* TC166 safety: กัน lobbyb-loaded gate deadlock — ถ้า 25s แล้วยังไม่ครบ ให้ force นับ present จาก peer ที่อยู่จริงใน LobbyB */
+function armLobbyBLoadedSafety(sid, space) {
+ if (!sid || !space) return;
+ if (space.lobbyBLoadedTimer) { clearTimeout(space.lobbyBLoadedTimer); space.lobbyBLoadedTimer = null; }
+ space.lobbyBLoadedTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (!sp || !serverMapIsPostCaseLobbyB(sp)) return;
+ const roster = sp.caseParticipantKeys;
+ if (!roster || roster.size === 0) return;
+ if (!sp.lobbyBLoadedKeys) sp.lobbyBLoadedKeys = new Set();
+ let added = 0;
+ sp.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k) && !sp.lobbyBLoadedKeys.has(k)) { sp.lobbyBLoadedKeys.add(k); added++; }
+ });
+ if (added) {
+ try { glog(sid, sp, 'lobbyb-gate', '[TC166] safety 25s → force นับ present จาก peer ที่อยู่จริง (+' + added + ')'); } catch (e) { /* ignore */ }
+ try { emitDetectiveLobbyBPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 25000);
+}
+
+/* TC156 story-gate: นับผู้เล่นในคดี "ที่อ่าน story (cutscene) จบแล้ว" เทียบกับคนที่อยู่ในห้องตอนนี้
+ allRead = ทุก roster-nick ที่ยัง present ได้ส่ง story-read-done แล้ว (คนที่ left ไม่นับ — presence gate คุมแยก) */
+function detectiveStoryReadStatus(space) {
+ const roster = space && space.caseParticipantKeys;
+ if (!roster || roster.size === 0) return { present: 0, total: 0, allRead: true };
+ const read = space.storyReadKeys || new Set();
+ const presentRosterKeys = new Set();
+ space.peers.forEach((p) => {
+ const k = detectiveParticipantKey(p);
+ if (k && roster.has(k)) presentRosterKeys.add(k);
+ });
+ let readCount = 0;
+ presentRosterKeys.forEach((k) => { if (read.has(k)) readCount++; });
+ const allRead = !!space.storyReadComplete || (presentRosterKeys.size > 0 && readCount >= presentRosterKeys.size);
+ return { present: readCount, total: presentRosterKeys.size, allRead };
+}
+function emitDetectiveStoryReadPresence(sid, space) {
+ if (!sid || !space) return;
+ if (!serverMapIsPostCaseLobbyB(space)) return;
+ const st = detectiveStoryReadStatus(space);
+ io.to(sid).emit('detective-story-read-presence', { present: st.present, total: st.total, allRead: st.allRead });
+}
+
+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) || '',
+ };
+ });
+ // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ (รวม evidenceByNick fallback — กันหลักฐานคดีเก่า leak ข้ามคดี)
+ space.playerEvidence = {};
+ space.evidenceByNick = {};
+ space.caseMinigameSuspects = {}; /* [achv b2] มินิเกมที่รันในคดีนี้ (suspectIndex) — เช็ค "เล่นครบทุกรอบ" */
+ space.caseCardTargetedKeys = {}; /* [achv d2] playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ /* #6: ความคืบหน้าการสืบต่อผู้ต้องสงสัย (shared — เท่ากันทุกคน) = จำนวนมินิเกมที่เล่นให้คนนั้น (0-3)
+ ใช้โชว์ช่องหลักฐานใต้การ์ด suspect แทนหลักฐานส่วนตัว (เดิมแต่ละคนเห็นไม่เท่ากัน) */
+ space.suspectTeamProgress = [0, 0, 0];
+ // สุ่มคนร้ายตัวจริง 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 */
+/* คีย์เก็บหลักฐานต่อผู้เล่น — ใช้ playerKey (unique ต่อ browser) เป็นหลัก กัน "ชื่อซ้ำ"
+ (เช่น 2-3 คนชื่อ MONE) ใช้ evidence ก้อนเดียวกัน = ถูกแจกซ้ำ/เกิน. fallback ชื่อเฉพาะกรณีไม่มี key */
+function peerNickKey(space, playerId) {
+ if (!space || !playerId) return '';
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ if (!p) return '';
+ if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
+ if (!p.nickname) return '';
+ return 'n:' + 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;
+}
+
+/* [achv Phase A 2026-07-05] นับหลักฐานที่ผู้เล่นเก็บได้ → achievements
+ b4_data_miner(รวมทุกใบ ×100) · b1_evidence_collector(common ×20) · a2_sharp_eye(rare/Silver ×10) · b3_hidden_fragment(legendary ครั้งแรก)
+ เฉพาะผู้เล่นจริง (มี playerKey) — ข้ามบอท + เรียกจาก "จุดแจกจริง" 2 ที่เท่านั้น (ไม่รวม debug hotkey) */
+function trackEvidenceCollectAchv(space, playerId, card) {
+ if (!card || !card.rarity) return;
+ const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
+ const pkey = p && typeof p.playerKey === 'string' ? p.playerKey : null;
+ if (!pkey) return;
+ const achv = [{ playerKey: pkey, id: 'b4_data_miner', inc: 1 }];
+ if (card.rarity === 'common') achv.push({ playerKey: pkey, id: 'b1_evidence_collector', inc: 1 });
+ else if (card.rarity === 'rare') achv.push({ playerKey: pkey, id: 'a2_sharp_eye', inc: 1 });
+ else if (card.rarity === 'legendary') achv.push({ playerKey: pkey, id: 'b3_hidden_fragment', inc: 1 });
+ submitAchievementProgress(achv);
+}
+
+function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
+ const out = {};
+ /* [achv b2] บันทึกว่า suspect นี้มีมินิเกมรันในคดีนี้ (ใช้เช็ค "เล่นมินิเกมครบทุกรอบ") */
+ if (suspectIndex >= 0 && suspectIndex <= 2) {
+ space.caseMinigameSuspects = space.caseMinigameSuspects || {};
+ space.caseMinigameSuspects[suspectIndex] = true;
+ }
+ const grantedKeys = new Set(); /* แจก 1 การ์ด/playerKey ต่อเกม — กัน "หลายแท็บ/ชื่อซ้ำ" key เดียวได้เกิน */
+ (playerIds || []).forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && grantedKeys.has(k)) return; /* key นี้ได้การ์ดรอบนี้แล้ว (evidence เก็บก้อนเดียวกันตาม key) */
+ const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
+ if (card != null) { out[pid] = card; if (k) grantedKeys.add(k); trackEvidenceCollectAchv(space, 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++) {
+ const freeKeys = new Set(); /* 1 ใบ/playerKey ต่อรอบ (กันชื่อซ้ำ/หลายแท็บได้เกิน) */
+ ids.forEach((pid) => {
+ if (!space.peers.has(pid)) return;
+ const k = peerNickKey(space, pid);
+ if (k && freeKeys.has(k)) return;
+ const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
+ if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; if (k) freeKeys.add(k); trackEvidenceCollectAchv(space, pid, extra); }
+ });
+ }
+ pend.consumed = true;
+ }
+ space.detectiveAwardDoneForRun = true;
+ space.lastDetectiveInvestigatedSuspect = suspectIdx;
+ space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
+ /* [TC182] verify: จำนวนใบต่อ playerKey ที่แจกรอบนี้ — ควรได้ 1 ใบ/key/เกม (กันชื่อซ้ำแจกเกิน) */
+ try {
+ const keyCounts = {};
+ Object.keys(awardedList).forEach((pid) => {
+ const k = peerNickKey(space, pid) || String(pid);
+ keyCounts[k] = (keyCounts[k] || 0) + (awardedList[pid] ? awardedList[pid].length : 0);
+ });
+ const vals = Object.values(keyCounts);
+ const over = vals.some((n) => n > 1);
+ glog(sid, space, 'evidence-grant', '[TC182] แจกหลักฐาน suspect#' + (suspectIdx + 1) + ' → ' + vals.length + ' key · ใบ/key=' + JSON.stringify(vals) + (over ? ' ⚠️เกิน' : ' (1/key · pass)'));
+ } catch (e) { /* ignore */ }
+ 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.lobbyBLoadedKeys = new Set(); /* ออกไปเล่นมินิเกม → ล้าง loaded; ตอนกลับ LobbyB ทุกคนต้องส่ง lobbyb-loaded ใหม่ */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock ถ้า signal ไม่ครบ */
+ 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;
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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;
+ /* #6: นับความคืบหน้าการสืบ (shared) +1 ต่อการเล่นมินิเกม 1 รอบของผู้ต้องสงสัยนั้น (สูงสุด 3) */
+ if (!Array.isArray(space.suspectTeamProgress)) space.suspectTeamProgress = [0, 0, 0];
+ if (suspectIdx >= 0 && suspectIdx <= 2) {
+ space.suspectTeamProgress[suspectIdx] = Math.min(3, (space.suspectTeamProgress[suspectIdx] || 0) + 1);
+ try { glog(sid, space, 'suspect-progress', '[TC179] สืบผู้ต้องสงสัย #' + (suspectIdx + 1) + ' → team progress ' + space.suspectTeamProgress.join('/') + ' (shared เท่ากันทุกจอ · pass)'); } catch (e) { /* ignore */ }
+ }
+ 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 || [],
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ 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 = [];
+ /* Phase B: majority vote (เช็ค lone-wolf) + มีคนโหวตไหม */
+ let mvAchv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mvAchv]) mvAchv = k; }
+ const anyVotesAchv = (counts[0] + counts[1] + counts[2]) > 0;
+ /* 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 });
+ /* ===== Phase B (2026-07-05) — เงื่อนไขต่อผู้โหวตถูก ===== */
+ /* c5 Lone Wolf: โหวตถูกแต่เสียงส่วนใหญ่ผิด (สวนทางทีม) */
+ if (anyVotesAchv && mvAchv !== culprit) achv.push({ playerKey: p.playerKey, id: 'c5_lone_wolf', inc: 1 });
+ /* หลักฐานในมือคดีนี้ (ต่อผู้ต้องสงสัย 3 กอง) */
+ const evRows = clonePlayerEvidence(space, id);
+ const evTotal = evRows.reduce((s, row) => s + (Array.isArray(row) ? row.length : 0), 0);
+ /* c2 High Stakes: โหวตถูกโดยมีหลักฐาน ≤3 ใบ */
+ if (evTotal <= 3) achv.push({ playerKey: p.playerKey, id: 'c2_high_stakes', inc: 1 });
+ /* c1 Early Accusation: โหวตถูกโดยมีหลักฐาน ≤1 ใบ (ชี้ตัวก่อนเปิดหลักฐานครบ) */
+ if (evTotal <= 1) achv.push({ playerKey: p.playerKey, id: 'c1_early_accusation', inc: 1 });
+ /* a4 Logic Over Luck: โหวตถูกโดยไม่มีหลักฐาน legendary (ชี้ชัด) เลย */
+ const hasLegendary = evRows.some((row) => Array.isArray(row) && row.some((c) => c && c.rarity === 'legendary'));
+ if (!hasLegendary) achv.push({ playerKey: p.playerKey, id: 'a4_logic_over_luck', inc: 1 });
+ /* c4 Clutch Mind: โหวตถูกใน 10 วิสุดท้ายก่อนหมดเวลา */
+ const voteAt = space.trialVoteAt && space.trialVoteAt[id];
+ if (voteAt && space.trialVoteEndsAt) {
+ const remain = space.trialVoteEndsAt - voteAt;
+ if (remain >= 0 && remain <= 10000) achv.push({ playerKey: p.playerKey, id: 'c4_clutch_mind', 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 */
+ }
+ }
+ /* ===== Phase C/D (2026-07-06) — ต่อผู้เล่นที่จบคดี (ไม่ใช่แค่ผู้โหวตถูก) ===== */
+ const streakItems = [];
+ /* c3 Quick Draw: ผู้โหวตถูกที่ "โหวตเร็วที่สุด" ในทีม (ใช้ trialVoteAt) */
+ {
+ let fastId = null, fastAt = Infinity;
+ winners.forEach((id) => {
+ const at = space.trialVoteAt && space.trialVoteAt[id];
+ const wp = space.peers.get(id);
+ if (at && wp && wp.playerKey && at < fastAt) { fastAt = at; fastId = id; }
+ });
+ if (fastId) { const fp = space.peers.get(fastId); if (fp && fp.playerKey) achv.push({ playerKey: fp.playerKey, id: 'c3_quick_draw', inc: 1 }); }
+ }
+ /* วนผู้เล่นจริงทุกคนที่จบคดี (ข้ามบอท/ผู้ถูกแบน spectator) */
+ const caseMgSuspects = Object.keys(space.caseMinigameSuspects || {}).map((n) => parseInt(n, 10));
+ const targetedKeys = space.caseCardTargetedKeys || {};
+ space.peers.forEach((p, pid) => {
+ if (!p || !p.playerKey || p.bannedSpectator) return;
+ const evRows = clonePlayerEvidence(space, pid);
+ const rarSet = {};
+ evRows.forEach((row) => { if (Array.isArray(row)) row.forEach((c) => { if (c && c.rarity) rarSet[c.rarity] = true; }); });
+ /* a3 Mind Architect: มีหลักฐานครบ 3 ระดับในคดีนี้ (common+rare+legendary) */
+ if (rarSet.common && rarSet.rare && rarSet.legendary) achv.push({ playerKey: p.playerKey, id: 'a3_mind_architect', inc: 1 });
+ /* b2 Relentless Investigator: เก็บหลักฐานครบทุก suspect ที่มีมินิเกมรันในคดีนี้ (= เล่นครบทุกรอบ) */
+ if (caseMgSuspects.length > 0 && caseMgSuspects.every((si) => Array.isArray(evRows[si]) && evRows[si].length > 0)) {
+ achv.push({ playerKey: p.playerKey, id: 'b2_relentless_investigator', inc: 1 });
+ }
+ /* d2 Silent Guardian: ไม่โดนโหวตการ์ด Silence/Ban ในคดีนี้ */
+ if (!targetedKeys[p.playerKey]) achv.push({ playerKey: p.playerKey, id: 'd2_silent_guardian', inc: 1 });
+ /* e1 The Observer (streak): จบคดีนี้โดยไม่แตะ legendary = hit, แตะ = รีเซ็ต */
+ streakItems.push({ playerKey: p.playerKey, id: 'e1_the_observer', event: rarSet.legendary ? 'miss' : 'hit' });
+ });
+ /* a6 Unbreakable Logic (โหวตถูกติดกัน) + d4 Flawless Diver (ไม่โหวตผิดติดกัน) — เฉพาะคนที่โหวตจริง */
+ Object.keys(votes).forEach((id) => {
+ const p = space.peers.get(id);
+ if (!p || !p.playerKey) return;
+ const ok = votes[id] === culprit;
+ streakItems.push({ playerKey: p.playerKey, id: 'a6_unbreakable_logic', event: ok ? 'hit' : 'miss' });
+ streakItems.push({ playerKey: p.playerKey, id: 'd4_flawless_diver', event: ok ? 'hit' : 'miss' });
+ });
+ submitAchievementProgress(achv);
+ submitAchievementStreak(streakItems);
+ } 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.trialVoteAt = {}; /* [achv c4] รีเซ็ตเวลาโหวตทุกครั้งที่เปิดโหวต (รวม revote) */
+ 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 });
+ /* [achv d2] บันทึก playerKey ที่โดนโหวตการ์ด Silence/Ban ในคดีนี้ (→ ไม่ได้ d2_silent_guardian) */
+ if (target && (pv.purpose === 'silence' || pv.purpose === 'ban')) {
+ const tp = space.peers.get(target);
+ if (tp && tp.playerKey) {
+ space.caseCardTargetedKeys = space.caseCardTargetedKeys || {};
+ space.caseCardTargetedKeys[tp.playerKey] = true;
+ }
+ }
+ 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 = [];
+ const tc191Rows = []; /* [TC191] verify: เทียบผลตัดสิน "แถวเท้า" vs "หัว(เดิม)" ต่อคน */
+
+ space.peers.forEach((p, peerId) => {
+ const st = sess.players[peerId];
+ if (!st) return;
+ eligibleCount++;
+ /* จับที่ "แถวเท้า" (bottom row ของ footprint) เท่านั้น — หัว/ตัวโผล่เข้าโซนแต่เท้ายังไม่ถึง = ไม่นับ
+ (ต้องตรงกับ client quizResolveChoiceFromStanding) */
+ const { cw: qCw, ch: qCh } = getCharacterFootprintWHForMove(mdLive);
+ const qBaseTx = Math.floor(Number(p.x));
+ const qFeetTy = Math.floor(Number(p.y)) + qCh - 1;
+ let inT = false, inF = false;
+ for (let qdx = 0; qdx < qCw; qdx++) {
+ const qftx = qBaseTx + qdx;
+ if (!inT && quizCellOn(tGrid, qftx, qFeetTy)) inT = true;
+ if (!inF && quizCellOn(fGrid, qftx, qFeetTy)) inF = true;
+ }
+ 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,
+ });
+ }
+
+ /* [TC191] เก็บบรรทัดเทียบ: เท้า(ที่ใช้จริง) vs หัว(สูตรเดิม single-tile) → พิสูจน์ว่าตัดสินที่แถวเท้า */
+ {
+ const headY = Math.floor(Number(p.y));
+ tc191Rows.push((p.nickname || '?').slice(0, 8) + ' fY=' + qFeetTy + ' hY=' + headY
+ + ' เท้า[T' + (inT ? 1 : 0) + 'F' + (inF ? 1 : 0) + ']'
+ + ' หัวเดิม[T' + (quizCellOn(tGrid, qBaseTx, headY) ? 1 : 0) + 'F' + (quizCellOn(fGrid, qBaseTx, headY) ? 1 : 0) + ']'
+ + ' → ' + (choice === true ? 'จริง' : choice === false ? 'เท็จ' : '-') + (right ? '✓' : '✗'));
+ }
+ results.push({
+ id: peerId,
+ nickname: p.nickname || '',
+ right,
+ choice,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : 0,
+ });
+ });
+
+ if (tc191Rows.length) {
+ try { glog(sid, space, 'mg1-feet', '[TC191] Q' + (idx + 1) + ' ตัดสินแถวเท้า: ' + tc191Rows.join(' | ')); } catch (e) { /* ignore */ }
+ }
+
+ 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_` 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;
+ /* โต๊ะ submit กลาง (interactive/เขียว) = สิ่งกีดขวาง ห้ามยืนทับ/เดินทะลุ (ส่งคำตอบจากช่องข้างๆ ได้ — quizCarryCanSubmitAtServer รับ adjacent) */
+ if (quizCellOn(md.interactive || [], 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;
+}
+
+/* ช่องที่บอท "เดินถึงได้จริง" (footprint-aware BFS) จากโซน option/spawn — กันบอทสุ่มเกิด/ติดใน
+ "คอนโซลกลาง" ที่ยืนได้แต่ออกไม่ได้ (footprint 3×4 ลอดช่องแคบ/ทางที่ติดกำแพง+hub+interactive ไม่ได้
+ = อาการ "บอทยืนบนโต๊ะ"). cache ต่อ md (โครงสร้างแมปคงที่ต่อรอบเกม) */
+function quizCarryReachableTilesServer(md) {
+ if (md.__qcReach) return md.__qcReach;
+ const w = md.width || 20, h = md.height || 15;
+ const oa = md.quizCarryOptionArea, sa = md.spawnArea;
+ const seeds = [];
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const isSeed = (Array.isArray(oa) && oa[y] && Number(oa[y][x]) >= 1) || (Array.isArray(sa) && quizCellOn(sa, x, y));
+ if (isSeed && quizCarryEntityCanStandServer(md, x, y)) seeds.push([x, y]);
+ }
+ }
+ const reach = new Set();
+ if (!seeds.length) {
+ /* ไม่มี seed (แมปไม่มี option/spawn) → ไม่กรอง: ทุก standable นับว่าถึงได้ */
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (quizCarryEntityCanStandServer(md, x, y)) reach.add(x + ',' + y);
+ md.__qcReach = reach;
+ return reach;
+ }
+ const q = [];
+ for (const s of seeds) { const k = s[0] + ',' + s[1]; if (!reach.has(k)) { reach.add(k); q.push(s); } }
+ while (q.length) {
+ const cur = q.shift();
+ const nb = [[cur[0] + 1, cur[1]], [cur[0] - 1, cur[1]], [cur[0], cur[1] + 1], [cur[0], cur[1] - 1]];
+ for (const n of nb) {
+ const k = n[0] + ',' + n[1];
+ if (!reach.has(k) && quizCarryEntityCanStandServer(md, n[0], n[1])) { reach.add(k); q.push(n); }
+ }
+ }
+ md.__qcReach = reach;
+ return reach;
+}
+function quizCarryTileReachableServer(md, x, y) {
+ return quizCarryReachableTilesServer(md).has(Math.floor(x) + ',' + Math.floor(y));
+}
+
+/** รวมรายชื่อช่องว่าง (เดินยืนได้ + ถึงได้จริง + ไม่ใช่ 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;
+ if (!quizCarryTileReachableServer(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 ถ้ามีการเปลี่ยนสถานะ · clientPos = ตำแหน่งที่ client รายงานตอนกด grab (ใช้ถ้าใกล้ server ≤1.25 tile) */
+function quizCarryServerTryInteract(sid, space, actorId, isBot, clientPos) {
+ 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);
+ /* client รายงานตำแหน่งตอนกด F — ถ้าใกล้ server พอ ใช้ client เพื่อลด desync หยิบไม่ติด/เด้ง */
+ if (clientPos && typeof clientPos === 'object') {
+ const cx = Number(clientPos.x), cy = Number(clientPos.y);
+ if (Number.isFinite(cx) && Number.isFinite(cy) && Number.isFinite(px) && Number.isFinite(py)) {
+ if (Math.hypot(cx - px, cy - py) <= 1.25) { px = cx; py = cy; }
+ }
+ }
+ }
+ 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 });
+ /* แจ้งผลถูก/ผิด "บรอดแคสต์ให้ทุกคน" → โชว์ไอคอน ✓/✗ เหนือหัวทั้งคนจริงและบอท (ทุกจอเห็นพร้อมกัน) */
+ io.to(sid).emit('quiz-carry-answer-feedback', { id: actorId, correct: ok });
+ 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;
+ /* resolve ชื่อผู้ชนะ (ให้ client โชว์ชื่อจริง ไม่ใช่ 'ผู้เล่น' ตอน churn) + diag: ใครตอบถูก (คน/บอท) */
+ let winnerNick = '', winnerIsBot = false;
+ if (winnerId) {
+ if (s.bots && s.bots.has(winnerId)) { winnerIsBot = true; }
+ else { const wp = space.peers.get(winnerId); if (wp && wp.nickname) winnerNick = wp.nickname; }
+ }
+ try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId ? ('[TC169] ' + (winnerNick || (winnerIsBot ? 'BOT ' + winnerId : winnerId))) : '-(หมดเวลา ไม่มีคนถูก)') + ' correctIdx=' + (s.current ? s.current.correctIndex : '?') + ' willEnd=' + willEnd); } catch (e) { /* ignore */ }
+ io.to(sid).emit('quiz-carry-result', {
+ questionIndex: s.qIndex + 1,
+ correctIndex: s.current ? s.current.correctIndex : null,
+ winnerId: winnerId || null,
+ winnerNick,
+ winnerIsBot,
+ 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.lastAdvanceAt = Date.now();
+ try { glog(sid, space, 'mg4-read', 'q=' + s.qIndex + ' deck=' + (s.deck ? s.deck.length : 0) + ' rounds=' + s.roundsCompleted + '/' + s.sessionLength); } catch (e) { /* ignore */ }
+ 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';
+ s.lastAdvanceAt = Date.now();
+ 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);
+ /* 0 = ไม่ได้ตั้งค่า → จบเมื่อ "ถามครบทุกข้อในชุด" รอบเดียว (ไม่วนซ้ำ) ตรงกับที่ผู้เล่นคาดหวังว่า
+ "พอจบทุกคำถาม เกมต้องจบ"; ถ้า pool ว่างค่อย fallback 10 (กันหารด้วยศูนย์/ค้าง) */
+ if (!(sessionLength > 0)) sessionLength = Math.max(1, pool.length || 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover (BFS เล่นต่อ) — แทนคนที่ออก ใช้ socket.id เดิม ไม่ชน id บอท AI */
+ {
+ const sC = space.quizCarrySession;
+ drainPendingMissionTakeovers(space, 'quiz_carry', (rec) => {
+ sC.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ dir: rec.direction || 'down', isWalking: false,
+ held: null, score: 0,
+ botPath: [], pathfindAfter: 0, wantIdx: null,
+ wanderTX: null, wanderTY: null, retargetAt: 0,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+ }
+ if (!pool.length) { endQuizCarryGame(sid, space); return; }
+ /* watchdog กันเกมค้างไม่จบ: เก็บนอก space.quizTimers (เพราะ resolveQuizCarryRound เคลียร์ array นั้นทุกข้อ)
+ ถ้าไม่มีความคืบหน้า (lastAdvanceAt) นานเกิน → force resolve/จบ — ป้องกัน "เล่นจนครบแต่เกมไม่ยอมจบ" */
+ {
+ const sC = space.quizCarrySession;
+ sC.lastAdvanceAt = Date.now();
+ const perRound = Math.max(0, sC.carryReadMs) + Math.max(1000, sC.carryAnswerMs) + QUIZ_CARRY_BETWEEN_MS + 1500;
+ const stallMs = perRound + 20000; /* ช่วงเวลาที่ "ยอมรับได้" ต่อข้อ ก่อนถือว่าค้าง */
+ if (space.quizCarryWatchdog) clearInterval(space.quizCarryWatchdog);
+ space.quizCarryWatchdog = setInterval(() => {
+ const sp = spaces.get(sid);
+ const ss = sp && sp.quizCarrySession;
+ if (!sp || !ss || !ss.active || ss.ended) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; return; }
+ const idle = Date.now() - (ss.lastAdvanceAt || 0);
+ if (idle > stallMs) {
+ try { glog(sid, sp, 'mg4-watchdog', 'STALL idle=' + idle + 'ms phase=' + ss.phase + ' round=' + ss.roundsCompleted + '/' + ss.sessionLength); } catch (e) { /* ignore */ }
+ ss.lastAdvanceAt = Date.now();
+ /* ดันให้ไปต่อ: ถ้ายังตอบ/อ่านค้าง → จบรอบนี้ (ไม่ให้คะแนน) แล้วระบบจะเลื่อนข้อหรือจบเองตาม willEnd */
+ if (!ss.roundResolved) { try { resolveQuizCarryRound(sid, sp, null); } catch (e) { /* ignore */ } }
+ else { try { scheduleQuizCarryReadPhase(sid, sp); } catch (e) { /* ignore */ } }
+ }
+ }, 5000);
+ }
+ scheduleQuizCarryReadPhase(sid, space);
+}
+
+function endQuizCarryGame(sid, space) {
+ const s = space.quizCarrySession;
+ if (s) { s.active = false; s.ended = true; }
+ if (space.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = null; }
+ try { glog(sid, space, 'mg4-end', 'END rounds=' + (s ? s.roundsCompleted : '?') + '/' + (s ? s.sessionLength : '?')); } catch (e) { /* ignore */ }
+ 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;
+
+ /* ก้าวไป (x,y) ได้ไหม: ต้อง "ยืนได้ทั้ง footprint" (canStand = ทุกช่องของตัวละครไม่ใช่กำแพง/ไม่ทับ hub/ไม่ทับโต๊ะ interactive)
+ — เดิมเช็คแค่ quizTileWalkableServer ที่ "ช่อง anchor จุดเดียว" ทำให้ footprint ขอบตัวเลื่อนคร่อมกำแพงได้ →
+ ตกไปช่องที่ reachability-BFS (footprint-aware) ถือว่า "ออกไม่ได้" → โดน push-out teleport ทุก tick = อาการ "บอทว๊าป".
+ ใช้ canStand ให้ตรงกับ pathfind (ที่วางเส้นบน canStand อยู่แล้ว) → บอทไม่เดินเข้าช่องที่จะถูกดันออก */
+ const canStepTo = (x, y) => quizCarryEntityCanStandServer(md, Math.floor(x), Math.floor(y));
+
+ 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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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 (canStepTo(nx, ny)) { b.x = nx; b.y = ny; }
+ else if (canStepTo(nx, b.y)) { b.x = nx; }
+ else if (canStepTo(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) => {
+ /* ช่วงอ่านคำถาม (read) → บอท freeze อยู่ตรงไหนตรงนั้น (ตรงกับผู้เล่นที่ freeze ฝั่ง client) */
+ if (s.phase === 'read') { b.isWalking = false; return; }
+ /* ทับ hub / โต๊ะ submit กลาง (interactive) / หรือ "ติดในคอนโซลกลาง" (ยืนได้แต่เดินออกไม่ได้) → ดันออกไปช่องว่างที่ถึงได้
+ หมายเหตุ: หลังแก้ canStepTo → canStand แล้ว บอทไม่ควรเดินเข้าช่องแบบนี้เอง teleport นี้จึงเป็น safety-net (ควรยิงน้อยมาก)
+ — diag: glog ทุกครั้งที่ teleport จริง เพื่อยืนยันว่า "ว๊าป" ลดลง (ถอด log ออกเมื่อยืนยันแล้ว) */
+ if (quizCarryFootprintOverlapsHubServer(md, b.x, b.y) || quizCarryFootprintOverlapsInteractiveServer(md, b.x, b.y) || !quizCarryTileReachableServer(md, b.x, b.y)) {
+ const tiles = quizCarrySpawnTilesServer(md);
+ if (tiles.length) {
+ const fromX = b.x, fromY = b.y;
+ const t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = [];
+ try { glog(sid, space, 'mg4-warp', '[TC158] push-out teleport ' + bid + ' (' + fromX.toFixed(2) + ',' + fromY.toFixed(2) + ')->(' + b.x.toFixed(2) + ',' + b.y.toFixed(2) + ') — event นี้ = ยังว๊าป (fail); ไม่มี event ตลอดเกม = pass'); } catch (e) { /* ignore */ }
+ }
+ }
+ /* ยังไม่มีเส้นทาง → ลองหยิบ/ส่งที่จุดยืนก่อน */
+ 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)) {
+ /* ช่องที่ "ยืนได้จริง + ชิดโต๊ะ interactive" (ไม่ใช่ยืนทับโต๊ะ — โต๊ะเป็นสิ่งกีดขวางแล้ว) → ส่งจากข้างๆ */
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!quizCarryEntityCanStandServer(md, x, y)) continue;
+ if (quizCarryFootprintAdjacentInteractiveServer(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 — เลข 2–16หาย) */
+function normalizeQuizCarryLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_carry') return;
+ const w = m.width || 20, h = m.height || 15;
+ const normHub = () => {
+ const src = m.quizCarryHubArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizCarryHubArea = rows;
+ };
+ const normOptions = () => {
+ const src = m.quizCarryOptionArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ m.quizCarryOptionArea = rows;
+ };
+ const normCountdown = () => {
+ const src = m.carryEmbedCountdownArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ rows.push(row);
+ }
+ m.carryEmbedCountdownArea = rows;
+ };
+ normHub();
+ normOptions();
+ normCountdown();
+}
+
+/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
+function normalizeQuizBattleDomeAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattleDomeArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattleDomeArea = rows;
+}
+
+/** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */
+function normalizeQuizBattleDomeCompOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const grid = m.quizBattleDomeArea;
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ let nextId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue;
+ nextId++;
+ const stack = [[x, y]];
+ comp[y][x] = nextId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue;
+ comp[ny][nx] = nextId;
+ stack.push([nx, ny]);
+ }
+ }
+ }
+ }
+ m.quizBattleDomeComp = comp;
+}
+
+/**
+ * ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน
+ * ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน
+ */
+function hasQuizBattleManualPathGates(m) {
+ const mg = m && m.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+}
+
+function buildQuizBattlePathGateFromManualOnMap(m) {
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const manual = m.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ /* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function buildQuizBattlePathGateRequiredOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) {
+ if (m) m.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGates(m)) {
+ buildQuizBattlePathGateFromManualOnMap(m);
+ return;
+ }
+ normalizeQuizBattleDomeCompOnMap(m);
+ const w = m.width || 20, h = m.height || 15;
+ const path = m.quizBattlePathArea;
+ const dome = m.quizBattleDomeArea;
+ const domeComp = m.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = m.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = m.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ m.quizBattlePathGateRequired = gate;
+}
+
+function finalizeQuizBattleMapLayersOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ normalizeQuizBattleDomeAreaOnMap(m);
+ normalizeQuizBattlePathAreaOnMap(m);
+ normalizeQuizBattleDomeCompOnMap(m);
+ buildQuizBattlePathGateRequiredOnMap(m);
+}
+
+function normalizeQuizBattlePathAreaOnMap(m) {
+ if (!m || m.gameType !== 'quiz_battle') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.quizBattlePathArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.quizBattlePathArea = rows;
+}
+
+function quizBattlePathModeActiveServer(m) {
+ if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
+ const g = m.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattleFootprintFullyOnPathServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ const g = m.quizBattlePathArea;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+/** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */
+function quizBattlePositionAllowedServer(m, px, py) {
+ if (!quizBattlePathModeActiveServer(m)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true;
+ const sa = m.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+}
+
+function quizBattlePlayerSolvedCompsServer(space, socketId) {
+ if (!space || !space.qbwSolved) return new Set();
+ const set = space.qbwSolved.get(socketId);
+ return set || new Set();
+}
+
+function quizBattlePathGateRequiredAtServer(m, px, py) {
+ const gate = m && m.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActiveServer(m)) return 0;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ const path = m.quizBattlePathArea;
+ let maxReq = 0;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+}
+
+function quizBattleFootprintOnDomeServer(m, px, py) {
+ const dome = m && m.quizBattleDomeArea;
+ if (!dome) return false;
+ const keys = quizBattleSpriteBoundsTileKeys(m, px, py);
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+}
+
+function quizBattlePathGateAllowsServer(m, space, socketId, px, py) {
+ if (!m || !quizBattlePathModeActiveServer(m)) return true;
+ if (quizBattleFootprintOnDomeServer(m, px, py)) return true;
+ const maxReq = quizBattlePathGateRequiredAtServer(m, px, py);
+ if (maxReq <= 0) return true;
+ const solved = quizBattlePlayerSolvedCompsServer(space, socketId);
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solved.has(c)) return false;
+ }
+ return true;
+}
+
+function serverFootprintClearOfWalls(md, px, py) {
+ const w = md.width || 20, h = md.height || 15;
+ const keys = serverWallCollisionTileKeys(md, px, py);
+ if (!keys.length) return false;
+ for (const k of keys) {
+ const [tx, ty] = k.split(',').map(Number);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects && md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+}
+
+/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
+function snapPositionOntoQuizBattlePathServer(md, px, py) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidServer(md, px, py)) return { x: px, y: py };
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidServer(md, nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+}
+
+function collectQuizBattleValidSpawnCentersServer(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+}
+
+function quizBattleSnapTargetValidServer(md, x, y) {
+ if (!md || !md.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ if (!serverFootprintClearOfWalls(md, x, y)) return false;
+ if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false;
+ return true;
+}
+
+function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersServer(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+}
+
+function collectQuizBattleSpawnPoolServer(md) {
+ const out = [];
+ if (!md) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActiveServer(md)) {
+ if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
+ } else if (!isMapTileWalkableForSpawn(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActiveServer(md)) {
+ return collectQuizBattleValidSpawnCentersServer(md);
+ }
+ return out;
+}
+
+function pickQuizBattleRandomSpawnWorldServer(md) {
+ const pool = collectQuizBattleSpawnPoolServer(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y);
+ }
+ const idx = typeof crypto.randomInt === 'function'
+ ? crypto.randomInt(0, pool.length)
+ : Math.floor(Math.random() * pool.length);
+ const pick = pool[idx];
+ return { x: pick.x, y: pick.y };
+}
+
+function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMap(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldServer(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldServer(md, sx, sy);
+ }
+ return pickQuizBattleRandomSpawnWorldServer(md);
+}
+
+function pickQuizBattleSpawnFromMap(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+}
+
+function normalizeJumpSurvivePlatformAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformArea = rows;
+}
+
+function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const pa = m.jumpSurvivePlatformArea || [];
+ const src = m.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ m.jumpSurvivePlatformVariantArea = rows;
+}
+
+function normalizeJumpSurviveHazardAreaOnMap(m) {
+ if (!m || m.gameType !== 'jump_survive') return;
+ const w = m.width || 20, h = m.height || 15;
+ const src = m.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ m.jumpSurviveHazardArea = rows;
+}
+
+/**
+ * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
+ */
+function applyStackTowerMissionMapPlayPolicy(safeId, m) {
+ if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
+ const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
+ const spNum = Number(raw.speedPxPerSec);
+ const speedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ const sel = Math.floor(Number(raw.stepEveryLayers));
+ const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
+ const ssp = Math.floor(Number(raw.stepScrollPx));
+ const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
+ const sam = Math.floor(Number(raw.stepAnimMs));
+ const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
+ const st = {
+ enabled: raw.enabled === true,
+ speedPxPerSec,
+ scrollDirection,
+ stepEveryLayers,
+ stepScrollPx,
+ stepAnimMs,
+ };
+ if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
+ if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
+ const rg = Number(raw.releaseGapWorldPx);
+ if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
+ m.stackTowerBgScroll = st;
+ const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
+ const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
+ const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
+ m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
+}
+
+function loadMaps() {
+ try {
+ if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(MAPS_DIR);
+ for (const f of files) {
+ if (!f.endsWith('.json')) continue;
+ const id = f.slice(0, -5);
+ if (!safeMapId(id)) continue;
+ try {
+ const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(id, data);
+ maps.set(id, data);
+ } catch (e) { console.error('load map', f, e.message); }
+ }
+ } catch (e) { console.error('loadMaps', e.message); }
+}
+
+/**
+ * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
+ * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
+ */
+function rehydrateSingleMapFromDisk(safeId) {
+ if (!safeId) return false;
+ const fp = path.join(MAPS_DIR, safeId + '.json');
+ if (!fs.existsSync(fp)) return false;
+ try {
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ normalizeJumpSurvivePlatformAreaOnMap(data);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(data);
+ normalizeJumpSurviveHazardAreaOnMap(data);
+ finalizeQuizBattleMapLayersOnMap(data);
+ applyStackTowerMissionMapPlayPolicy(safeId, data);
+ maps.set(safeId, data);
+ return true;
+ } catch (e) {
+ console.error('rehydrate map', safeId, e.message);
+ return false;
+ }
+}
+
+/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
+function ensurePostCaseLobbyMapLoaded() {
+ if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
+ try {
+ const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
+ if (!fs.existsSync(fp)) return false;
+ const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
+ normalizeQuizCarryLayersOnMap(data);
+ maps.set(POST_CASE_LOBBY_SPACE_ID, data);
+ return true;
+ } catch (e) {
+ console.error('ensurePostCaseLobbyMapLoaded', e.message);
+ return false;
+ }
+}
+
+function saveMap(id, m) {
+ const safe = safeMapId(id);
+ if (!safe) return;
+ try {
+ if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
+ fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
+ } catch (e) { console.error('saveMap', id, e.message); }
+}
+
+function isMapNameTaken(name, excludeId) {
+ const n = (name || '').trim().toLowerCase();
+ for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
+ return false;
+}
+
+function applyDevCors(req, res) {
+ const origin = req.headers.origin;
+ if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.setHeader('Vary', 'Origin');
+ }
+}
+
+const server = http.createServer((req, res) => {
+ applyDevCors(req, res);
+ if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ let url = req.url;
+ if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
+ if (url.startsWith(BASE_PATH + '/api/maps')) {
+ let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
+ if (id.includes('?')) id = id.split('?')[0];
+ if (req.method === 'GET' && id) {
+ const sid = safeMapId(decodeURIComponent(id));
+ if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ rehydrateSingleMapFromDisk(sid);
+ const m = maps.get(sid);
+ if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
+ }
+ if (req.method === 'GET' && !id) {
+ const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
+ return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
+ }
+ if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || 'ฉากใหม่';
+ if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const id = Date.now().toString(36);
+ const m = { ...defaultMap(), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ if (req.method === 'PUT' && id && maps.has(id)) {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body);
+ const name = (d.name || '').trim() || maps.get(id).name;
+ if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
+ const m = { ...maps.get(id), ...d, name };
+ if (!m.blockPlayer) m.blockPlayer = [];
+ if (!m.interactive) m.interactive = [];
+ if (!m.startGameArea) m.startGameArea = [];
+ if (!m.spawnArea) m.spawnArea = [];
+ if (!m.specialQuizSpawnArea) m.specialQuizSpawnArea = [];
+ if (!m.quizTrueArea) m.quizTrueArea = [];
+ if (!m.quizFalseArea) m.quizFalseArea = [];
+ if (!m.quizQuestionArea) m.quizQuestionArea = [];
+ if (!m.quizQuestions) m.quizQuestions = [];
+ if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
+ if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
+ if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
+ if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
+ if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
+ if (!m.stackReleaseArea) m.stackReleaseArea = [];
+ if (!m.stackLandArea) m.stackLandArea = [];
+ if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
+ if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
+ if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
+ if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
+ normalizeQuizCarryLayersOnMap(m);
+ normalizeJumpSurvivePlatformAreaOnMap(m);
+ normalizeJumpSurvivePlatformVariantAreaOnMap(m);
+ normalizeJumpSurviveHazardAreaOnMap(m);
+ finalizeQuizBattleMapLayersOnMap(m);
+ applyStackTowerMissionMapPlayPolicy(id, m);
+ maps.set(id, m);
+ saveMap(id, m);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, mapId: id }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ }
+ const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ const quizCarryDiskPaths = new Set([
+ BASE_PATH + '/api/quiz-carry-from-disk',
+ BASE_PATH + '/api-quiz-carry-from-disk.php',
+ ]);
+ if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
+ if (req.method === 'GET') {
+ const g = loadQuizSettings();
+ const slice = {
+ carryMapPanelTheme: g.carryMapPanelTheme,
+ carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
+ carryReadMs: g.carryReadMs,
+ carryAnswerMs: g.carryAnswerMs,
+ carrySessionLength: g.carrySessionLength,
+ carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
+ carryWalkSpeedMult: g.carryWalkSpeedMult,
+ carryQuestions: g.carryQuestions,
+ };
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(slice));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (quizSettingsUrlPath === BASE_PATH + '/api/system-stats') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(getSystemStats()));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadQuizSettings()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveQuizSettings(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ battleQuizMcqSaved: saved.battleQuizMcqSaved,
+ carryQuestionsSaved: saved.carryQuestionsSaved,
+ specialQuizSets: saved.specialQuizSets,
+ carryMapPanelTheme: saved.carryMapPanelTheme,
+ carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
+ carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
+ carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
+ carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadEvidenceCards()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveEvidenceCards(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify(loadCaseMedia()));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveCaseMedia(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ Pragma: 'no-cache',
+ });
+ return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
+ if (req.method === 'GET') {
+ res.writeHead(200, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ });
+ return res.end(JSON.stringify({ ...runtimeGameTiming }));
+ }
+ if (req.method === 'PUT') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ const d = JSON.parse(body || '{}');
+ const saved = saveGameTimingToFile(d);
+ if (!saved.ok) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
+ }
+ res.writeHead(200);
+ const { ok: _ok, error: _err, ...timingRest } = saved;
+ return res.end(JSON.stringify({ ok: true, ...timingRest }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
+ }
+ });
+ return;
+ }
+ res.writeHead(405);
+ return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
+ }
+ const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureGauntletAssetsDir();
+ const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
+ let fname;
+ if (replaceFilename) {
+ const s = safeGauntletStoredFilename(replaceFilename);
+ if (!s) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
+ }
+ const cur = path.join(GAUNTLET_ASSETS_DIR, s);
+ if (!fs.existsSync(cur)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
+ }
+ const norm = (e) => {
+ let x = String(e || '').toLowerCase();
+ if (x.startsWith('.')) x = x.slice(1);
+ return x === 'jpeg' ? 'jpg' : x;
+ };
+ if (norm(path.extname(s)) !== norm(ext)) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
+ }
+ fname = s;
+ } else {
+ fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ }
+ fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
+ const meta = loadGauntletAssetsMeta();
+ const prev = meta[fname] || {};
+ meta[fname] = {
+ ...prev,
+ label: labelIn || prev.label || fname,
+ updatedAt: Date.now(),
+ };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/gauntlet-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
+ if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const dataUrl = d.imageDataUrl;
+ if (!dataUrl || typeof dataUrl !== 'string') {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
+ }
+ const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
+ if (!m) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
+ }
+ const extRaw = m[1].toLowerCase();
+ const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
+ const b64 = m[2].replace(/\s/g, '');
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
+ }
+ if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
+ }
+ ensureQuizCarryPlaqueAssetsDir();
+ const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
+ fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
+ res.writeHead(200);
+ return res.end(JSON.stringify({
+ ok: true,
+ filename: fname,
+ url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
+ }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const fname = safeGauntletStoredFilename(d.filename);
+ if (!fname) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
+ }
+ const meta = loadGauntletAssetsMeta();
+ const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
+ meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
+ } catch (e) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
+ }
+ });
+ return;
+ }
+ if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
+ try {
+ const q = url.includes('?') ? url.split('?')[1] : '';
+ const sp = new URLSearchParams(q);
+ const rawFn = sp.get('file');
+ const fname = safeGauntletStoredFilename(rawFn);
+ if (!fname) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
+ }
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
+ const meta = loadGauntletAssetsMeta();
+ delete meta[fname];
+ saveGauntletAssetsMeta(meta);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: true }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
+ }
+ }
+ if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const ids = new Set();
+ const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
+ files.forEach(f => {
+ let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
+ if (m) ids.add(m[1]);
+ });
+ const idArr = [...ids];
+ const oldestMsById = {};
+ idArr.forEach((id) => {
+ let oldest = Infinity;
+ const prefix = id + '_';
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ try {
+ const st = fs.statSync(path.join(CHARACTERS_DIR, f));
+ const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
+ if (t < oldest) oldest = t;
+ } catch (e) { /* ignore */ }
+ });
+ oldestMsById[id] = oldest === Infinity ? 0 : oldest;
+ });
+ idArr.sort((a, b) => {
+ const ta = oldestMsById[a] || 0;
+ const tb = oldestMsById[b] || 0;
+ if (ta !== tb) return ta - tb;
+ return String(a).localeCompare(String(b));
+ });
+ const list = idArr.map((id) => {
+ const prefix = id + '_';
+ const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
+ const layerNameSet = new Set();
+ if (hasLayerFiles) {
+ files.forEach((f) => {
+ if (!f.startsWith(prefix) || !ext.test(f)) return;
+ let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) {
+ layerNameSet.add(m[1]);
+ return;
+ }
+ m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
+ if (m) layerNameSet.add(m[1]);
+ });
+ }
+ const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
+ const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
+ return { id, name: id, hasLayerFiles, layers, layerManifest };
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(list));
+ } catch (e) {
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify([]));
+ }
+ }
+ const urlPath = url.split('?')[0];
+ {
+ const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
+ if (lm && req.method === 'GET') {
+ const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const manifest = buildCharacterLayerManifest(id, files);
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify(manifest));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
+ }
+ }
+ }
+ if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
+ if (/\/layer-manifest$/i.test(urlPath)) {
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
+ return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
+ }
+ const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
+ const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
+ if (!id) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
+ }
+ try {
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const files = fs.readdirSync(CHARACTERS_DIR);
+ const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
+ let removed = 0;
+ files.forEach(f => {
+ if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
+ try {
+ fs.unlinkSync(path.join(CHARACTERS_DIR, f));
+ removed++;
+ } catch (e) {
+ // ignore single file errors
+ }
+ }
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, removed }));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ return;
+ }
+ if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ if (!body || body.length < 2) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
+ }
+ const d = JSON.parse(body);
+ const dirs = ['up', 'down', 'left', 'right'];
+ const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
+ if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
+ const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
+ let walkWritten = 0;
+ for (const dir of dirs) {
+ let data = d[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ // เขียนไฟล์เฟรม (สำหรับ animation)
+ const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ walkWritten++;
+ frameIndex++;
+ }
+ }
+ let layerWritten = 0;
+ // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
+ const lf = d.layerFrames;
+ if (lf && typeof lf === 'object') {
+ for (const dir of dirs) {
+ const arr = lf[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const lfIdle = d.layerFramesIdle;
+ if (lfIdle && typeof lfIdle === 'object') {
+ for (const dir of dirs) {
+ const arr = lfIdle[dir];
+ if (!Array.isArray(arr)) continue;
+ const multi = arr.length > 1;
+ arr.forEach((frameObj, frameIndex) => {
+ if (!frameObj || typeof frameObj !== 'object') return;
+ for (const layerName of Object.keys(frameObj)) {
+ if (!ALLOWED_LAYER[layerName]) continue;
+ const dataUrl = frameObj[layerName];
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameLayer = multi
+ ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
+ : (id + '_' + dir + '_idle_layer_' + layerName + '.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
+ layerWritten++;
+ }
+ });
+ }
+ }
+ const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
+ if (idleObj) {
+ for (const dir of dirs) {
+ let data = idleObj[dir];
+ if (!data) continue;
+ const list = Array.isArray(data) ? data : [data];
+ let frameIndex = 0;
+ for (const dataUrl of list) {
+ if (!dataUrl || typeof dataUrl !== 'string') continue;
+ const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
+ if (!m) continue;
+ const b64 = m[1].replace(/\s/g, '');
+ if (!b64.length) continue;
+ let buf;
+ try {
+ buf = Buffer.from(b64, 'base64');
+ } catch (err) {
+ continue;
+ }
+ if (buf.length === 0) continue;
+ const fnameFrame = list.length > 1
+ ? (id + '_' + dir + '_idle_' + frameIndex + '.png')
+ : (id + '_' + dir + '_idle.png');
+ fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
+ if (list.length > 1 && frameIndex === 0) {
+ const baseName = id + '_' + dir + '_idle.png';
+ fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
+ }
+ frameIndex++;
+ }
+ }
+ }
+ if (walkWritten === 0 && layerWritten === 0) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, characterId: id }));
+ } catch (e) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/api/spaces')) {
+ const parts = url.slice((BASE_PATH + '/api/spaces').length).split('?')[0].split('#')[0].split('/').filter(Boolean);
+ const spaceId = parts[0];
+ if (req.method === 'GET' && !spaceId) {
+ const now = Date.now();
+ for (const [id, s] of [...spaces.entries()]) {
+ if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
+ const n = s.peers ? s.peers.size : 0;
+ if (n !== 0) continue;
+ /* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */
+ const since = s.emptySince || s.createdAt;
+ const stale = since == null || now - since > SPACE_EMPTY_TTL_MS;
+ if (stale) spaces.delete(id);
+ }
+ const list = [...spaces]
+ .filter(([, s]) => !s.isPrivate)
+ .filter(([, s]) => (s.peers && s.peers.size > 0))
+ .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;
+ }
+ /* เสียง: volume master/music/sfx — GET เปิดสาธารณะ (sound.js ทุก client อ่าน) · PUT เฉพาะแอดมิน */
+ if (url === BASE_PATH + '/api/sound-settings' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(loadSoundSettings()));
+ return;
+ }
+ if (url === BASE_PATH + '/api/sound-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 = loadSoundSettings();
+ const clamp = (v, cur) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : cur; };
+ if (d.masterVol !== undefined) s.masterVol = clamp(d.masterVol, s.masterVol);
+ if (d.musicVol !== undefined) s.musicVol = clamp(d.musicVol, s.musicVol);
+ if (d.sfxVol !== undefined) s.sfxVol = clamp(d.sfxVol, s.sfxVol);
+ saveSoundSettings(s);
+ res.writeHead(200);
+ res.end(JSON.stringify({ ok: true, settings: s }));
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ try {
+ const d = JSON.parse(body || '{}');
+ const message = (d.message || d.text || '').trim();
+ if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
+ const s = loadAiSettings();
+ if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
+ const model = s.model || 'gpt-4o-mini';
+ const messages = [];
+ const systemParts = [];
+ if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
+ if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
+ systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
+ }
+ if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
+ messages.push({ role: 'user', content: message });
+ const payload = JSON.stringify({
+ model: model,
+ messages: messages,
+ max_tokens: 500,
+ });
+ const urlObj = new URL('https://api.openai.com/v1/chat/completions');
+ const opts = {
+ hostname: urlObj.hostname,
+ path: urlObj.pathname,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + s.openai_api_key,
+ 'Content-Length': Buffer.byteLength(payload, 'utf8'),
+ },
+ };
+ const reqOut = require('https').request(opts, (resOut) => {
+ let data = '';
+ resOut.on('data', chunk => data += chunk);
+ resOut.on('end', () => {
+ try {
+ const parsed = JSON.parse(data);
+ const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
+ if (text != null) {
+ res.writeHead(200);
+ res.end(JSON.stringify({ response: String(text).trim() }));
+ } else {
+ res.writeHead(502);
+ res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
+ }
+ } catch (e) {
+ res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
+ }
+ });
+ });
+ reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
+ reqOut.write(payload);
+ reqOut.end();
+ } catch (e) {
+ res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
+ }
+ });
+ return;
+ }
+ if (url.startsWith(BASE_PATH + '/img/characters/') && (req.method === 'GET' || req.method === 'HEAD')) {
+ const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
+ const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
+ if (fname && fname.length > 0) {
+ const fp = path.join(CHARACTERS_DIR, fname);
+ if (fs.existsSync(fp)) {
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': 'image/png' });
+ if (req.method === 'HEAD') return res.end();
+ res.end(data);
+ });
+ }
+ }
+ }
+ if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
+ const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
+ // production: nginx เสิร์ฟ /Game/img/* เป็น static ทั้งหมด — local node จึงต้องเสิร์ฟไฟล์ที่มีอยู่จริงในโฟลเดอร์ด้วย
+ // (ไม่จำกัดเฉพาะไฟล์อัปโหลด gauntlet-) ไม่งั้น asset ดีไซน์ (popup-Howto, Avartar, btn-ready…) จะ 400 เฉพาะตอนรันผ่าน node ตรง ๆ
+ const fname = safeGauntletStoredFilename(sub) || path.basename(sub); // basename กัน path traversal
+ const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
+ if (!fname || !fs.existsSync(fp)) {
+ res.writeHead(404);
+ return res.end();
+ }
+ const ext = path.extname(fname);
+ return fs.readFile(fp, (err, data) => {
+ if (err) return res.writeHead(500), res.end();
+ res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
+ res.end(data);
+ });
+ }
+ if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
+ // ตัด query string / hash ออกก่อน resolve เป็น path ไฟล์ (เช่น /play.html?space=...&map=... → /play.html)
+ // ไม่ตัด จะหาไฟล์ชื่อ "play.html?space=..." ไม่เจอ → 404 (เข้า node ตรง ๆ พร้อม query)
+ url = url.split('?')[0].split('#')[0] || '/index.html';
+ const filePath = path.join(__dirname, 'public', url);
+ const ext = path.extname(filePath);
+ const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
+ fs.readFile(filePath, (err, data) => {
+ if (err) return res.writeHead(404), res.end('Not Found');
+ res.writeHead(200, {
+ 'Content-Type': types[ext] || 'application/octet-stream',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0'
+ });
+ res.end(data);
+ });
+});
+
+const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
+const spaces = new Map();
+
+const GAUNTLET_MAX_PLAYERS = 6;
+/** เข้าเส้นชัยแล้ว — หยุด 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);
+}
+
+/** มินิเกม "live แล้ว" (พ้น how-to/นับถอยหลัง — session engine เริ่มแล้ว หรือ gauntlet ปล่อยวิ่งแล้ว)?
+ ใช้ตอน rejoin: ถ้า live แล้ว client ต้องข้าม how-to แล้วเข้าเล่นต่อทันที (เดิมโชว์ how-to เสมอ → คน reconnect ค้าง)
+ signal = session.active (สร้างตอน 3-2-1 จบ, ล้างตอนกลับ LobbyB) — เชื่อถือได้กว่า missionLiveBeginsAt (ไม่เคย reset ต่อเกม) */
+function isMinigameLiveForRejoinResume(space) {
+ if (!space) return false;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (!md) return false;
+ switch (md.gameType) {
+ case 'gauntlet': return !!(space.gauntletRun && !space.gauntletRun.crownRunHeld);
+ case 'stack': return !!(space.stackSession && space.stackSession.active);
+ case 'jump_survive': return !!(space.jumpSurviveSession && space.jumpSurviveSession.active);
+ case 'space_shooter': return !!(space.spaceShooterSession && space.spaceShooterSession.active);
+ case 'balloon_boss': return !!(space.balloonBossSession && space.balloonBossSession.active);
+ case 'quiz': return !!(space.quizSession && space.quizSession.active);
+ case 'quiz_carry': return !!(space.quizCarrySession && space.quizCarrySession.active);
+ default: return false;
+ }
+}
+
+/* ── How-to / pre-live disconnect takeover (MG3–MG7) ───────────────────────────
+ มินิเกมเหล่านี้สร้าง session (bots/turnOrder) ตอน 3-2-1 จบเท่านั้น → ถ้าคนหลุดช่วง how-to /
+ นับถอยหลัง ยังไม่มี session ให้แปลงเป็นบอท เดิม avatar เลยหายไปเลย (ตกไป user-left).
+ แก้: เก็บ "pending takeover" ไว้บน space + แจ้ง client เก็บ avatar (*-player-to-bot) แล้ว
+ seed เป็นบอท server ตอน startXxxGame → บอทรับช่วงเล่นต่อทันทีเมื่อเกมเริ่ม เหมือน MG1/MG2 */
+function missionShellGameTypeForSpace(space) {
+ if (!space) return null;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ const gt = md && md.gameType;
+ if (!gt) return null;
+ if (gt === 'space_shooter' && isSpaceShooterMissionShellSpace(space)) return 'space_shooter';
+ if (gt === 'jump_survive' && isJumpSurviveMissionShellSpace(space)) return 'jump_survive';
+ if (gt === 'balloon_boss' && isBalloonBossMissionShellSpace(space)) return 'balloon_boss';
+ if (gt === 'stack' && isStackTowerMissionShellSpace(space)) return 'stack';
+ if (gt === 'quiz_carry') return 'quiz_carry';
+ return null;
+}
+function recordPendingMissionTakeover(space, gameType, socketId, info, playerKey, nick, wasElim) {
+ if (!space || !gameType || !socketId || !info) return;
+ if (!Array.isArray(space.pendingMissionTakeovers)) space.pendingMissionTakeovers = [];
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => r.id !== socketId);
+ const themeIdx = parseInt(info.lobbyColorThemeIndex, 10);
+ const skinIdx = parseInt(info.lobbySkinToneIndex, 10);
+ space.pendingMissionTakeovers.push({
+ gameType, id: socketId, playerKey: playerKey || '', nick: (nick || 'ผู้เล่น'), wasEliminated: !!wasElim,
+ x: Number(info.x), y: Number(info.y), direction: info.direction || 'down',
+ characterId: info.characterId != null ? info.characterId : null,
+ themeIdx: Number.isFinite(themeIdx) ? themeIdx : null,
+ skinIdx: Number.isFinite(skinIdx) ? skinIdx : null,
+ spaceShooterScore: Math.max(0, Number(info.spaceShooterScore) || 0),
+ spaceShooterEliminated: !!info.spaceShooterEliminated,
+ balloonBossScore: Math.max(0, Number(info.balloonBossScore) || 0),
+ balloonBossBossDmg: Math.max(0, Number(info.balloonBossBossDmg) || 0),
+ balloonBossBalloons: Math.max(0, Number(info.balloonBossBalloons) || 0),
+ balloonBossEliminated: !!info.balloonBossEliminated,
+ jumpSurviveEliminated: !!info.jumpSurviveEliminated,
+ });
+}
+/** seed บอท takeover จาก pending (เฉพาะ gameType นี้) — makeBot(rec) สร้างบอทใน session แล้วคืน true ถ้าสำเร็จ */
+function drainPendingMissionTakeovers(space, gameType, makeBot) {
+ if (!space || !Array.isArray(space.pendingMissionTakeovers) || typeof makeBot !== 'function') return 0;
+ const keep = [];
+ let n = 0;
+ for (const rec of space.pendingMissionTakeovers) {
+ if (rec.gameType !== gameType) { keep.push(rec); continue; }
+ try { if (makeBot(rec) !== false) n++; } catch (e) { /* ignore */ }
+ }
+ space.pendingMissionTakeovers = keep;
+ return n;
+}
+
+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_` 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,
+ gameoverAt: null, /* != null = อยู่ในช่วง grace "ตายหมด" (โชว์ game over ก่อนกลับ LobbyB) */
+ };
+}
+
+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.5; /* miss เมื่อ overlap < 50% บล็อก (เดิม 30% หลวมไป = ลงเยื้อง 3 ช่องยังไม่ miss หัวใจไม่ลด) — ต้องตรงกับ client */
+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 = 1200; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป (ลดจาก 1500 + swing เดินต่อระหว่าง busy → ไม่ค้าง) */
+const STACK_BOT_THINK_MIN_MS = 560;
+const STACK_BOT_THINK_MAX_MS = 1180;
+const STACK_SEATS = 6;
+/* เฟส client (ตอนกดวาง) ต้องใกล้เฟส server ไม่เกินนี้ (rad) ถึงจะใช้.
+ เดิม 1.2rad (~0.35s@0.55Hz) → latency/desync เกินนั้น server ทิ้งเฟส client ใช้ s.phase (เลื่อนไปแล้ว)
+ = "เล็งกลางแต่ลงเยื้อง/ร่วง" (intermittent ตาม latency). ขยายเป็น ~1รอบ (2π ≈ 1.8s@0.55Hz)
+ = ครอบ latency เล่นได้จริงทั้งหมด → ใช้เฟสที่ผู้เล่นเห็นเสมอ (ลงตรงจุดที่เห็น) เหลือไว้แค่กันค่า garbage.
+ เกม co-op สร้างตึกร่วมกัน ไม่ต้องกันโกงเฟส */
+const STACK_DROP_PHASE_TOL = Math.PI * 2;
+
+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));
+ }
+ /* rel-boost: ความกว้างสวิงตาม stackReleaseArea (ต้องตรงกับ client play.js ~12177-12180 เป๊ะ)
+ เดิม server ใช้แค่ land span → client (มี rel-boost) แกว่ง "กว้างกว่า" server: บล็อกห้อยกว้างแต่ตกแคบ
+ = ไถลเฉียงตอนปล่อย + logical ตกใกล้กลางเสมอจน "ทับ support ตลอด ไม่นับ miss → หัวใจไม่ลด".
+ ทำให้ 2 ฝั่งใช้สวิงเดียวกัน → ตกตรง (straight) + miss/หัวใจ ทำงานถูก */
+ const rel = stackBoundsServer(md.stackReleaseArea, w, h);
+ if (rel) {
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.32, 3.0)); /* ต้องตรงกับ client play.js — ลดจาก 0.42/4.2 (สวิงกว้างเกิน = หน่วงนิ้วนิดเดียวเยื้องเยอะ "ต่อตรงแต่ลงเยื้อง") แต่ยังกว้างพอให้ miss ได้เมื่อเยื้องจริง */
+ }
+ 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)
+ phaseOverride: ตา "คนจริง" ส่งเฟสที่เห็นตอนกดมาด้วย (client phase) → คำนวณจุดลงตามที่ผู้เล่นเห็น
+ ไม่ใช่เฟส server ณ ตอน req มาถึง (ช้ากว่า half-RTT = บล็อกลงเยื้อง). บอท/fallback ใช้ s.phase */
+function stackComputeDropServer(s, md, phaseOverride) {
+ const dropPhase = (typeof phaseOverride === 'number' && Number.isFinite(phaseOverride)) ? phaseOverride : s.phase;
+ const raw = s.swingAmp * Math.sin(dropPhase);
+ 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;
+ /* MISS = แค่ overlapMiss (บล็อกไม่ทับบล็อกล่างพอ) เท่านั้น.
+ เดิมมี driftMiss เทียบ placedCx กับ "กลางพื้นดินตายตัว" (anchorCx=land.cx) → ต่อเอียงเป็นบันได
+ (แต่ละชั้นทับชั้นล่างพอดี) พอ center สะสมห่างจากกลางพื้น >38% ฐาน บล็อกที่วางดีๆ ก็ถูกนับ miss
+ = อาการ "ตึกโยกตกทั้งที่ไม่ได้อยู่ริมๆ". ตัดออก — overlapMiss คือเงื่อนไขตกที่ถูกต้องอยู่แล้ว. */
+ const miss = overlapMiss;
+ 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;
+}
+/* สี block ของ "คนจริง" = lobbyColorThemeIndex (1-8) → ตรงกับสี avatar ทุกจอ
+ (เดิมโค้ด stack ใช้ p.lobbyThemeIndex ที่ไม่เคยถูกเซ็ต → null เสมอ; และ (x%8)+1 บนค่า 1-based = เลื่อนสีผิด 1 ช่อง) */
+function normStackTheme8(n) {
+ const v = Math.floor(Number(n));
+ return Number.isFinite(v) ? ((v - 1) % 8 + 8) % 8 + 1 : null;
+}
+function stackHumanThemeServer(p) {
+ return p ? normStackTheme8(p.lobbyColorThemeIndex) : null;
+}
+/* สี block ของบอท AI = สี avatar บอท slot นั้น (lobbyBotThemeBySlot[slot].themeIndex) ไม่ใช่ (slot%8)+1 */
+function stackBotThemeServer(space, slot) {
+ const m = space && space.lobbyBotThemeBySlot;
+ const t = m && m[slot] ? normStackTheme8(m[slot].themeIndex) : null;
+ return t || (((Math.floor(Number(slot)) % 8) + 8) % 8 + 1);
+}
+/* สร้างลำดับตา: humans (เรียง spawnJoinOrder) + bots เติมที่ว่าง ≤ 6 ที่นั่ง */
+function buildStackTurnOrderServer(space, s) {
+ try { syncLobbyBotThemeSlots(space); } catch (_e) { /* ให้ lobbyBotThemeBySlot ตรงกับสีที่ client ได้รับ → สี block บอทตรง avatar */ }
+ 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: stackHumanThemeServer(p) });
+ seat++;
+ }
+ /* บอท takeover (คนหลุดช่วง how-to) ได้ seat ต่อจากคน ก่อนบอท AI — คง avatar/ตำแหน่งลำดับเดิมไว้ */
+ for (const [botId, b] of s.bots) {
+ if (!b || !b.takeover) continue;
+ if (seat > STACK_SEATS) break;
+ order.push({ kind: 'bot', seat, id: null, botId, theme: (b.theme != null ? b.theme : ((seat % 8) + 1)) });
+ 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: stackBotThemeServer(space, slot) });
+ 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,
+ /* full-sync หัวใจ + decrypt% → client ที่พลาด packet stack-drop (ช้า/churn) self-correct
+ (เดิมส่งเฉพาะตอน drop → พลาดแล้ว "หัวใจไม่ลด/% ไม่ตรง" ค้าง) */
+ teamMissesLeft: s.teamMissesLeft,
+ progressPct: s.progressPct,
+ });
+}
+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; }
+ }
+ });
+ /* [TC183] verify คะแนน MG3: team score + คะแนนต่อคน (ดูว่าสูงผิดปกติ/ซ้ำจากชื่อซ้ำไหม) */
+ try {
+ const per = s.turnOrder.map((e) => {
+ if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); return 'bot=' + Math.max(0, (b && b.score) | 0); }
+ if (e.id != null) { const p = space.peers.get(e.id); return ((p && p.nickname) || e.id).toString().slice(0, 6) + '=' + Math.max(0, s.humanPts[e.id] | 0); }
+ return '?';
+ });
+ glog(sid, space, 'stack-score', '[TC183] จบ MG3 outcome=' + outcome + ' team=' + (s.score | 0) + ' layers=' + s.layers.length + ' progress=' + Math.floor(s.progressPct || 0) + '% · ต่อคน: ' + per.join(' '));
+ } catch (e) { /* ignore */ }
+ 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) — ยังดรอปไม่ได้ (drop handler เช็ค busyUntil) แต่ให้ "บล็อกถัดไปแกว่งต่อ"
+ (advance phase + emit swing) → ตาถัดไปไม่เห็นบล็อกห้อยนิ่ง = ไม่ค้าง · client มี guard (!stackFall) ไม่ให้กระตุกตอนยังร่วง */
+ if (now < s.busyUntil) {
+ 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, t: now });
+ }
+ 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, t: now }); }
+ if (now >= s.botThinkUntil) {
+ /* คิดเสร็จ → ล็อกเฟสเป้าหมาย "ข้างหน้า" ครั้งเดียว แล้วรอแกว่งวิ่งมาถึงเองธรรมชาติ
+ (ไม่สแนปเฟส = ไม่กระตุก) ปล่อยเมื่อถึงเป้า หรือเลย deadline กันค้าง */
+ if (s.botAimPhase == null) {
+ s.botAimPhase = stackBotComputeAimPhaseServer(s, md, tier);
+ /* deadline ต้องยาวพอให้สวิง "ถึงเป้า" จริง = เวลาแกว่งจาก phase ปัจจุบันไปถึง aimPhase + buffer.
+ เดิม 2600ms ตายตัว → ตอน hz ต่ำ (สวิงช้า) กว่าจะถึงเป้าใช้ >2.6s → deadline เตะก่อน = บอทปล่อยผิด phase (เฉียง/miss) */
+ const _gap = Math.max(0, s.botAimPhase - s.phase);
+ const _radPerSec = Math.max(1e-4, s.phaseSpeed * Math.PI * 2);
+ s.botAimDeadline = now + Math.min(15000, Math.ceil((_gap / _radPerSec) * 1000) + 1500);
+ }
+ 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,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = now + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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, t: now }); }
+ }
+ /* 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);
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนจัดลำดับตา (buildStackTurnOrderServer จะใส่เป็น seat ให้) */
+ {
+ const s3 = space.stackSession;
+ drainPendingMissionTakeovers(space, 'stack', (rec) => {
+ s3.bots.set(rec.id, {
+ tier: 'avg', score: 0, takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ theme: normStackTheme8(rec.themeIdx),
+ });
+ });
+ }
+ 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 6), effectiveBotSlotCount(space)));
+ const bannedSlot = jsBannedBotSlot(space);
+ let poolIdx = 1 + humanCount + existing;
+ 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedJumpSurviveTakeoverBots(space, md);
+ createJumpSurviveBots(space, md);
+}
+function seedJumpSurviveTakeoverBots(space, md) {
+ const s = space.jumpSurviveSession;
+ if (!s) return;
+ const now = Date.now();
+ drainPendingMissionTakeovers(space, 'jump_survive', (rec) => {
+ s.bots.set(rec.id, {
+ tier: 'avg',
+ x: Number.isFinite(rec.x) ? rec.x : 1, y: Number.isFinite(rec.y) ? rec.y : 1,
+ vy: 0, onGround: false, dir: rec.direction || 'down', walking: false,
+ eliminated: !!rec.jumpSurviveEliminated,
+ wishX: 0, wishNext: now + 200, nextJumpAi: now + 200,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ยัง emit elim:true ต่อทุก tick (เดิม return ข้าม = ส่ง elim ครั้งเดียว → client พลาด = ค้างนิ่ง) */
+ if (b.eliminated) { outBots.push({ id, x: b.x, y: b.y, dir: b.dir || 'down', walking: false, elim: true }); 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 });
+ });
+ /* ผู้เล่นจริงที่สลับแท็บ (hidden) → จำลองการตกแทน timer ฝั่ง client ที่ถูก throttle → ส่งตำแหน่ง full-rate (คนอื่นเห็นลื่น).
+ ไม่มี input (ตก/ไหลตามแท่นเฉย ๆ) เหมือนคนปล่อยจอ. ตาย → mark eliminated + ตั้ง p.jumpSurviveEliminated (เข้าเช็ค all_dead/เกรด) */
+ if (s.players) {
+ for (const pid in s.players) {
+ const st = s.players[pid];
+ if (!st || !st.hidden || st.eliminated) continue;
+ if (!space.peers.has(pid)) { st.hidden = false; continue; } /* หลุดห้องแล้ว → ปล่อยให้ disconnect logic จัดการ */
+ const pxc = ((Number.isFinite(st.x) ? st.x : 1) + cw * 0.5) * ts;
+ const feetY = ((Number.isFinite(st.y) ? st.y : 1) + ch) * ts;
+ const r = jsPhysicsIntegrate(md, ts, sc, {
+ dt, ch, pxc0: pxc, feetY0: feetY, vy0: st.vy || 0, wasOnGround: !!st.onGround,
+ jumpQueued: false, moveLeft: false, moveRight: false,
+ });
+ st.x = r.pxc / ts - cw * 0.5;
+ st.y = r.feetY / ts - ch;
+ st.vy = r.vy; st.onGround = r.onGround;
+ if (r.died) {
+ st.eliminated = true; st.hidden = false; st.vy = 0;
+ const p = space.peers.get(pid); if (p) p.jumpSurviveEliminated = true;
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: false, elim: true });
+ } else {
+ outBots.push({ id: pid, x: st.x, y: st.y, dir: st.dir || 'down', walking: Math.abs(r.vy) > 40, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" ในการจำกัด 6 ช่อง กันรวมเกิน 6 */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed เป็นบอท takeover ก่อนสร้างบอท AI (createSpaceShooterBots นับ s.bots.size เป็น human-like) */
+ seedSpaceShooterTakeoverBots(space, md);
+ createSpaceShooterBots(space, md);
+}
+function seedSpaceShooterTakeoverBots(space, md) {
+ const s = space.spaceShooterSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ drainPendingMissionTakeovers(space, 'space_shooter', (rec) => {
+ const cx = Number.isFinite(rec.x) ? Math.max(ts * 0.5, Math.min(mw - ts * 0.5, (rec.x + 0.5) * ts)) : mw * 0.5;
+ const cy = ssClampCy(Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.5, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', cx, cy, dir: 'up',
+ score: rec.spaceShooterScore || 0, eliminated: !!rec.spaceShooterEliminated, 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() + 500,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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) => {
+ /* บอทตายแล้ว: ไม่เดิน/ไม่ยิง แต่ "ยัง emit elim:true ต่อ" — เดิม return ข้ามเลย (ส่ง elim ครั้งเดียวตอนตาย)
+ ถ้า client พลาด packet นั้น = ไม่รู้ว่าตาย → ยานค้างนิ่งบนจอ. ส่งซ้ำทุก tick → client ซ่อนได้แน่นอน */
+ if (!b.eliminated) {
+ 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: !!b.eliminated, 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;
+ /* บอท takeover (จาก how-to) ที่ seed ไว้แล้ว = นับเป็น "คน" กันรวมเกิน 6 + เลื่อนช่อง spawn บอท AI */
+ const existing = s.bots.size;
+ const botCount = Math.max(0, Math.min(6 - Math.min(humanCount + existing, 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 + existing + 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,
+ };
+ /* คนหลุดช่วง how-to → seed บอท takeover ก่อนสร้างบอท AI */
+ seedBalloonBossTakeoverBots(space, md);
+ createBalloonBossBots(space, md);
+}
+function seedBalloonBossTakeoverBots(space, md) {
+ const s = space.balloonBossSession;
+ if (!s) return;
+ const ts = jsTileSize(md);
+ const mw = (md.width || 20) * ts, mh = (md.height || 15) * ts;
+ const startBalloons = balloonBossBalloonsForMap(md);
+ drainPendingMissionTakeovers(space, 'balloon_boss', (rec) => {
+ const cx = Number.isFinite(rec.x) ? (rec.x + 0.5) * ts : mw * 0.5;
+ const cy = Number.isFinite(rec.y) ? (rec.y + 0.5) * ts : mh - ts * 1.4;
+ const cl = bbClampWorld(cx, cy, mw, mh);
+ s.bots.set(rec.id, {
+ tier: 'avg', homeCx: cl.cx, homeCy: cl.cy, cx: cl.cx, cy: cl.cy,
+ score: rec.balloonBossScore || 0, dmg: rec.balloonBossBossDmg || 0,
+ balloons: rec.balloonBossBalloons || startBalloons, eliminated: !!rec.balloonBossEliminated,
+ fireCd: 0.6 + Math.random() * 0.8, phase: Math.random() * Math.PI * 2,
+ takeover: true, takeoverPlayerKey: rec.playerKey || '',
+ });
+ });
+}
+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 });
+}
+/* ตำแหน่งบอส "เคลื่อนที่จริง" — port จาก client getBalloonBossBossLiveCenterPlay (anchor=liveBeginsAt เดียวกัน → ตรงกันทุกเครื่อง)
+ server เดิมคิดบอสเป็นจุดนิ่ง → บอทไม่รู้ว่าบอสขยับมาทับ. มีตัวนี้ บอทหลบบอสที่เคลื่อนที่ได้ + เล็งยิงตรงบอสจริง */
+function bbBossLiveCenter(md, ts, s, now) {
+ const base = bbBossCenter(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const anchor = (s && s.liveBeginsAt) ? s.liveBeginsAt : now;
+ const tSec = Math.max(0, (now - anchor) / 1000);
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return bbClampWorld(cx, cy, mw, mh);
+}
+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 = bbBossLiveCenter(md, ts, s, now); /* บอสเคลื่อนที่จริง (หลบ+เล็งตามตัวนี้) */
+ const fleeR = Math.max(ts * 3.4, 96); /* หลบบอสเมื่อ home เข้าใกล้ ~3 ช่อง */
+ const fleeSpd = 165; /* px/s ดริฟต์ home หนีบอส (เร็วกว่าบอส → ไม่ติด) */
+ const sepMin = ts * 2.4; /* ระยะ home ขั้นต่ำระหว่างบอท (หลบกันเอง) */
+ const outBots = [];
+ const alive = [];
+ s.bots.forEach((b, id) => {
+ if (b.eliminated) { outBots.push({ id, cx: b.cx, cy: b.cy, score: b.score, dmg: b.dmg, balloons: b.balloons, elim: true }); return; } /* ตายแล้ว: emit elim:true ต่อ (กัน client พลาด packet = ค้างนิ่ง) */
+ alive.push(b);
+ /* หลบบอส: ดริฟต์ "home" ออกจากบอสที่เคลื่อนเข้าใกล้ (แทน clamp ตำแหน่ง = ไม่ติดนิ่งขอบบอส) */
+ const hx = b.homeCx - boss.cx, hy = b.homeCy - boss.cy;
+ const hd = Math.hypot(hx, hy) || 1e-6;
+ if (hd < fleeR) { b.homeCx += (hx / hd) * fleeSpd * dt; b.homeCy += (hy / hd) * fleeSpd * dt; }
+ 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 });
+ });
+ /* หลบกันเอง: ดัน home ที่ใกล้กันเกินให้แยกออก (รอบถัดไปบอทจะ wobble รอบ home ใหม่ → ไม่ซ้อนกัน) */
+ for (let i = 0; i < alive.length; i++) {
+ for (let j = i + 1; j < alive.length; j++) {
+ const a = alive[i], c = alive[j];
+ const dx = a.homeCx - c.homeCx, dy = a.homeCy - c.homeCy;
+ const d = Math.hypot(dx, dy) || 1e-6;
+ if (d < sepMin) { const p = (sepMin - d) * 0.5, nx = dx / d, ny = dy / d; a.homeCx += nx * p; a.homeCy += ny * p; c.homeCx -= nx * p; c.homeCy -= ny * p; }
+ }
+ }
+ alive.forEach((b) => { const cl = bbClampWorld(b.homeCx, b.homeCy, mw, mh); b.homeCx = cl.cx; b.homeCy = cl.cy; }); /* กัน home ดริฟต์หลุดขอบ */
+ 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_' (ตรงกับ 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 };
+}
+
+/* คอมโพเนนต์เดินได้ "หลัก" (BFS จาก spawn slot) — กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated walkable) ที่กำแพงล้อม
+ cache ต่อ space (invalidate เมื่อ mapId เปลี่ยน) */
+function lobbyMainCompServer(md, space) {
+ const mid = space && space.mapId;
+ if (space && space._lobbyMainComp && space._lobbyMainCompMap === mid) return space._lobbyMainComp;
+ const w = md.width || 20, h = md.height || 15;
+ const isW = (x, y) => x >= 0 && x < w && y >= 0 && y < h && !(md.objects && md.objects[y] && md.objects[y][x] === 1);
+ const slots = parseLobbyPlayerSpawnsServer(md).filter(Boolean);
+ let seed = null;
+ for (const s of slots) { const sx = Math.floor(s.x), sy = Math.floor(s.y); if (isW(sx, sy)) { seed = [sx, sy]; break; } }
+ if (!seed && md.spawn && isW(Math.floor(md.spawn.x), Math.floor(md.spawn.y))) seed = [Math.floor(md.spawn.x), Math.floor(md.spawn.y)];
+ if (!seed) { for (let y = 0; y < h && !seed; y++) for (let x = 0; x < w; x++) { if (isW(x, y)) { seed = [x, y]; break; } } }
+ const comp = new Set();
+ if (seed) {
+ comp.add(seed[0] + ',' + seed[1]);
+ const q = [seed];
+ while (q.length) { const [a, b] = q.shift(); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = a + dx, ny = b + dy, k = nx + ',' + ny; if (!comp.has(k) && isW(nx, ny)) { comp.add(k); q.push([nx, ny]); } } }
+ }
+ if (space) { space._lobbyMainComp = comp; space._lobbyMainCompMap = mid; }
+ return comp;
+}
+/* เดินบอท 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;
+ const mainComp = lobbyMainCompServer(md, space);
+ const inMain = (x, y) => !mainComp.size || mainComp.has(Math.floor(x) + ',' + Math.floor(y));
+ bots.forEach((b, id) => {
+ /* กันบอทไปติดเกาะแยก/strip ข้างฉาก (isolated) → ดึงกลับ spawn หลัก (main component) */
+ if (!inMain(b.x, b.y)) {
+ const slot = parseInt(id.slice(LOBBY_BOT_PREFIX.length), 10);
+ const sp = pickLobbyBotSpawnServer(md, space, slot);
+ if (inMain(sp.x, sp.y)) { b.x = sp.x + 0.5; b.y = sp.y + 0.5; }
+ else { const first = mainComp.values().next().value; if (first) { const [mx, my] = first.split(',').map(Number); b.x = mx + 0.5; b.y = my + 0.5; } }
+ b.wTX = null; b.wTY = null; b.retargetAt = 0; b.pauseUntil = 0;
+ }
+ 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' ? 'หมดเวลา — จบมินิเกม'
+ : (reason === 'all_dead' ? 'ตกขอบหมดแล้ว — จบมินิเกม' : 'จบมินิเกม');
+ 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;
+ }
+
+ /* ตายหมดแล้ว (all_dead) — อยู่ในช่วง grace: หยุดเกม + ให้เวลา client วาดจอ result-gameover → GCM
+ ก่อน server ดึงกลับ LobbyB. เดิม all_dead จบ+return ทันที (0 grace) = client ยังไม่ทันวาด game over
+ ก็โดนดึงกลับ = "ตายหมดแต่ไม่ขึ้น game over". ใช้ grace เท่ากับทางชนะ (GAUNTLET_FINISH_WALK_MS) */
+ if (gr.gameoverAt != null) {
+ if (gr.obstacles && gr.obstacles.length) gr.obstacles = [];
+ if (Date.now() >= gr.gameoverAt) {
+ endGauntletGame(sid, space, 'all_dead');
+ return;
+ }
+ /* ไม่ emit sync ระหว่าง grace — ปล่อย client คุม overlay result เอง (เหมือน finish grace) */
+ return;
+ }
+
+ /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client
+ เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */
+ if (gr.finishPhase) {
+ if (gr.obstacles.length) { gr.obstacles = []; }
+ /* ตายหมดตั้งแต่ก่อน/ระหว่าง finish-walk grace → เข้า grace game over (แทน result-complete จากทางเส้นชัย)
+ (race: client ตัวหนึ่ง scroll ถึงเส้นชัยก่อน → set finishPhase → early-return ข้ามเช็ค all_dead ท้าย tick) */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ตายหมด (ระหว่าง finish-phase) → grace game over → กลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ return;
+ }
+ 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);
+ });
+ }
+
+ /* ผู้เล่น (คนจริง) ตกขอบหมด → เข้า grace game over (ไม่รอหมดเวลา) แล้วค่อย return LobbyB
+ ส่ง sync ครั้งสุดท้าย (ทุกคน eliminated) ให้ client ทริก overlay result-gameover ก่อนหมด grace */
+ if (gauntletAllHumansEliminated(space)) {
+ gr.gameoverAt = Date.now() + GAUNTLET_FINISH_WALK_MS;
+ gr.obstacles = [];
+ try { glog(sid, space, 'gauntlet-end', '[TC172] ผู้เล่นตกขอบหมด → grace game over แล้วกลับ LobbyB (pass)'); } catch (e) { /* ignore */ }
+ emitGauntletSync(sid, space);
+ return;
+ }
+
+ emitGauntletSync(sid, space);
+}
+
+/** MG2 crown/gauntlet: คนจริงที่ "ยังเล่นอยู่" (ไม่นับผู้ถูกแบน/ผู้ชม) ตกขอบหมด = จบเกม all_dead
+ บอทอยู่ใน gr.bots ไม่ใช่ space.peers → ไม่ถูกนับอยู่แล้ว (server ตัดสินจากคนจริงเท่านั้น)
+ - นับเฉพาะ crown map · ต้องมีคนเล่นจริง ≥1 คน (กันจบทันทีตอน 0 คน) */
+function gauntletAllHumansEliminated(space) {
+ if (!isGauntletCrownHeistMapBySpace(space) || !space.peers || space.peers.size === 0) return false;
+ let active = 0;
+ let alive = 0;
+ space.peers.forEach((p) => {
+ if (!p || p.bannedSpectator) return; /* ผู้ถูกแบน = เข้ามาดู ไม่ได้เล่น → ไม่บล็อกการจบเกม */
+ active++;
+ if (!p.gauntletEliminated) alive++;
+ });
+ return active > 0 && alive === 0;
+}
+
+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: clampTroublesomeOfferSec(runtimeGameTiming && runtimeGameTiming.troublesomeOfferSec) });
+}
+
+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) {
+ const rollMaxMs = Math.max(1000, Math.round(clampTroublesomeRollMaxSec(runtimeGameTiming && runtimeGameTiming.troublesomeRollMaxSec) * 1000));
+ space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), rollMaxMs);
+ }
+}
+
+/**
+ * ห้องทดสอบเอดิเตอร์ / 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) {
+ /* rejoin whitelist: เช็ค playerKey เป็นหลัก (กันเคส 2 คนชื่อซ้ำ — ลบ nick คนนึงตอนออก ทำให้อีกคน rejoin ไม่ได้)
+ fallback เป็นชื่อ (คนที่ไม่มี playerKey / ความเข้ากันได้เดิม) */
+ const jk = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? ('k:' + playerKey) : '';
+ const wlKeys = space.caseParticipantKeys;
+ const wlNicks = space.caseParticipantNicknames;
+ const okByKey = !!(jk && wlKeys && wlKeys.size > 0 && wlKeys.has(jk));
+ const okByNick = !!(joinNickNorm && wlNicks && wlNicks.size > 0 && wlNicks.has(joinNickNorm));
+ if (!okByKey && !okByNick) {
+ 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 }); });
+ }
+ /* MG3–MG7 pending takeover (คนหลุดช่วง how-to/นับถอยหลัง — ยังไม่มี session live): กลับมาทันก่อนเกมเริ่ม
+ → ล้าง pending ที่ playerKey ตรง กัน drainPendingMissionTakeovers seed เป็นบอทซ้ำตอน startXxxGame
+ ROOT CAUSE "บอทเข้าไปเล่นด้วย MG3/5/7": rejoin เดิมถอนบอทเฉพาะ session ที่ live แล้ว (บล็อกข้างบน)
+ แต่ shell (MG3-7) สร้าง session ตอน 3-2-1 จบ → ตอน rejoin ยังไม่มี session = ไม่มีอะไรถอน +
+ pending ค้าง → seed เป็น "บอทผี" คู่กับคนจริงที่ rejoin. emit user-left(id เก่า) ลบ avatar บอท placeholder */
+ if (Array.isArray(space.pendingMissionTakeovers) && space.pendingMissionTakeovers.length) {
+ const cancelled = space.pendingMissionTakeovers.filter((r) => r && r.playerKey && r.playerKey === joinPlayerKey);
+ if (cancelled.length) {
+ space.pendingMissionTakeovers = space.pendingMissionTakeovers.filter((r) => !(r && r.playerKey && r.playerKey === joinPlayerKey));
+ cancelled.forEach((r) => { if (r.id) io.to(spaceId).emit('user-left', { id: r.id }); });
+ glog(spaceId, space, 'join', '[TC173] rejoin ก่อนเกมเริ่ม → ยกเลิก pending bot takeover ' + cancelled.length + ' (กันบอทผี MG3/5/7 · pass)', nickname);
+ }
+ }
+ }
+ /* 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,
+ netReady: false, /* เพิ่ง join → กำลังโหลด จนกว่า client ส่ง client-ready (badge ให้คนอื่นเห็นว่า "กำลังโหลด" ไม่ใช่ค้าง) */
+ };
+ 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),
+ suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
+ botSlotCount: effectiveBotSlotCount(space),
+ lobbyBotThemes: syncLobbyBotThemeSlots(space),
+ joinLocked: !!space.joinLocked,
+ missionLiveBeginsAt: space.missionLiveBeginsAt || null,
+ missionLive: isMinigameLiveForRejoinResume(space), /* rejoin กลางเกม live → client ข้าม how-to เข้าเล่นต่อ */
+ };
+ 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);
+ /* [achv b5] คนที่แตะไอคอน ตำรวจ/ทนาย = คนเก็บไอเทมช่วยเหลือ (trigger ครั้งเดียว/ไอคอน) */
+ if (started) {
+ const bp = space.peers.get(socket.id);
+ if (bp && bp.playerKey) submitAchievementProgress([{ playerKey: bp.playerKey, id: 'b5_deep_scanner', inc: 1 }]);
+ }
+ 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', (data) => {
+ 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;
+ /* ใช้เฟสที่ผู้เล่นเห็นจริงตอนกด (client ส่งมา) → บล็อกลงตรงจุดที่เห็นแกว่ง ไม่เยื้องตาม half-RTT/latency.
+ sanity bound กว้าง (STACK_DROP_PHASE_TOL = 2π) เหลือไว้กันค่า garbage เท่านั้น (ดู const) */
+ let dropPhase = s.phase;
+ const cph = data && Number(data.phase);
+ if (Number.isFinite(cph) && Math.abs(cph - s.phase) <= STACK_DROP_PHASE_TOL) dropPhase = cph;
+ const hit = stackComputeDropServer(s, md, dropPhase);
+ stackApplyHitServer(s, hit, { id: entry.id });
+ const peer = space.peers.get(socket.id);
+ const theme = stackHumanThemeServer(peer) || (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: dropPhase,
+ teamMissesLeft: s.teamMissesLeft,
+ });
+ s.busyUntil = Date.now() + STACK_DROP_BUSY_MS;
+ if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) {
+ stackSessionEnd(sid, space, 'misses_exhausted');
+ return;
+ }
+ 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 }] });
+ }
+ });
+
+ /* MG5 — ผู้เล่นสลับแท็บ (hidden): browser throttle setInterval เหลือ ~1Hz → emit move ~1/วิ = คนอื่นเห็นตกเป็นก้อน.
+ แก้: client แจ้ง hidden พร้อม state ปัจจุบัน → server จำลองการตก (jsPhysicsIntegrate) แทน ใน stepJumpSurviveBots
+ แล้ว broadcast ผ่าน jump-survive-bot-move (full rate) → คนอื่นเห็นลื่น. visible → hidden:false → client คุมเอง. */
+ socket.on('jump-survive-hidden', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !data) return;
+ const s = space.jumpSurviveSession;
+ if (!s || !s.active || s.ended) return;
+ if (!s.players[socket.id]) s.players[socket.id] = {};
+ const st = s.players[socket.id];
+ if (data.hidden) {
+ if (st.eliminated) return;
+ st.hidden = true;
+ st.x = Number.isFinite(data.x) ? data.x : (Number.isFinite(st.x) ? st.x : 1);
+ st.y = Number.isFinite(data.y) ? data.y : (Number.isFinite(st.y) ? st.y : 1);
+ st.vy = Number.isFinite(data.vy) ? data.vy : 0;
+ st.onGround = !!data.onGround;
+ st.dir = data.direction || st.dir || 'down';
+ } else {
+ st.hidden = false;
+ }
+ });
+
+ /** เปลี่ยนชื่อที่แสดงใน 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) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK เริ่มสืบสวน — รอผู้เล่นกลับเข้า LobbyB ครบ (' + waitInfo.present + '/' + waitInfo.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({
+ ok: false,
+ waitingPlayers: true,
+ present: waitInfo.present,
+ total: waitInfo.total,
+ error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')',
+ });
+ }
+ /* TC156: gate จนกว่าทุกคนอ่าน story (cutscene) ครบ — เฉพาะรอบแรก (storyReadComplete เป็น true หลังครบ/รอบถัดไป) */
+ if (!space.storyReadComplete) {
+ const stRead = detectiveStoryReadStatus(space);
+ if (!stRead.allRead) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'story-gate', '[TC156] BLOCK เริ่มสืบสวน — รอทุกคนอ่าน story ครบ (' + stRead.present + '/' + stRead.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingStory: true, present: stRead.present, total: stRead.total, error: 'รอผู้เล่นอ่านเนื้อเรื่องให้ครบก่อน (' + stRead.present + '/' + stRead.total + ')' });
+ }
+ space.storyReadComplete = true;
+ }
+ 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' });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'vote-gate', '[TC157] เริ่ม vote ban (ผู้เล่นเข้า LobbyB ครบ ' + waitInfo.present + '/' + waitInfo.total + ') — มินิเกมจะเริ่มหลังโหวตจบเท่านั้น (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ 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);
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ ' + waitInfo.total + ' คน · ผู้ต้องสงสัย #' + (selectedIndex + 1), (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ reply(started);
+ });
+
+ socket.on('detective-minigame-finished', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) {
+ return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ }
+ /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
+ const finPeer = space.peers.get(socket.id);
+ if (finPeer && data && typeof data === 'object') {
+ /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง
+ หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */
+ if (Number.isFinite(Number(data.score))) {
+ let rs = Math.max(0, Math.floor(Number(data.score)));
+ if (rs > REPORTED_MINI_SCORE_MAX) {
+ console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX);
+ rs = REPORTED_MINI_SCORE_MAX;
+ }
+ finPeer.reportedMiniScore = rs;
+ }
+ if (data.survived === false) finPeer.reportedMiniSurvived = false;
+ }
+ if (!space.detectiveMinigameActive) {
+ if (serverMapIsPostCaseLobbyB(space)) {
+ const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
+ return reply({
+ ok: true,
+ alreadyOnLobbyB: true,
+ suspectIndex: grant.suspectIdx,
+ awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
+ awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
+ grade: grant.grade || null,
+ freeEvidenceCount: grant.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ }
+ return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
+ }
+ /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก"
+ จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด
+ ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */
+ const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) {
+ if (finPeer) finPeer.detectiveFinished = true;
+ const allDone = [...space.peers.values()].every((p) =>
+ p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished
+ );
+ if (!allDone) {
+ /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */
+ return reply({ ok: true, holdInScene: true });
+ }
+ /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */
+ }
+ // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
+ returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
+ const g = space.lastEvidenceGrant || {};
+ reply({
+ ok: true,
+ suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
+ awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
+ awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
+ grade: g.grade || null,
+ freeEvidenceCount: g.freeEvidenceCount || 0,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ /**
+ * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที
+ * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)
+ */
+ socket.on('detective-debug-award-evidence', (data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
+ if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ const idx = Math.floor(Number(data && data.suspectIndex));
+ if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' });
+ const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C');
+ if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' });
+ emitLobbyEvidenceSync(sid, space);
+ reply({
+ ok: true,
+ suspectIndex: idx,
+ awardedCard: card,
+ myPlayerEvidence: clonePlayerEvidence(space, socket.id),
+ suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
+ });
+ });
+
+ // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย)
+ socket.on('suspect-accuse-open', (_data, cb) => {
+ const reply = typeof cb === 'function' ? cb : () => {};
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' });
+ if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' });
+ const prog = playerSuspectProgressFromEvidence(space, socket.id);
+ if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' });
+ /* ชี้ตัวคนร้าย = เข้าพิจารณาคดี → ต้องรอผู้เล่นในคดีกลับเข้า LobbyB ครบก่อน (กันคนตกรอบโหวต) */
+ const accuseWait = detectiveLobbyBMissingParticipants(space);
+ if (accuseWait.missing > 0) {
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'lobbyb-gate', '[TC144] BLOCK ชี้ตัวคนร้าย — รอผู้เล่นเข้า LobbyB ครบ (' + accuseWait.present + '/' + accuseWait.total + ') — gate ทำงาน (pass)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ return reply({ ok: false, waitingPlayers: true, present: accuseWait.present, total: accuseWait.total, error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + accuseWait.present + '/' + accuseWait.total + ')' });
+ }
+ 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;
+ space.trialVoteAt = space.trialVoteAt || {};
+ space.trialVoteAt[socket.id] = Date.now(); /* [achv c4] เวลาโหวต → เช็ค "10 วิสุดท้าย" */
+ emitTrialVoteUpdate(sid, space);
+ const totalTV = trialEligibleVoterTotal(space);
+ const votedNow = Object.keys(space.trialVotes).length;
+ try { glog(sid, space, 'trial-vote', '[TC180] ยืนยันโหวต ' + votedNow + '/' + totalTV + (votedNow >= totalTV && totalTV > 0 ? ' — ครบ → reveal (pass)' : ' — รออีก')); } catch (e) { /* ignore */ }
+ if (votedNow >= 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)) {
+ /* ล็อก: host กำลังเปิด wizard เลือกระดับ/คดี → ปฏิเสธ set-ready (กัน client หลุด flow ตอน host เลือกอยู่) */
+ if (space.preplayOpen && space.hostId !== socket.id) {
+ const p = space.peers.get(socket.id);
+ io.to(socket.id).emit('peer-ready', { id: socket.id, ready: !!(p && p.ready) }); /* ย้ำสถานะเดิมกลับให้ผู้ส่ง */
+ try { glog(sid, space, 'preplay-lock', '[TC154] REJECT set-ready ระหว่าง host เลือกคดี — feature ทำงาน (pass): client ยกเลิกพร้อมไม่ได้', (p && p.nickname) || String(socket.id).slice(0, 6)); } catch (e) { /* ignore */ }
+ break;
+ }
+ const p = space.peers.get(socket.id);
+ if (p) p.ready = !!ready;
+ io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
+ break;
+ }
+ }
+ });
+
+ /* LobbyA: host เปิด/ปิด wizard เลือกระดับ/คดี → เก็บ state ที่ server + relay ทั้งห้อง (ล็อกปุ่มพร้อมฝั่ง client) */
+ socket.on('lobby-preplay-open', ({ open } = {}) => {
+ for (const [sid, space] of spaces) {
+ if (space.peers.has(socket.id) && space.hostId === socket.id) {
+ space.preplayOpen = !!open;
+ io.to(sid).emit('lobby-preplay-open', { open: !!open });
+ try { const hp = space.peers.get(socket.id); glog(sid, space, 'preplay-lock', '[TC154] ' + (open ? 'host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง' : 'host ปิด wizard → ปลดล็อก set-ready') + ' (' + space.peers.size + ' คนในห้อง)', (hp && hp.nickname) || 'host'); } catch (e) { /* ignore */ }
+ break;
+ }
+ }
+ });
+
+ /* TC156: client อ่าน story (cutscene) จบ → บันทึกว่าคนนี้พร้อม · ครบทุกคน → ปลดล็อกเลือกผู้ต้องสงสัย */
+ socket.on('story-read-done', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.storyReadKeys) space.storyReadKeys = new Set();
+ const already = space.storyReadKeys.has(key);
+ space.storyReadKeys.add(key);
+ /* อ่าน story จบ = โหลด LobbyB เสร็จแน่นอน → นับ lobbyb-loaded ด้วย (reliable กว่า event lobbyb-loaded เดี่ยว) */
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ space.lobbyBLoadedKeys.add(key);
+ const st = detectiveStoryReadStatus(space);
+ if (!already) { try { glog(sid, space, 'story-gate', '[TC156] อ่าน story จบ ' + st.present + '/' + st.total, nick); } catch (e) { /* ignore */ } }
+ if (st.allRead && !space.storyReadComplete) {
+ space.storyReadComplete = true;
+ if (space.storyReadTimer) { clearTimeout(space.storyReadTimer); space.storyReadTimer = null; }
+ try { glog(sid, space, 'story-gate', '[TC156] ทุกคนอ่าน story ครบ (' + st.total + ') → ปลดล็อกเลือกผู้ต้องสงสัย (pass)'); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveStoryReadPresence(sid, space);
+ /* piggyback เพิ่ม lobbyBLoadedKeys แล้ว → ต้อง broadcast lobbyb-presence ใหม่ด้วย
+ ไม่งั้น client ค้างโชว์ "รอผู้เล่น (2/3)" (ค่าเก่า) → ปุ่ม host ถูก disable ทั้งที่ครบแล้ว */
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉาก LobbyB เสร็จจริง → นับเป็น "present" ได้ (กัน host เริ่มก่อนคนโหลดเสร็จ = ค้างตอนจบ) */
+ socket.on('lobbyb-loaded', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ if (!serverMapIsPostCaseLobbyB(space)) break;
+ const p = space.peers.get(socket.id);
+ const key = detectiveParticipantKey(p);
+ const nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : '';
+ if (!key) break;
+ if (!space.lobbyBLoadedKeys) space.lobbyBLoadedKeys = new Set();
+ if (!space.lobbyBLoadedKeys.has(key)) {
+ space.lobbyBLoadedKeys.add(key);
+ const info = detectiveLobbyBMissingParticipants(space);
+ try { glog(sid, space, 'lobbyb-gate', '[TC144] โหลด LobbyB เสร็จ ' + info.present + '/' + info.total, nick); } catch (e) { /* ignore */ }
+ }
+ emitDetectiveLobbyBPresence(sid, space);
+ break;
+ }
+ });
+
+ /* client โหลดฉากเสร็จ (ทุกฉาก lobby/minigame) → เอา badge "กำลังโหลด" ออก (คนอื่นเห็นว่าพร้อมแล้ว) */
+ socket.on('client-ready', () => {
+ for (const [sid, space] of spaces) {
+ if (!space.peers.has(socket.id)) continue;
+ const p = space.peers.get(socket.id);
+ if (p && p.netReady !== true) {
+ p.netReady = true;
+ io.to(sid).emit('peer-net', { id: socket.id, nr: true });
+ }
+ 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) {
+ space.preplayOpen = false; /* host เริ่มเกมแล้ว → ปลดล็อก (client เปลี่ยนฉากอยู่แล้ว แต่เคลียร์ state กัน stuck) */
+ 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();
+ const caseKeyWl = new Set();
+ space.peers.forEach((p) => {
+ caseNickWl.add(normalizeLobbyNickname(p.nickname)); /* ชื่อ — ใช้ whitelist rejoin (คงเดิม) */
+ const k = detectiveParticipantKey(p);
+ if (k) caseKeyWl.add(k); /* playerKey — ใช้ presence/story/gate (กันชื่อซ้ำนับเป็นคนเดียว) */
+ });
+ space.caseParticipantNicknames = caseNickWl;
+ space.caseParticipantKeys = caseKeyWl;
+ /* TC156: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story (cutscene) ครบ — reset ที่ initial LobbyA→LobbyB เท่านั้น
+ (รอบ investigate ถัดไปไม่มี cutscene → storyReadComplete คงเป็น true) · safety timer กัน deadlock ถ้า client ไม่ emit */
+ space.storyReadKeys = new Set();
+ space.storyReadComplete = false;
+ space.lobbyBLoadedKeys = new Set(); /* เริ่มคดี → ทุกคนต้องส่ง lobbyb-loaded ใหม่ ก่อนนับ present */
+ armLobbyBLoadedSafety(sid, space); /* backstop กัน deadlock */
+ if (space.storyReadTimer) clearTimeout(space.storyReadTimer);
+ space.storyReadTimer = setTimeout(() => {
+ const sp = spaces.get(sid);
+ if (sp && !sp.storyReadComplete) {
+ sp.storyReadComplete = true;
+ try { glog(sid, sp, 'story-gate', '[TC156] safety-timeout 90s → force ปลดล็อก (client บางคนไม่ส่ง story-read-done)'); } catch (e) { /* ignore */ }
+ try { emitDetectiveStoryReadPresence(sid, sp); } catch (e) { /* ignore */ }
+ }
+ }, 90000);
+ 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 ยังมีอยู่ — รวมช่วง how-to / pre-run hold (crownRunHeld) ด้วย เหมือน MG1
+ (เดิมข้าม crownRunHeld → host หลุดตอน how-to = avatar หายไปเลย ไม่มีบอทรับช่วง; ตอนนี้แปลงเป็นบอททันที
+ บอทจะค้างในเลนตอน how-to แล้ววิ่งเองเมื่อเกมเริ่ม — emitGauntletSync ส่งบอท takeover ระหว่าง hold อยู่แล้ว) */
+ if (grT && md9b && md9b.gameType === 'gauntlet') {
+ 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 */ }
+ /* MG3–MG7 หลุดช่วง how-to / นับถอยหลัง (ยังไม่มี session live) → เก็บ pending takeover ไว้
+ แล้ว seed เป็นบอทตอนเกมเริ่ม + แจ้ง client เก็บ avatar (*-player-to-bot) เหมือน MG1/MG2
+ (เดิม: ไม่มี session = ไม่ takeover = avatar หายเลยตอน how-to) */
+ let shellTakeoverEvent = null;
+ try {
+ if (!mg1Takeover && !mg2Takeover && !mg3Takeover && !mg4Takeover && !mg5Takeover && !mg6Takeover && !mg7Takeover && space.detectiveMinigameActive) {
+ const gtShell = missionShellGameTypeForSpace(space);
+ const liveActive = !!(
+ (gtShell === 'space_shooter' && space.spaceShooterSession && space.spaceShooterSession.active) ||
+ (gtShell === 'jump_survive' && space.jumpSurviveSession && space.jumpSurviveSession.active) ||
+ (gtShell === 'balloon_boss' && space.balloonBossSession && space.balloonBossSession.active) ||
+ (gtShell === 'quiz_carry' && space.quizCarrySession && space.quizCarrySession.active) ||
+ (gtShell === 'stack' && space.stackSession && space.stackSession.active)
+ );
+ if (gtShell && !liveActive) {
+ recordPendingMissionTakeover(space, gtShell, socket.id, leftPeerInfo, leftPlayerKey, leftNick, leftWasEliminated);
+ shellTakeoverEvent =
+ gtShell === 'space_shooter' ? 'space-shooter-player-to-bot' :
+ gtShell === 'jump_survive' ? 'jump-survive-player-to-bot' :
+ gtShell === 'balloon_boss' ? 'balloon-boss-player-to-bot' :
+ gtShell === 'quiz_carry' ? 'quiz-carry-player-to-bot' :
+ gtShell === 'stack' ? 'stack-player-to-bot' : null;
+ }
+ }
+ } 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.quizCarryWatchdog) { clearInterval(space.quizCarryWatchdog); space.quizCarryWatchdog = 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.pendingMissionTakeovers = []; /* ห้องว่าง → ล้าง pending takeover ค้าง (กัน seed บอทผีรอบหน้า) */
+ 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) {
+ /* host หลุดกลางเปิด wizard เลือกคดี → ปลดล็อกปุ่มพร้อมให้คนที่เหลือ (กัน stuck lock) */
+ if (space.preplayOpen) { space.preplayOpen = false; io.to(sid).emit('lobby-preplay-open', { open: false }); try { glog(sid, space, 'preplay-lock', '[TC154] host หลุดกลางเปิด wizard → auto-ปลดล็อก set-ready (กัน stuck)', leftNick); } catch (e) { /* ignore */ } }
+ 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 if (shellTakeoverEvent) {
+ /* how-to/นับถอยหลัง: client เก็บ avatar + ติดป้าย (บอท) — บอท server จะ seed ตอนเกมเริ่ม (pendingMissionTakeovers) */
+ io.to(sid).emit(shellTakeoverEvent, { id: socket.id, nickname: leftNick });
+ glog(sid, space, 'leave', 'หลุดช่วง how-to → บอทรับช่วงตอนเกมเริ่ม · เหลือคน ' + 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 */ }
+ /* เอา participantKey ออกจาก roster gate ด้วย (playerKey — กัน gate รอคนที่ออกถาวร) */
+ try {
+ if (sp.caseParticipantKeys) {
+ if (leftPlayerKey && /^[a-zA-Z0-9_-]{8,128}$/.test(leftPlayerKey)) sp.caseParticipantKeys.delete('k:' + leftPlayerKey);
+ sp.caseParticipantKeys.delete('n:' + 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', (data) => {
+ 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;
+ const changed = quizCarryServerTryInteract(sid, space, socket.id, false, data);
+ /* กดแล้วไม่เกิดอะไร (หยิบไม่ได้/ส่งไม่ได้) → ยืนยัน held จริงกลับไปให้ "คนกด" เพื่อแก้ optimistic prediction
+ ที่อาจค้างผิด (เช่น ทำนายว่าหยิบได้แต่ server ปฏิเสธเพราะคนอื่นถืออยู่) */
+ if (!changed) {
+ const hp = s.players[socket.id];
+ io.to(socket.id).emit('quiz-carry-held', { id: socket.id, held: hp ? (hp.held == null ? null : hp.held) : null });
+ }
+ });
+
+ /* ตอบเวลา 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));
\ No newline at end of file
diff --git a/www/html/gamelog/data/log.jsonl b/www/html/gamelog/data/log.jsonl
index 8661365..6ef04c4 100644
--- a/www/html/gamelog/data/log.jsonl
+++ b/www/html/gamelog/data/log.jsonl
@@ -16239,3 +16239,146 @@
{"t":1783265946499,"cat":"LobbyB","room":"a-1783265509117","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:21.3,11.2->20.2,11.0 b1:5.4,9.3->5.4,9.3 b2:8.4,8.9->8.4,10.0 b3:19.3,9.2->19.3,9.2 b4:27.9,10.7->27.9,10.7] [+279503ms]","src":"client"}
{"t":1783265950528,"cat":"LobbyB","room":"a-1783265509117","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.4,7.5->8.4,7.5 b1:8.0,10.6->7.1,11.1 b2:24.9,16.9->24.9,16.9 b3:21.0,13.6->20.0,13.3 b4:14.2,6.6->14.9,7.3] [+283534ms]","src":"client"}
{"t":1783265954564,"cat":"LobbyB","room":"a-1783265509117","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:12.4,5.4->12.4,5.4 b1:7.6,9.1->7.6,9.1 b2:22.9,6.2->22.9,6.2 b3:19.0,11.4->19.0,11.4 b4:26.0,8.3->25.9,7.3] [+287567ms]","src":"client"}
+{"t":1783308796514,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"lobby-open","detail":"space=a-1783308793150 [+0ms]","src":"client"}
+{"t":1783308796652,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"preload-start","detail":" [+23ms]","src":"client"}
+{"t":1783308796657,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+21ms]","src":"client"}
+{"t":1783308796738,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"manifest-done","detail":" [+213ms]","src":"client"}
+{"t":1783308796743,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"colors-resolved","detail":" [+213ms]","src":"client"}
+{"t":1783308796803,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"socket-connect","detail":" [+248ms]","src":"client"}
+{"t":1783308796809,"cat":"LobbyA","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 5","src":"server"}
+{"t":1783308798275,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=5 [+1758ms]","src":"client"}
+{"t":1783308798315,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=a-1783308793150 mapName=LobbyA [+1786ms]","src":"client"}
+{"t":1783308798319,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:10.0,14.0->10.9,14.0 b1:16.0,14.0->16.8,13.6 b2:22.0,13.0->21.1,13.2 b3:23.0,8.0->23.4,7.2 b4:15.0,5.0->14.1,5.0] [+1791ms]","src":"client"}
+{"t":1783308799352,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=true [+2851ms]","src":"client"}
+{"t":1783308800993,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"preload-occupants-done","detail":" [+4488ms]","src":"client"}
+{"t":1783308801017,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+4489ms]","src":"client"}
+{"t":1783308801039,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+4489ms]","src":"client"}
+{"t":1783308804980,"cat":"LobbyA","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง (1 คนในห้อง)","src":"server"}
+{"t":1783308808805,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:28.0,13.7->28.0,13.7 b1:20.2,10.2->20.2,10.2 b2:6.4,15.8->6.4,15.8 b3:19.0,4.6->19.0,4.6 b4:6.1,4.1->6.1,4.0] [+12301ms]","src":"client"}
+{"t":1783308808868,"cat":"LobbyB","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"}
+{"t":1783308809327,"cat":"LobbyA","room":"a-1783308793150","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+12816ms]","src":"client"}
+{"t":1783308812129,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+15600ms]","src":"client"}
+{"t":1783308812804,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+16280ms]","src":"client"}
+{"t":1783308812980,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:16.0,13.5->15.1,13.3 b1:14.5,12.2->14.5,12.3 b2:8.1,17.0->8.1,17.0 b3:7.6,16.6->7.6,16.6 b4:11.7,12.8->12.4,13.2] [+16425ms]","src":"client"}
+{"t":1783308813857,"cat":"LobbyB","room":"a","who":"บอท7","ev":"story-gate","detail":"[TC156] อ่าน story จบ 1/1","src":"server"}
+{"t":1783308813858,"cat":"LobbyB","room":"a","who":"","ev":"story-gate","detail":"[TC156] ทุกคนอ่าน story ครบ (1) → ปลดล็อกเลือกผู้ต้องสงสัย (pass)","src":"server"}
+{"t":1783308814305,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+17799ms]","src":"client"}
+{"t":1783308817108,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:5.9,9.8->6.7,10.0 b1:14.4,14.6->15.5,14.2 b2:16.0,14.0->14.9,14.1 b3:7.6,16.6->7.6,16.6 b4:9.2,12.1->10.1,11.7] [+20604ms]","src":"client"}
+{"t":1783308821165,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.7,14.7 b1:15.7,13.7->20.4,12.7 b2:6.3,15.7->9.5,15.1 b3:5.9,15.1->5.8,15.1 b4:20.4,5.9->20.4,5.9] [+24662ms]","src":"client"}
+{"t":1783308825196,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->14.4,8.9 b1:15.7,13.7->8.1,8.0 b2:6.3,15.7->27.6,11.7 b3:5.9,15.1->27.9,13.7 b4:20.4,5.9->7.6,16.6] [+28690ms]","src":"client"}
+{"t":1783308829238,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.4,16.8 b1:15.7,13.7->8.1,4.7 b2:6.3,15.7->16.3,8.5 b3:5.9,15.1->25.5,12.2 b4:20.4,5.9->7.6,16.6] [+32730ms]","src":"client"}
+{"t":1783308833265,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->8.3,13.0 b1:15.7,13.7->7.4,13.6 b2:6.3,15.7->7.8,14.1 b3:5.9,15.1->12.7,12.6 b4:20.4,5.9->14.2,10.9] [+36759ms]","src":"client"}
+{"t":1783308837302,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->24.0,6.0 b1:15.7,13.7->26.3,14.0 b2:6.3,15.7->20.2,9.3 b3:5.9,15.1->16.6,16.7 b4:20.4,5.9->28.0,10.4] [+40796ms]","src":"client"}
+{"t":1783308841334,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->12.1,6.7 b1:15.7,13.7->5.3,9.8 b2:6.3,15.7->23.6,8.5 b3:5.9,15.1->17.8,5.2 b4:20.4,5.9->20.3,5.2] [+44830ms]","src":"client"}
+{"t":1783308845500,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->10.4,7.8 b1:15.7,13.7->5.3,9.5 b2:6.3,15.7->27.9,9.2 b3:5.9,15.1->17.4,8.0 b4:20.4,5.9->11.7,12.3] [+48904ms]","src":"client"}
+{"t":1783308849416,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->8.2,7.5 b1:15.7,13.7->15.2,13.4 b2:6.3,15.7->19.2,9.0 b3:5.9,15.1->21.6,5.3 b4:20.4,5.9->15.1,16.4] [+52912ms]","src":"client"}
+{"t":1783308853447,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->24.0,13.5 b1:15.7,13.7->13.7,5.3 b2:6.3,15.7->22.2,8.3 b3:5.9,15.1->14.3,10.9 b4:20.4,5.9->7.2,13.5] [+56939ms]","src":"client"}
+{"t":1783308857476,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.9,10.4 b1:15.7,13.7->9.3,16.9 b2:6.3,15.7->16.2,13.0 b3:5.9,15.1->13.4,13.1 b4:20.4,5.9->16.0,6.4] [+60968ms]","src":"client"}
+{"t":1783308861518,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->23.7,16.8 b1:15.7,13.7->13.9,5.7 b2:6.3,15.7->23.3,15.1 b3:5.9,15.1->21.9,5.2 b4:20.4,5.9->7.6,10.8] [+65011ms]","src":"client"}
+{"t":1783308865542,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->22.5,16.8 b1:15.7,13.7->13.9,5.5 b2:6.3,15.7->19.0,9.1 b3:5.9,15.1->12.7,11.6 b4:20.4,5.9->28.0,14.4] [+69037ms]","src":"client"}
+{"t":1783308869576,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->16.9,13.3 b1:15.7,13.7->19.0,10.1 b2:6.3,15.7->8.2,8.3 b3:5.9,15.1->20.2,15.7 b4:20.4,5.9->28.3,12.4] [+73071ms]","src":"client"}
+{"t":1783308873618,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.8,4.0 b1:15.7,13.7->26.4,10.6 b2:6.3,15.7->28.8,8.6 b3:5.9,15.1->11.1,12.2 b4:20.4,5.9->19.9,7.3] [+77108ms]","src":"client"}
+{"t":1783308877655,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->26.8,8.7 b1:15.7,13.7->24.6,8.8 b2:6.3,15.7->10.3,15.7 b3:5.9,15.1->19.5,6.1 b4:20.4,5.9->16.3,6.4] [+81148ms]","src":"client"}
+{"t":1783308881690,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->18.1,16.2 b1:15.7,13.7->8.1,8.0 b2:6.3,15.7->13.4,16.7 b3:5.9,15.1->27.6,16.4 b4:20.4,5.9->28.6,8.1] [+85185ms]","src":"client"}
+{"t":1783308885722,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->15.8,16.8 b1:15.7,13.7->13.3,11.2 b2:6.3,15.7->21.2,8.8 b3:5.9,15.1->7.1,14.9 b4:20.4,5.9->19.5,10.9] [+89216ms]","src":"client"}
+{"t":1783308889750,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->15.5,16.4 b1:15.7,13.7->24.5,17.0 b2:6.3,15.7->19.1,10.9 b3:5.9,15.1->7.1,14.8 b4:20.4,5.9->19.4,11.0] [+93246ms]","src":"client"}
+{"t":1783308893787,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.2,11.5 b1:15.7,13.7->24.5,17.0 b2:6.3,15.7->10.0,8.5 b3:5.9,15.1->13.5,9.0 b4:20.4,5.9->9.6,12.4] [+97280ms]","src":"client"}
+{"t":1783308897822,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->7.9,16.8 b1:15.7,13.7->13.9,5.0 b2:6.3,15.7->12.0,10.7 b3:5.9,15.1->19.5,9.0 b4:20.4,5.9->6.5,10.6] [+101315ms]","src":"client"}
+{"t":1783308901902,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->14.6,12.9 b1:15.7,13.7->13.9,5.4 b2:6.3,15.7->14.6,10.6 b3:5.9,15.1->27.8,12.0 b4:20.4,5.9->23.4,8.2] [+105393ms]","src":"client"}
+{"t":1783308905962,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->8.1,5.2 b1:15.7,13.7->13.9,5.5 b2:6.3,15.7->11.4,7.6 b3:5.9,15.1->12.4,5.1 b4:20.4,5.9->27.7,16.1] [+109458ms]","src":"client"}
+{"t":1783308909992,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->17.7,14.9 b1:15.7,13.7->18.9,8.8 b2:6.3,15.7->20.0,14.1 b3:5.9,15.1->12.4,5.1 b4:20.4,5.9->27.7,16.8] [+113487ms]","src":"client"}
+{"t":1783308914022,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->7.7,10.4 b1:15.7,13.7->15.3,8.8 b2:6.3,15.7->9.7,16.8 b3:5.9,15.1->8.3,9.0 b4:20.4,5.9->12.8,5.2] [+117515ms]","src":"client"}
+{"t":1783308918051,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.1,9.5 b1:15.7,13.7->27.3,9.6 b2:6.3,15.7->24.7,13.2 b3:5.9,15.1->16.8,5.4 b4:20.4,5.9->22.2,16.8] [+121546ms]","src":"client"}
+{"t":1783308922088,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->21.5,7.1 b1:15.7,13.7->18.0,8.6 b2:6.3,15.7->27.6,10.1 b3:5.9,15.1->17.9,5.4 b4:20.4,5.9->27.7,16.7] [+125583ms]","src":"client"}
+{"t":1783308926131,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.3,10.0 b1:15.7,13.7->18.0,8.6 b2:6.3,15.7->27.6,10.0 b3:5.9,15.1->15.3,8.7 b4:20.4,5.9->27.7,16.7] [+129622ms]","src":"client"}
+{"t":1783308930159,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.3,10.6 b1:15.7,13.7->27.5,11.7 b2:6.3,15.7->20.1,16.7 b3:5.9,15.1->23.4,7.8 b4:20.4,5.9->28.7,8.4] [+133654ms]","src":"client"}
+{"t":1783308934192,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->8.5,7.5 b1:15.7,13.7->12.4,17.0 b2:6.3,15.7->20.0,11.4 b3:5.9,15.1->6.2,10.6 b4:20.4,5.9->28.7,8.2] [+137687ms]","src":"client"}
+{"t":1783308938234,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->28.0,9.2 b1:15.7,13.7->14.2,16.3 b2:6.3,15.7->15.3,8.8 b3:5.9,15.1->8.8,16.7 b4:20.4,5.9->19.6,16.9] [+141729ms]","src":"client"}
+{"t":1783308942267,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->10.8,6.7 b1:15.7,13.7->27.5,11.4 b2:6.3,15.7->8.6,15.9 b3:5.9,15.1->8.8,16.7 b4:20.4,5.9->19.6,16.9] [+145762ms]","src":"client"}
+{"t":1783308946307,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.6,10.4 b1:15.7,13.7->19.6,9.9 b2:6.3,15.7->8.3,11.5 b3:5.9,15.1->27.3,16.6 b4:20.4,5.9->9.5,5.5] [+149800ms]","src":"client"}
+{"t":1783308950335,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->12.2,7.8 b1:15.7,13.7->20.2,5.0 b2:6.3,15.7->15.0,10.8 b3:5.9,15.1->23.6,6.0 b4:20.4,5.9->12.6,7.0] [+153831ms]","src":"client"}
+{"t":1783308954374,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.3,8.0 b1:15.7,13.7->22.5,16.8 b2:6.3,15.7->23.4,8.9 b3:5.9,15.1->17.0,17.0 b4:20.4,5.9->11.9,16.3] [+157869ms]","src":"client"}
+{"t":1783308958408,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->13.5,15.9 b1:15.7,13.7->26.1,10.1 b2:6.3,15.7->6.5,9.3 b3:5.9,15.1->6.8,17.0 b4:20.4,5.9->28.0,15.0] [+161902ms]","src":"client"}
+{"t":1783308962454,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.4,11.6 b1:15.7,13.7->19.4,10.8 b2:6.3,15.7->14.5,11.4 b3:5.9,15.1->7.6,9.4 b4:20.4,5.9->28.0,15.0] [+165949ms]","src":"client"}
+{"t":1783308966477,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.4,12.7 b1:15.7,13.7->9.9,5.0 b2:6.3,15.7->14.6,14.8 b3:5.9,15.1->9.1,16.8 b4:20.4,5.9->12.1,16.8] [+169971ms]","src":"client"}
+{"t":1783308970523,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->9.0,16.9 b1:15.7,13.7->9.9,5.6 b2:6.3,15.7->18.7,13.3 b3:5.9,15.1->15.8,6.0 b4:20.4,5.9->21.9,5.3] [+174016ms]","src":"client"}
+{"t":1783308974557,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.9,14.5 b1:15.7,13.7->27.1,5.9 b2:6.3,15.7->21.8,13.5 b3:5.9,15.1->20.3,7.2 b4:20.4,5.9->8.7,9.1] [+178048ms]","src":"client"}
+{"t":1783308978590,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.9,14.4 b1:15.7,13.7->7.6,16.7 b2:6.3,15.7->23.9,6.1 b3:5.9,15.1->8.9,6.1 b4:20.4,5.9->8.8,4.3] [+182085ms]","src":"client"}
+{"t":1783308982626,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->9.0,9.4 b1:15.7,13.7->14.9,9.3 b2:6.3,15.7->16.5,6.1 b3:5.9,15.1->5.3,16.9 b4:20.4,5.9->8.8,4.4] [+186122ms]","src":"client"}
+{"t":1783308986657,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->16.2,5.7 b1:15.7,13.7->27.6,9.2 b2:6.3,15.7->13.5,5.7 b3:5.9,15.1->20.2,12.6 b4:20.4,5.9->11.2,6.5] [+190152ms]","src":"client"}
+{"t":1783308990699,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->7.6,11.8 b1:15.7,13.7->16.9,13.4 b2:6.3,15.7->14.6,16.6 b3:5.9,15.1->20.1,13.7 b4:20.4,5.9->14.1,10.9] [+194194ms]","src":"client"}
+{"t":1783308994721,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->7.2,13.9 b1:15.7,13.7->18.1,13.4 b2:6.3,15.7->27.6,16.5 b3:5.9,15.1->18.6,8.9 b4:20.4,5.9->8.3,4.3] [+198215ms]","src":"client"}
+{"t":1783308998756,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->16.1,5.4 b1:15.7,13.7->9.0,7.0 b2:6.3,15.7->19.3,10.5 b3:5.9,15.1->14.7,9.0 b4:20.4,5.9->14.2,9.9] [+202250ms]","src":"client"}
+{"t":1783309002779,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->9.3,16.9 b1:15.7,13.7->9.4,7.1 b2:6.3,15.7->14.0,14.0 b3:5.9,15.1->5.4,9.4 b4:20.4,5.9->8.3,7.8] [+206273ms]","src":"client"}
+{"t":1783309006814,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.1,12.1 b1:15.7,13.7->8.4,6.3 b2:6.3,15.7->18.6,13.3 b3:5.9,15.1->19.2,10.2 b4:20.4,5.9->14.6,10.0] [+210307ms]","src":"client"}
+{"t":1783309010857,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.7,11.5 b1:15.7,13.7->8.4,4.5 b2:6.3,15.7->16.2,15.2 b3:5.9,15.1->26.0,16.8 b4:20.4,5.9->13.6,11.6] [+214353ms]","src":"client"}
+{"t":1783309014882,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->17.1,6.0 b1:15.7,13.7->8.4,4.5 b2:6.3,15.7->6.6,15.4 b3:5.9,15.1->19.6,12.7 b4:20.4,5.9->19.0,6.7] [+218376ms]","src":"client"}
+{"t":1783309018908,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->20.8,9.7 b1:15.7,13.7->14.7,12.9 b2:6.3,15.7->28.7,8.7 b3:5.9,15.1->21.8,11.3 b4:20.4,5.9->16.4,8.8] [+222407ms]","src":"client"}
+{"t":1783309022946,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.4,9.5 b1:15.7,13.7->9.2,12.8 b2:6.3,15.7->19.1,11.5 b3:5.9,15.1->5.2,15.2 b4:20.4,5.9->15.9,8.5] [+226441ms]","src":"client"}
+{"t":1783309026992,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.4,9.7 b1:15.7,13.7->14.5,9.1 b2:6.3,15.7->19.1,11.8 b3:5.9,15.1->19.3,6.2 b4:20.4,5.9->14.7,12.1] [+230485ms]","src":"client"}
+{"t":1783309031024,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.4,10.0 b1:15.7,13.7->27.3,10.2 b2:6.3,15.7->28.7,12.7 b3:5.9,15.1->19.7,6.5 b4:20.4,5.9->28.7,6.7] [+234518ms]","src":"client"}
+{"t":1783309035059,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->19.4,9.8 b1:15.7,13.7->19.3,12.4 b2:6.3,15.7->19.1,11.0 b3:5.9,15.1->23.1,13.3 b4:20.4,5.9->28.4,6.7] [+238555ms]","src":"client"}
+{"t":1783309039175,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->26.4,8.1 b1:15.7,13.7->9.1,7.1 b2:6.3,15.7->11.2,16.4 b3:5.9,15.1->19.3,10.9 b4:20.4,5.9->27.3,5.1] [+242670ms]","src":"client"}
+{"t":1783309043199,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->28.8,12.9 b1:15.7,13.7->14.8,10.5 b2:6.3,15.7->11.0,10.3 b3:5.9,15.1->13.5,5.3 b4:20.4,5.9->25.1,4.6] [+246693ms]","src":"client"}
+{"t":1783309047233,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->9.2,5.4 b1:15.7,13.7->23.0,6.1 b2:6.3,15.7->8.3,5.2 b3:5.9,15.1->27.9,10.8 b4:20.4,5.9->25.1,4.3] [+250727ms]","src":"client"}
+{"t":1783309051271,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->21.8,5.5 b1:15.7,13.7->28.9,7.2 b2:6.3,15.7->8.3,4.4 b3:5.9,15.1->28.5,12.5 b4:20.4,5.9->19.4,12.7] [+254762ms]","src":"client"}
+{"t":1783309055303,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->9.3,5.4 b1:15.7,13.7->25.0,16.5 b2:6.3,15.7->13.3,5.6 b3:5.9,15.1->12.3,6.7 b4:20.4,5.9->27.6,14.4] [+258797ms]","src":"client"}
+{"t":1783309059340,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->24.3,8.6 b1:15.7,13.7->12.5,17.0 b2:6.3,15.7->8.5,7.3 b3:5.9,15.1->27.7,10.1 b4:20.4,5.9->11.1,6.4] [+262834ms]","src":"client"}
+{"t":1783309063377,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->22.4,12.0 b1:15.7,13.7->13.0,17.0 b2:6.3,15.7->13.6,9.2 b3:5.9,15.1->27.7,10.1 b4:20.4,5.9->27.7,16.8] [+266874ms]","src":"client"}
+{"t":1783309067403,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->6.6,17.0 b1:15.7,13.7->9.3,14.6 b2:6.3,15.7->25.0,14.1 b3:5.9,15.1->27.7,10.9 b4:20.4,5.9->10.4,12.0] [+270898ms]","src":"client"}
+{"t":1783309071439,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->14.0,5.7 b1:15.7,13.7->7.5,9.3 b2:6.3,15.7->7.4,14.1 b3:5.9,15.1->20.4,8.9 b4:20.4,5.9->26.9,11.1] [+274935ms]","src":"client"}
+{"t":1783309075465,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->9.4,16.5 b1:15.7,13.7->20.8,14.5 b2:6.3,15.7->24.8,13.4 b3:5.9,15.1->8.4,4.7 b4:20.4,5.9->22.6,6.4] [+278960ms]","src":"client"}
+{"t":1783309079506,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->12.7,14.2 b1:15.7,13.7->17.2,16.7 b2:6.3,15.7->21.9,13.7 b3:5.9,15.1->9.8,14.8 b4:20.4,5.9->17.1,8.5] [+282998ms]","src":"client"}
+{"t":1783309083535,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.5,7.3 b1:15.7,13.7->19.0,10.8 b2:6.3,15.7->27.8,13.0 b3:5.9,15.1->14.8,10.3 b4:20.4,5.9->11.1,14.6] [+287037ms]","src":"client"}
+{"t":1783309087574,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->21.0,16.7 b1:15.7,13.7->19.1,6.5 b2:6.3,15.7->11.2,15.9 b3:5.9,15.1->20.8,15.5 b4:20.4,5.9->12.8,16.7] [+291070ms]","src":"client"}
+{"t":1783309091604,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->15.2,16.9 b1:15.7,13.7->17.9,5.6 b2:6.3,15.7->23.1,14.5 b3:5.9,15.1->7.4,10.7 b4:20.4,5.9->20.6,6.8] [+295101ms]","src":"client"}
+{"t":1783309095637,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->13.7,16.9 b1:15.7,13.7->14.0,10.3 b2:6.3,15.7->7.0,11.8 b3:5.9,15.1->8.2,7.2 b4:20.4,5.9->27.9,16.6] [+299132ms]","src":"client"}
+{"t":1783309099679,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.4,3.2 b1:15.7,13.7->8.6,16.9 b2:6.3,15.7->13.8,5.1 b3:5.9,15.1->7.5,14.8 b4:20.4,5.9->27.9,16.9] [+303175ms]","src":"client"}
+{"t":1783309103720,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.4,4.6 b1:15.7,13.7->19.2,11.7 b2:6.3,15.7->13.8,5.1 b3:5.9,15.1->6.5,9.1 b4:20.4,5.9->27.9,14.4] [+307215ms]","src":"client"}
+{"t":1783309107775,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->24.3,5.5 b1:15.7,13.7->19.2,11.0 b2:6.3,15.7->27.9,15.5 b3:5.9,15.1->9.2,9.0 b4:20.4,5.9->5.2,15.7] [+311260ms]","src":"client"}
+{"t":1783309111819,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->28.6,7.1 b1:15.7,13.7->19.2,12.1 b2:6.3,15.7->27.9,16.4 b3:5.9,15.1->27.7,14.4 b4:20.4,5.9->18.8,13.3] [+315318ms]","src":"client"}
+{"t":1783309115827,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->22.7,8.2 b1:15.7,13.7->17.2,6.3 b2:6.3,15.7->7.8,11.0 b3:5.9,15.1->27.7,14.6 b4:20.4,5.9->28.5,8.6] [+319322ms]","src":"client"}
+{"t":1783309119861,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->8.2,7.1 b1:15.7,13.7->14.8,12.7 b2:6.3,15.7->15.3,13.1 b3:5.9,15.1->19.4,9.2 b4:20.4,5.9->26.2,10.0] [+323359ms]","src":"client"}
+{"t":1783309123896,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.2,5.5 b1:15.7,13.7->17.4,16.5 b2:6.3,15.7->18.1,8.8 b3:5.9,15.1->16.7,5.1 b4:20.4,5.9->8.8,10.0] [+327394ms]","src":"client"}
+{"t":1783309127932,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->7.5,14.5 b1:15.7,13.7->23.7,6.5 b2:6.3,15.7->25.7,8.9 b3:5.9,15.1->7.0,13.8 b4:20.4,5.9->8.9,10.0] [+331430ms]","src":"client"}
+{"t":1783309131975,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.4,8.8 b1:15.7,13.7->8.1,5.8 b2:6.3,15.7->29.0,8.9 b3:5.9,15.1->14.8,9.2 b4:20.4,5.9->16.4,13.1] [+335472ms]","src":"client"}
+{"t":1783309136013,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->28.9,7.5 b1:15.7,13.7->9.7,5.8 b2:6.3,15.7->19.2,12.7 b3:5.9,15.1->26.3,7.2 b4:20.4,5.9->8.1,8.8] [+339510ms]","src":"client"}
+{"t":1783309140099,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->27.6,14.6 b1:15.7,13.7->27.8,9.7 b2:6.3,15.7->18.1,8.9 b3:5.9,15.1->13.5,16.5 b4:20.4,5.9->9.2,11.6] [+343541ms]","src":"client"}
+{"t":1783309144087,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.3,13.9->25.5,9.5 b1:15.7,13.7->16.3,6.4 b2:6.3,15.7->8.4,8.1 b3:5.9,15.1->13.5,16.5 b4:20.4,5.9->18.0,8.1] [+347579ms]","src":"client"}
+{"t":1783309148121,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:19.0,9.2->19.0,9.2 b1:18.5,13.6->19.5,13.5 b2:14.6,12.1->14.6,12.2 b3:16.6,5.1->16.6,5.0 b4:17.8,5.9->17.8,5.9] [+351617ms]","src":"client"}
+{"t":1783309152158,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:19.0,11.5->19.0,11.6 b1:16.9,8.8->16.9,8.8 b2:14.6,10.1->14.6,9.9 b3:14.8,11.6->14.8,11.6 b4:20.0,7.5->20.9,8.1] [+355652ms]","src":"client"}
+{"t":1783309156195,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:7.1,13.6->7.1,13.6 b1:14.8,14.4->15.5,13.9 b2:28.6,6.8->28.6,6.8 b3:14.8,12.0->14.8,12.1 b4:28.0,13.6->28.0,13.6] [+359690ms]","src":"client"}
+{"t":1783309160226,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:7.0,9.3->7.0,9.3 b1:12.3,6.2->11.4,6.1 b2:8.0,6.4->8.0,6.4 b3:28.1,6.0->28.5,6.9 b4:28.0,13.2->28.0,13.2] [+363721ms]","src":"client"}
+{"t":1783309164265,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:11.3,6.9->12.2,6.4 b1:14.9,12.2->14.9,12.2 b2:27.8,5.7->28.1,6.4 b3:27.8,16.2->27.8,16.7 b4:12.8,8.3->13.7,8.8] [+367757ms]","src":"client"}
+{"t":1783309168296,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:19.4,7.4->18.4,7.3 b1:14.1,9.6->13.8,8.6 b2:19.3,11.1->19.2,11.3 b3:13.2,10.4->14.0,9.8 b4:27.7,16.9->27.7,16.9] [+371791ms]","src":"client"}
+{"t":1783309172337,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:23.2,7.0->23.2,7.0 b1:9.2,16.6->9.2,16.6 b2:19.2,12.6->19.2,12.6 b3:16.7,7.2->16.3,7.3 b4:27.6,13.7->27.6,13.7] [+375832ms]","src":"client"}
+{"t":1783309176377,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:7.3,10.3->7.3,10.3 b1:14.6,15.4->15.6,15.2 b2:12.7,11.8->11.9,11.4 b3:16.5,8.9->16.0,8.9 b4:27.6,13.8->27.6,13.8] [+379868ms]","src":"client"}
+{"t":1783309180424,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.2,7.6->8.1,7.1 b1:27.5,10.7->27.5,10.7 b2:7.4,9.2->7.4,9.2 b3:9.8,15.4->9.3,16.2 b4:27.6,13.4->27.6,13.4] [+383918ms]","src":"client"}
+{"t":1783309184454,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.6,4.8->8.6,4.8 b1:13.6,5.1->13.6,5.1 b2:15.2,6.8->16.2,6.5 b3:9.0,14.1->8.0,13.8 b4:27.6,5.3->27.6,5.3] [+387947ms]","src":"client"}
+{"t":1783309188488,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.6,4.9->8.6,4.9 b1:13.6,5.3->13.6,5.3 b2:27.6,16.6->27.6,16.6 b3:10.3,8.9->10.8,8.1 b4:24.4,5.6->24.4,5.6] [+391983ms]","src":"client"}
+{"t":1783309192522,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.1,6.1->8.1,6.1 b1:15.8,16.5->15.8,16.5 b2:26.7,16.6->26.7,16.6 b3:18.4,8.3->19.3,8.1 b4:13.3,5.4->13.7,5.2] [+396015ms]","src":"client"}
+{"t":1783309196560,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:17.3,6.7->18.4,6.8 b1:15.1,13.3->15.0,13.3 b2:26.7,16.6->26.7,16.6 b3:14.9,12.1->14.9,12.3 b4:18.0,8.8->18.0,8.8] [+400053ms]","src":"client"}
+{"t":1783309200595,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:28.6,7.6->28.6,7.6 b1:14.5,6.3->14.5,6.3 b2:10.2,10.8->9.3,11.4 b3:14.9,12.4->14.9,12.4 b4:11.3,14.8->10.7,15.7] [+404089ms]","src":"client"}
+{"t":1783309204633,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:19.5,9.8->19.5,9.8 b1:24.9,16.6->24.9,16.6 b2:8.9,13.6->9.7,13.1 b3:27.9,13.3->27.9,13.3 b4:14.8,12.1->14.9,11.7] [+408127ms]","src":"client"}
+{"t":1783309208665,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:22.2,7.8->23.0,7.3 b1:28.7,12.4->28.7,12.4 b2:14.9,9.3->14.2,9.9 b3:27.8,9.7->27.9,9.5 b4:14.9,10.3->14.9,10.3] [+412161ms]","src":"client"}
+{"t":1783309212702,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:25.3,4.1->25.3,4.1 b1:16.5,5.0->16.2,5.0 b2:19.6,16.9->19.6,16.9 b3:28.9,8.3->28.9,8.3 b4:7.7,10.3->7.7,10.2] [+416196ms]","src":"client"}
+{"t":1783309216729,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:24.3,6.2->23.7,7.0 b1:17.2,5.9->17.8,6.5 b2:21.6,5.7->21.4,6.6 b3:11.5,7.4->11.4,7.5 b4:13.0,16.8->13.0,16.8] [+420223ms]","src":"client"}
+{"t":1783309220760,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:22.9,7.9->21.9,8.2 b1:13.2,13.6->14.2,13.6 b2:18.3,16.5->18.3,16.5 b3:25.2,10.4->25.6,10.7 b4:23.4,12.0->24.4,11.6] [+424256ms]","src":"client"}
+{"t":1783309224803,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:19.4,10.8->19.4,11.7 b1:28.8,12.3->28.8,12.3 b2:22.1,16.6->22.1,16.6 b3:27.9,16.3->27.9,16.3 b4:27.8,9.5->27.8,9.5] [+428295ms]","src":"client"}
+{"t":1783309228846,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:15.0,11.7->15.0,11.8 b1:18.7,6.3->18.6,6.3 b2:16.4,13.8->17.3,14.3 b3:19.3,9.3->19.3,9.3 b4:20.7,15.3->19.7,15.5] [+432337ms]","src":"client"}
+{"t":1783309232879,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:15.0,12.2->15.0,12.2 b1:26.2,6.4->26.2,6.4 b2:7.3,13.5->7.3,13.5 b3:16.5,16.6->16.5,16.6 b4:17.8,7.9->18.8,7.5] [+436372ms]","src":"client"}
+{"t":1783309236916,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:21.5,7.8->22.4,7.6 b1:19.0,6.1->19.0,6.1 b2:15.3,16.9->15.3,16.9 b3:16.8,16.6->17.6,16.5 b4:23.2,16.6->23.2,16.6] [+440411ms]","src":"client"}
+{"t":1783309240950,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.5,7.1->8.5,7.2 b1:7.4,14.1->7.4,14.5 b2:17.8,13.1->18.4,13.1 b3:23.6,17.0->23.6,17.0 b4:9.3,6.2->9.4,5.3] [+444444ms]","src":"client"}
+{"t":1783309244988,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:8.5,7.2->8.5,7.2 b1:5.3,16.4->5.3,16.4 b2:25.4,14.6->25.4,14.6 b3:19.4,6.0->19.9,6.0 b4:9.7,5.3->9.7,5.3] [+448480ms]","src":"client"}
+{"t":1783309249105,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:14.8,11.6->14.8,11.6 b1:8.4,11.3->8.4,11.3 b2:12.4,5.1->12.4,5.1 b3:28.5,8.3->28.5,8.1 b4:8.9,4.2->8.9,4.2] [+452561ms]","src":"client"}
+{"t":1783309253118,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:7.4,13.5->7.4,13.5 b1:27.9,16.3->27.9,16.3 b2:28.1,12.8->28.1,12.8 b3:20.7,12.2->21.1,11.9 b4:8.9,4.2->8.9,4.2] [+456608ms]","src":"client"}
+{"t":1783309257587,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:20.3,14.0->21.3,14.2 b1:27.9,16.3->27.9,16.3 b2:6.0,9.4->6.0,9.4 b3:8.9,7.1->8.4,7.2 b4:14.8,10.2->14.8,10.3] [+460670ms]","src":"client"}
+{"t":1783309261185,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:19.5,11.2->19.4,12.1 b1:26.5,6.2->26.0,6.5 b2:21.3,5.7->21.5,5.4 b3:18.6,7.6->19.4,7.2 b4:19.4,14.9->20.3,15.3] [+464679ms]","src":"client"}
+{"t":1783309265213,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:28.5,12.6->28.5,12.6 b1:12.5,10.2->13.2,9.4 b2:14.9,7.0->15.7,6.3 b3:29.0,8.6->29.0,8.6 b4:19.7,10.8->19.4,10.1] [+468708ms]","src":"client"}
+{"t":1783309268512,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"trial-confirm","detail":"[TC180] กดยืนยันโหวต (แยก select\/confirm) → ส่งจริง [+472005ms]","src":"client"}
+{"t":1783309268507,"cat":"LobbyB","room":"a","who":"","ev":"trial-vote","detail":"[TC180] ยืนยันโหวต 6/6 — ครบ → reveal (pass)","src":"server"}
+{"t":1783309268620,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"trial-reveal-dur","detail":"[TC174] โชว์ผลโหวต 7000ms (admin ปรับได้) ก่อนไปหน้าผลเต็ม [+472114ms]","src":"client"}
+{"t":1783309269252,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:18.3,8.8->18.3,8.8 b1:15.8,16.8->15.8,16.8 b2:17.9,5.7->17.9,5.9 b3:14.2,6.3->13.4,6.0 b4:23.5,15.3->23.5,16.2] [+472746ms]","src":"client"}
+{"t":1783309273375,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:26.1,14.5->26.0,13.6 b1:25.3,8.4->26.0,7.8 b2:21.3,7.0->21.8,7.8 b3:13.9,5.5->13.9,5.5 b4:19.2,9.3->19.2,9.3] [+476777ms]","src":"client"}
+{"t":1783309277336,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:25.1,3.7->25.1,3.7 b1:16.8,6.8->16.7,7.3 b2:27.9,11.5->27.9,11.5 b3:6.0,9.5->6.0,9.5 b4:24.8,12.4->24.3,11.6] [+480819ms]","src":"client"}
+{"t":1783309281564,"cat":"LobbyB","room":"a-1783308793150","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=5 [b0:25.1,4.2->25.1,4.2 b1:15.3,8.9->15.3,8.9 b2:5.1,16.4->5.1,16.5 b3:6.4,10.6->6.4,10.6 b4:15.9,8.9->15.9,8.9] [+485035ms]","src":"client"}