diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index ba8540f..fb3b44d 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -51,9 +51,9 @@ "providerUserId": "p_1775109142385_wq7wfy1p32j", "notes": "auto: player-coins", "blocked": false, - "coins": 2860, + "coins": 2890, "createdAt": "2026-04-02T05:52:21+00:00", - "updatedAt": "2026-07-01T14:37:22+00:00", + "updatedAt": "2026-07-02T03:30:08+00:00", "daily": { "anchorMs": 1781197200000, "claimedDays": [ @@ -67,11 +67,11 @@ ], "lockUntilMs": 0 }, - "score": 786, + "score": 816, "scoreByCase": { "1": 26, - "10": 914, - "8": 296, + "10": 924, + "8": 306, "11": 20, "13": 364, "14": 30, @@ -83,7 +83,7 @@ "7": 14, "3": 150, "6": 44, - "2": 224 + "2": 234 }, "lbName": "บอท7", "achievements": { @@ -1072,15 +1072,15 @@ "providerUserId": "p_1782802776912_g9ua6h1w5dj", "notes": "auto: achievements", "blocked": false, - "coins": 40, + "coins": 56, "achievements": { - "d1_minigame_solver": 4 + "d1_minigame_solver": 6 }, "createdAt": "2026-07-01T06:43:44+00:00", - "updatedAt": "2026-07-01T06:48:17+00:00", - "score": 40, + "updatedAt": "2026-07-01T16:08:23+00:00", + "score": 56, "scoreByCase": { - "1": 40 + "1": 56 }, "lbName": "DevTester" }, @@ -1121,6 +1121,46 @@ ], "lockUntilMs": 1782925200000 } + }, + { + "id": "998017fcbe328e93ad04b2bd", + "email": "", + "displayName": "MONE", + "loginType": "guest", + "providerUserId": "p_1782921490642_wkx6gl2hwtn", + "notes": "auto: game-award", + "blocked": false, + "coins": 24, + "score": 24, + "scoreByCase": { + "1": 24 + }, + "lbName": "MONE", + "createdAt": "2026-07-01T16:01:03+00:00", + "updatedAt": "2026-07-01T16:08:23+00:00", + "achievements": { + "d1_minigame_solver": 3 + } + }, + { + "id": "cc3c865981aa4d36998c8645", + "email": "", + "displayName": "ผู้เล่น", + "loginType": "guest", + "providerUserId": "p_1782921488461_e17kxhzmrl8", + "notes": "auto: game-award", + "blocked": false, + "coins": 18, + "score": 18, + "scoreByCase": { + "1": 18 + }, + "lbName": "ผู้เล่น", + "createdAt": "2026-07-01T16:01:03+00:00", + "updatedAt": "2026-07-01T16:08:23+00:00", + "achievements": { + "d1_minigame_solver": 1 + } } ] } \ No newline at end of file diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json index 410f230..c94980d 100644 --- a/www/html/Game/data/game-timing.json +++ b/www/html/Game/data/game-timing.json @@ -97,7 +97,7 @@ "balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png", "balloonBossBalloonsPerPlayer": 3, "forcedMinigameKeys": [ - "gauntlet", + "quiz_carry", "quiz", "balloon_boss" ], diff --git a/www/html/Game/public/audio/button.wav b/www/html/Game/public/audio/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/button.wav differ diff --git a/www/html/Game/public/audio/lobby/bgm.mp3 b/www/html/Game/public/audio/lobby/bgm.mp3 new file mode 100644 index 0000000..2e7470a Binary files /dev/null and b/www/html/Game/public/audio/lobby/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg1/bgm.mp3 b/www/html/Game/public/audio/mg1/bgm.mp3 new file mode 100644 index 0000000..ee9b6d0 Binary files /dev/null and b/www/html/Game/public/audio/mg1/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg1/button.wav b/www/html/Game/public/audio/mg1/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg1/button.wav differ diff --git a/www/html/Game/public/audio/mg1/correct.mp3 b/www/html/Game/public/audio/mg1/correct.mp3 new file mode 100644 index 0000000..07a23f5 Binary files /dev/null and b/www/html/Game/public/audio/mg1/correct.mp3 differ diff --git a/www/html/Game/public/audio/mg1/endgame.mp3 b/www/html/Game/public/audio/mg1/endgame.mp3 new file mode 100644 index 0000000..093b5ec Binary files /dev/null and b/www/html/Game/public/audio/mg1/endgame.mp3 differ diff --git a/www/html/Game/public/audio/mg1/time.mp3 b/www/html/Game/public/audio/mg1/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg1/time.mp3 differ diff --git a/www/html/Game/public/audio/mg1/wrong.mp3 b/www/html/Game/public/audio/mg1/wrong.mp3 new file mode 100644 index 0000000..afc0304 Binary files /dev/null and b/www/html/Game/public/audio/mg1/wrong.mp3 differ diff --git a/www/html/Game/public/audio/mg2/bgm.mp3 b/www/html/Game/public/audio/mg2/bgm.mp3 new file mode 100644 index 0000000..9369d05 Binary files /dev/null and b/www/html/Game/public/audio/mg2/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg2/button.wav b/www/html/Game/public/audio/mg2/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg2/button.wav differ diff --git a/www/html/Game/public/audio/mg2/gameover.mp3 b/www/html/Game/public/audio/mg2/gameover.mp3 new file mode 100644 index 0000000..7a268ed Binary files /dev/null and b/www/html/Game/public/audio/mg2/gameover.mp3 differ diff --git a/www/html/Game/public/audio/mg2/jump.mp3 b/www/html/Game/public/audio/mg2/jump.mp3 new file mode 100644 index 0000000..5ac13d6 Binary files /dev/null and b/www/html/Game/public/audio/mg2/jump.mp3 differ diff --git a/www/html/Game/public/audio/mg2/time.mp3 b/www/html/Game/public/audio/mg2/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg2/time.mp3 differ diff --git a/www/html/Game/public/audio/mg2/winner.mp3 b/www/html/Game/public/audio/mg2/winner.mp3 new file mode 100644 index 0000000..70a4427 Binary files /dev/null and b/www/html/Game/public/audio/mg2/winner.mp3 differ diff --git a/www/html/Game/public/audio/mg3/bgm.mp3 b/www/html/Game/public/audio/mg3/bgm.mp3 new file mode 100644 index 0000000..3a5a2f6 Binary files /dev/null and b/www/html/Game/public/audio/mg3/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg3/button.wav b/www/html/Game/public/audio/mg3/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg3/button.wav differ diff --git a/www/html/Game/public/audio/mg3/drop-clear.mp3 b/www/html/Game/public/audio/mg3/drop-clear.mp3 new file mode 100644 index 0000000..49cbfca Binary files /dev/null and b/www/html/Game/public/audio/mg3/drop-clear.mp3 differ diff --git a/www/html/Game/public/audio/mg3/drop-fail.mp3 b/www/html/Game/public/audio/mg3/drop-fail.mp3 new file mode 100644 index 0000000..f6485f6 Binary files /dev/null and b/www/html/Game/public/audio/mg3/drop-fail.mp3 differ diff --git a/www/html/Game/public/audio/mg3/drop-hard.mp3 b/www/html/Game/public/audio/mg3/drop-hard.mp3 new file mode 100644 index 0000000..b5abfdc Binary files /dev/null and b/www/html/Game/public/audio/mg3/drop-hard.mp3 differ diff --git a/www/html/Game/public/audio/mg3/endgame.mp3 b/www/html/Game/public/audio/mg3/endgame.mp3 new file mode 100644 index 0000000..093b5ec Binary files /dev/null and b/www/html/Game/public/audio/mg3/endgame.mp3 differ diff --git a/www/html/Game/public/audio/mg3/gameover.mp3 b/www/html/Game/public/audio/mg3/gameover.mp3 new file mode 100644 index 0000000..7a268ed Binary files /dev/null and b/www/html/Game/public/audio/mg3/gameover.mp3 differ diff --git a/www/html/Game/public/audio/mg3/time.mp3 b/www/html/Game/public/audio/mg3/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg3/time.mp3 differ diff --git a/www/html/Game/public/audio/mg3/timeup.mp3 b/www/html/Game/public/audio/mg3/timeup.mp3 new file mode 100644 index 0000000..d90b15e Binary files /dev/null and b/www/html/Game/public/audio/mg3/timeup.mp3 differ diff --git a/www/html/Game/public/audio/mg4/bgm.mp3 b/www/html/Game/public/audio/mg4/bgm.mp3 new file mode 100644 index 0000000..2e7470a Binary files /dev/null and b/www/html/Game/public/audio/mg4/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg4/button.wav b/www/html/Game/public/audio/mg4/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg4/button.wav differ diff --git a/www/html/Game/public/audio/mg4/correct.mp3 b/www/html/Game/public/audio/mg4/correct.mp3 new file mode 100644 index 0000000..07a23f5 Binary files /dev/null and b/www/html/Game/public/audio/mg4/correct.mp3 differ diff --git a/www/html/Game/public/audio/mg4/endgame.mp3 b/www/html/Game/public/audio/mg4/endgame.mp3 new file mode 100644 index 0000000..093b5ec Binary files /dev/null and b/www/html/Game/public/audio/mg4/endgame.mp3 differ diff --git a/www/html/Game/public/audio/mg4/gameover.mp3 b/www/html/Game/public/audio/mg4/gameover.mp3 new file mode 100644 index 0000000..7a268ed Binary files /dev/null and b/www/html/Game/public/audio/mg4/gameover.mp3 differ diff --git a/www/html/Game/public/audio/mg4/time.mp3 b/www/html/Game/public/audio/mg4/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg4/time.mp3 differ diff --git a/www/html/Game/public/audio/mg4/timeup.mp3 b/www/html/Game/public/audio/mg4/timeup.mp3 new file mode 100644 index 0000000..d90b15e Binary files /dev/null and b/www/html/Game/public/audio/mg4/timeup.mp3 differ diff --git a/www/html/Game/public/audio/mg4/wrong.mp3 b/www/html/Game/public/audio/mg4/wrong.mp3 new file mode 100644 index 0000000..afc0304 Binary files /dev/null and b/www/html/Game/public/audio/mg4/wrong.mp3 differ diff --git a/www/html/Game/public/audio/mg5/bgm.mp3 b/www/html/Game/public/audio/mg5/bgm.mp3 new file mode 100644 index 0000000..e2ac7a9 Binary files /dev/null and b/www/html/Game/public/audio/mg5/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg5/button.wav b/www/html/Game/public/audio/mg5/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg5/button.wav differ diff --git a/www/html/Game/public/audio/mg5/endgame.mp3 b/www/html/Game/public/audio/mg5/endgame.mp3 new file mode 100644 index 0000000..093b5ec Binary files /dev/null and b/www/html/Game/public/audio/mg5/endgame.mp3 differ diff --git a/www/html/Game/public/audio/mg5/gameover.mp3 b/www/html/Game/public/audio/mg5/gameover.mp3 new file mode 100644 index 0000000..7a268ed Binary files /dev/null and b/www/html/Game/public/audio/mg5/gameover.mp3 differ diff --git a/www/html/Game/public/audio/mg5/jump.mp3 b/www/html/Game/public/audio/mg5/jump.mp3 new file mode 100644 index 0000000..5ac13d6 Binary files /dev/null and b/www/html/Game/public/audio/mg5/jump.mp3 differ diff --git a/www/html/Game/public/audio/mg5/time.mp3 b/www/html/Game/public/audio/mg5/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg5/time.mp3 differ diff --git a/www/html/Game/public/audio/mg5/timeup.mp3 b/www/html/Game/public/audio/mg5/timeup.mp3 new file mode 100644 index 0000000..d90b15e Binary files /dev/null and b/www/html/Game/public/audio/mg5/timeup.mp3 differ diff --git a/www/html/Game/public/audio/mg6/bgm.mp3 b/www/html/Game/public/audio/mg6/bgm.mp3 new file mode 100644 index 0000000..9306a52 Binary files /dev/null and b/www/html/Game/public/audio/mg6/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg6/bomb.mp3 b/www/html/Game/public/audio/mg6/bomb.mp3 new file mode 100644 index 0000000..2b9a15e Binary files /dev/null and b/www/html/Game/public/audio/mg6/bomb.mp3 differ diff --git a/www/html/Game/public/audio/mg6/button.wav b/www/html/Game/public/audio/mg6/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg6/button.wav differ diff --git a/www/html/Game/public/audio/mg6/crash.mp3 b/www/html/Game/public/audio/mg6/crash.mp3 new file mode 100644 index 0000000..6fda721 Binary files /dev/null and b/www/html/Game/public/audio/mg6/crash.mp3 differ diff --git a/www/html/Game/public/audio/mg6/endgame.mp3 b/www/html/Game/public/audio/mg6/endgame.mp3 new file mode 100644 index 0000000..093b5ec Binary files /dev/null and b/www/html/Game/public/audio/mg6/endgame.mp3 differ diff --git a/www/html/Game/public/audio/mg6/gameover.mp3 b/www/html/Game/public/audio/mg6/gameover.mp3 new file mode 100644 index 0000000..7a268ed Binary files /dev/null and b/www/html/Game/public/audio/mg6/gameover.mp3 differ diff --git a/www/html/Game/public/audio/mg6/laser.mp3 b/www/html/Game/public/audio/mg6/laser.mp3 new file mode 100644 index 0000000..9a0ea7e Binary files /dev/null and b/www/html/Game/public/audio/mg6/laser.mp3 differ diff --git a/www/html/Game/public/audio/mg6/time.mp3 b/www/html/Game/public/audio/mg6/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg6/time.mp3 differ diff --git a/www/html/Game/public/audio/mg6/timeup.mp3 b/www/html/Game/public/audio/mg6/timeup.mp3 new file mode 100644 index 0000000..d90b15e Binary files /dev/null and b/www/html/Game/public/audio/mg6/timeup.mp3 differ diff --git a/www/html/Game/public/audio/mg7/bgm.mp3 b/www/html/Game/public/audio/mg7/bgm.mp3 new file mode 100644 index 0000000..f57afc2 Binary files /dev/null and b/www/html/Game/public/audio/mg7/bgm.mp3 differ diff --git a/www/html/Game/public/audio/mg7/boss-elim.mp3 b/www/html/Game/public/audio/mg7/boss-elim.mp3 new file mode 100644 index 0000000..ddd2456 Binary files /dev/null and b/www/html/Game/public/audio/mg7/boss-elim.mp3 differ diff --git a/www/html/Game/public/audio/mg7/button.wav b/www/html/Game/public/audio/mg7/button.wav new file mode 100644 index 0000000..16cf8dd Binary files /dev/null and b/www/html/Game/public/audio/mg7/button.wav differ diff --git a/www/html/Game/public/audio/mg7/endgame.mp3 b/www/html/Game/public/audio/mg7/endgame.mp3 new file mode 100644 index 0000000..093b5ec Binary files /dev/null and b/www/html/Game/public/audio/mg7/endgame.mp3 differ diff --git a/www/html/Game/public/audio/mg7/gameover.mp3 b/www/html/Game/public/audio/mg7/gameover.mp3 new file mode 100644 index 0000000..7a268ed Binary files /dev/null and b/www/html/Game/public/audio/mg7/gameover.mp3 differ diff --git a/www/html/Game/public/audio/mg7/laser.mp3 b/www/html/Game/public/audio/mg7/laser.mp3 new file mode 100644 index 0000000..cb468b0 Binary files /dev/null and b/www/html/Game/public/audio/mg7/laser.mp3 differ diff --git a/www/html/Game/public/audio/mg7/time.mp3 b/www/html/Game/public/audio/mg7/time.mp3 new file mode 100644 index 0000000..f6c6e22 Binary files /dev/null and b/www/html/Game/public/audio/mg7/time.mp3 differ diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 62ecf91..3423937 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -4136,6 +4136,7 @@ function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) { const urls = playSpaceShooterAsteroidSpriteUrls; if (!urls || urls.length < 2) return; + if (window.jdSound) { try { window.jdSound.playSfx('bomb', 'mg6'); } catch (e) { /* ignore */ } } spaceShooterAsteroidExplosions.push({ x: worldX, y: worldY, @@ -4296,6 +4297,7 @@ } } if (!mine) return; + if (window.jdSound) { try { window.jdSound.playSfx(mine.right ? 'correct' : 'wrong', 'mg1'); } catch (e) { /* ignore */ } } el.classList.remove('is-hidden'); const botExtra = playBotsEnabled() && (botRight + botWrong > 0) ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)' @@ -4455,6 +4457,7 @@ const scoreEl = document.getElementById('quiz-map-q-feedback-score'); if (!panel || !icon || panel.classList.contains('is-hidden')) return; clearQuizMapQuestionCarryFeedback(); + if (window.jdSound) { try { window.jdSound.playSfx(isCorrect ? 'correct' : 'wrong', 'mg4'); } catch (e) { /* ignore */ } } icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect; icon.classList.remove('is-hidden'); if (isCorrect && scoreWrap && scoreEl) { @@ -4905,6 +4908,8 @@ function setCountdown321QuestionAssetGraphic(numEl, n) { if (!numEl) return; const d = Math.max(1, Math.min(3, n)); + /* เสียงนับถอยหลัง (เล่นครั้งเดียวต่อเลข กัน re-render ยิงซ้ำ) */ + if (window.jdSound && window.__jdLastCountN !== d) { window.__jdLastCountN = d; try { window.jdSound.playSfx('time'); } catch (e) { /* ignore */ } } if (String(numEl.tagName || '').toUpperCase() === 'IMG') { numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png'; numEl.alt = String(d); @@ -6500,6 +6505,24 @@ } function isFrogger() { return mapData && mapData.gameType === 'frogger'; } + /* ===== Sound (jdSound) — scene + end-sfx helpers ===== */ + var JD_SCENE_MAP = { quiz: 'mg1', gauntlet: 'mg2', stack: 'mg3', quiz_carry: 'mg4', jump_survive: 'mg5', space_shooter: 'mg6', balloon_boss: 'mg7' }; + function jdPlayScene() { var gt = mapData && mapData.gameType; return (gt && JD_SCENE_MAP[gt]) || 'mg1'; } + /* เล่นเสียงจบเกมจากชื่อไฟล์แฟลชผล (complete/victory=ชนะ · timeup=หมดเวลา · gameover=แพ้) + หยุด BGM */ + function jdEndSfxFromFile(f) { + if (!window.jdSound) return; + f = String(f || ''); var s = jdPlayScene(); + if (f.indexOf('victory') >= 0 || f.indexOf('complete') >= 0) window.jdSound.playSfx('win', s); + else if (f.indexOf('timeup') >= 0) window.jdSound.playSfx('timeup', s); + else if (f.indexOf('gameover') >= 0) window.jdSound.playSfx('lose', s); + try { window.jdSound.stopBgm(); } catch (e) { /* ignore */ } + } + /* เสียงคลิกปุ่ม (เฉพาะ element ที่เป็นปุ่มจริง) */ + document.addEventListener('click', function (e) { + if (!window.jdSound) return; + var t = (e.target && e.target.closest) ? e.target.closest('button, .btn, [role="button"], [class*="btn-"]') : null; + if (t) { try { window.jdSound.playSfx('button', jdPlayScene()); } catch (err) { /* ignore */ } } + }, true); function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; } function isGauntletFaceRightMapMno9kb07() { if (!isGauntlet()) return false; @@ -7306,6 +7329,7 @@ if (!mission || !ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); }; + jdEndSfxFromFile(file); imgEl.src = gcmMissionMockAssetUrl(mission, file); ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); @@ -7339,6 +7363,7 @@ imgEl.onerror = null; finishToGcm(); }; + jdEndSfxFromFile(file); imgEl.src = url; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); @@ -7381,6 +7406,7 @@ imgEl.onerror = null; onDone(); }; + jdEndSfxFromFile(file); imgEl.src = jumperAssetUrl(file); ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); @@ -7425,6 +7451,7 @@ imgEl.onerror = null; onDone(); }; + jdEndSfxFromFile(file); imgEl.src = violentCrimeAssetUrl(file); ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); @@ -7468,12 +7495,14 @@ }; const showOneFlash = function (url, onDone) { imgEl.onerror = function () { imgEl.onerror = null; onDone(); }; + jdEndSfxFromFile(url); imgEl.src = url; ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS); }; if (endReason === 'victory') { + if (window.jdSound) { try { window.jdSound.playSfx('boss-elim', 'mg7'); } catch (e) { /* ignore */ } } /* ฆ่าบอส HP=0 → end-victory-2.png (MegaVirus) */ showOneFlash(megaVirusAssetUrl('end-victory-2.png'), finishToGcm); } else if (endReason === 'time') { @@ -11293,6 +11322,7 @@ if ((meGauntletJumpTicks || 0) > 0 || isMeGauntletJumpAnimActivePlay()) return false; lastGauntletJumpKey = now; socket.emit('gauntlet-jump'); + if (window.jdSound) window.jdSound.playSfx('jump', 'mg2'); /* เริ่มอาร์คกระโดด local ทันที (instant feedback) — ยาวเท่าจังหวะกระโดดของ server (jumpTicks×tickMs) เพื่อใ��้เห็นโค้งขึ้น-ลงเต็ม แม้ server จะตัด jumpTicks=0 กลางคันตอนข้ามสำเร็จ */ meGauntletJumpAnimDur = Math.max(360, (gauntletRuntimeJumpTicks || 6) * (gauntletRuntimeTickMs || 220)); @@ -12511,6 +12541,7 @@ const m = stackMini; const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat(); const actorHeavy = !!m.pendingDropHeavy; + if (window.jdSound) { try { window.jdSound.playSfx(hit.miss ? 'drop-fail' : (actorHeavy ? 'drop-hard' : 'drop-clear'), 'mg3'); } catch (e) { /* ignore */ } } const actorTheme = m.pendingDropTheme || null; /* remote drop = สีจาก host; local = null (resolve เอง) */ const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay(); /* release = จุดที่ server ปล่อย (landOffsetTiles) → ร่วง "ตรง" ไม่ไถลเฉียง (เฟส server-time sync แล้ว จุดที่เห็น≈จุดนี้ → teleport เหลือนิดเดียว ถูกบังด้วยการเริ่มร่วง) @@ -17323,6 +17354,7 @@ /* broadcast กระสุนให้ทุกคนเห็นตรงกัน */ try { if (socket && myId != null) socket.emit('space-shooter-shot', { x: shotX, y: shotY, vy: -580, ownerId: myId, t0: serverNow() }); + if (window.jdSound) window.jdSound.playSfx('laser', 'mg6'); } catch (eS) { /* ignore */ } } @@ -18408,6 +18440,7 @@ try { if (socket && myId != null) { socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow() }); + if (window.jdSound) window.jdSound.playSfx('laser', 'mg7'); } } catch (eS) { /* ignore */ } /* สเปก MG7: ยิง 1 ครั้ง = −5 คะแนน (ต่ำสุด 0 ไม่ติดลบ) */ @@ -19694,6 +19727,7 @@ const jq = jumpSurviveJumpQueued; jumpSurviveJumpQueued = false; + if (jq && jumpSurviveOnGround && window.jdSound) { try { window.jdSound.playSfx('jump', 'mg5'); } catch (e) { /* ignore */ } } /* virtual joystick (touch) — แกน x ป้อนซ้าย/ขวาเหมือนคีย์ A/D (deadzone จัดการในตัว update แล้ว) */ const joyX = isJumpSurviveMissionHudActivePlay() ? (Number(quizQuestionMissionJoyVecX) || 0) : 0; @@ -21848,7 +21882,7 @@ voiceAnalyser.getByteFrequencyData(dataArray); var sum = 0; for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; var level = Math.min(1, (sum / dataArray.length) / 60); - if (level > 0.08 && Date.now() - lastEmit > 80) { lastEmit = Date.now(); socket.emit('voice-activity', { level: level }); } + if (level > 0.08 && Date.now() - lastEmit > 80) { lastEmit = Date.now(); socket.emit('voice-activity', { level: level }); if (window.jdSound) window.jdSound.notifySpeaking(); } }, 100); } catch (e) { /* ignore */ } } @@ -26452,6 +26486,8 @@ } function tick() { if (!mapData) { playTickLastTs = 0; requestAnimationFrame(tick); return; } + /* BGM ต่อมินิเกม (idempotent — playBgm return เร็วถ้า scene เดิม) */ + if (window.jdSound) { try { window.jdSound.playBgm(jdPlayScene()); } catch (e) { /* ignore */ } } /* dt frame (วินาที) → playDtMul = dt*60 (clamp 0.05s = step ≤0.45 tile กัน tunneling) ใช้ทำ movement time-based */ { const _pnow = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); diff --git a/www/html/Game/public/js/play.js.bak-sfx2 b/www/html/Game/public/js/play.js.bak-sfx2 new file mode 100644 index 0000000..f5136a1 --- /dev/null +++ b/www/html/Game/public/js/play.js.bak-sfx2 @@ -0,0 +1,27205 @@ +(function () { + const BASE = '/Game'; + function playDesignMainMenuHref() { + try { + if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') { + return globalThis.designMainMenuHref(); + } + } catch (e) { /* ignore */ } + return '/Main-Menu/'; + } + function playDesignMainLobbyHref() { + try { + if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') { + return globalThis.designMainLobbyHref(); + } + } catch (e) { /* ignore */ } + return '/Main-Lobby/'; + } + /** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */ + const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/'; + /** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */ + const QUIZ_CARRY_MAP_FEEDBACK_IMG = { + correct: BASE + '/img/quiz-carry/icon-Correct.png', + incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png', + scorePlus: BASE + '/img/quiz-carry/score+10.png', + }; + const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400; + const params = new URLSearchParams(window.location.search); + const spaceId = params.get('space'); + function readStoredDisplayName() { + try { + return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim(); + } catch (e) { return ''; } + } + let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น'; + const previewMode = params.get('preview') === '1'; + /* Card 3 Ban — ผู้ถูกแบนรอบนี้: เข้ามาดูเพื่อนเล่นได้ แต่ควบคุมตัวละคร/ทำแอ็กชันไม่ได้ (รอบหน้าเล่นปกติ) */ + const playBannedSpectator = params.get('banned') === '1'; + /* Card 3 Ban (กรณีโหวตแบน "บอท") — slot ของ __pv_bot_N ที่ถูกแบน → ไม่ spawn ลงเล่นรอบนี้ (-1 = ไม่มี) */ + const playBannedBotSlot = (() => { const v = parseInt(params.get('banBot'), 10); return Number.isFinite(v) && v >= 0 ? v : -1; })(); + /* shield โปร่งใสคลุมทั้งจอ (z 200) กิน pointer/touch ทุกอย่าง → ปุ่มเกม(jump z59/drop z60/joystick/shoot)+canvas กดไม่ได้ + แต่ต่ำกว่า modal (z 10000+) เลย special quiz / สรุปผล ยังกดได้ปกติ + ป้ายบอกสถานะด้านบน */ + function setupBannedSpectatorUi() { + if (!playBannedSpectator || document.getElementById('banned-spectator-shield')) return; + const shield = document.createElement('div'); + shield.id = 'banned-spectator-shield'; + shield.style.cssText = 'position:fixed;inset:0;z-index:200;background:transparent;pointer-events:auto;cursor:not-allowed;touch-action:none;-webkit-tap-highlight-color:transparent'; + ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'touchstart', 'touchend', 'touchmove', 'wheel', 'contextmenu'].forEach(function (ev) { + shield.addEventListener(ev, function (e) { e.stopPropagation(); e.preventDefault(); }, { passive: false }); + }); + document.body.appendChild(shield); + const banner = document.createElement('div'); + banner.id = 'banned-spectator-banner'; + banner.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:201;pointer-events:none;background:linear-gradient(180deg,#2a1430,#160a1d);border:1px solid rgba(247,118,142,.6);border-radius:14px;padding:10px 20px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,.55)'; + banner.innerHTML = '
BAN CARD
' + + '
คุณถูกแบนรอบนี้ · กำลังดูเพื่อนเล่น
' + + '
รอบหน้ากลับมาเล่นได้ตามปกติ
'; + document.body.appendChild(banner); + } + const specialQuizForceDebug = params.get('sqForce') === '1'; + const forceDefaultCharacter = params.get('defaultChar') === '1'; + const editorEmbedReturn = params.get('editorEmbed') === '1'; + if (previewMode && editorEmbedReturn) { + try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ } + } + + /* #5 หน้าโหลดตอนเข้าเกม — กันเห็นจอเปล่า/asset แว่บก่อนฉากพร้อม. โชว์ทันที ซ่อนเมื่อ draw() เฟรมแรกมี mapData + (+ หน่วงเล็กน้อยให้ asset แรกโหลด) มี safety timeout กันค้าง. ไม่โชว์ใน editor embed preview */ + let playLoadingHidden = false; + let playLoadingHideScheduled = false; + const PLAY_LOADING_MIN_MS = 450; /* โชว์อย่างน้อยเท่านี้ กันแว่บหาย (ไม่ทันเห็น) */ + let playLoadingShownAt = (typeof performance !== 'undefined' ? performance.now() : Date.now()); + function nowMsPlay() { return (typeof performance !== 'undefined' ? performance.now() : Date.now()); } + function hidePlayLoadingOverlay() { + if (playLoadingHidden) return; + const elapsed = nowMsPlay() - playLoadingShownAt; + if (elapsed < PLAY_LOADING_MIN_MS) { setTimeout(hidePlayLoadingOverlay, PLAY_LOADING_MIN_MS - elapsed); return; } + playLoadingHidden = true; + try { + const el = document.getElementById('play-loading-overlay'); + if (el) { el.style.opacity = '0'; el.style.pointerEvents = 'none'; setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 340); } + } catch (_e) { /* ignore */ } + } + (function showPlayLoadingOverlay() { + /* editor embed preview: ไม่ต้องมี loading → เอา overlay static ออก */ + if (previewMode && editorEmbedReturn) { + playLoadingHidden = true; + try { const pe = document.getElementById('play-loading-overlay'); if (pe && pe.parentNode) pe.parentNode.removeChild(pe); } catch (_e) {} + return; + } + try { + if (!document.body) return; + /* overlay เป็น static ใน play.html แล้ว — ถ้าไม่มี (html เก่า cache) ค่อย inject เสริม */ + if (!document.getElementById('play-loading-overlay')) { + const css = document.createElement('style'); + css.textContent = '' + + '#play-loading-overlay{position:fixed;inset:0;z-index:99990;display:flex;flex-direction:column;align-items:center;' + + 'justify-content:center;gap:18px;background:radial-gradient(circle at 50% 42%,#10203c,#060b16 75%);transition:opacity .32s;}' + + '#play-loading-overlay .plo-spin{width:62px;height:62px;border-radius:50%;border:6px solid rgba(120,190,255,.18);' + + 'border-top-color:#5fd0ff;animation:ploSpin .85s linear infinite;}' + + '#play-loading-overlay .plo-text{color:#cfe8ff;font:600 19px system-ui,"Kanit",sans-serif;letter-spacing:.5px;text-shadow:0 2px 10px rgba(0,0,0,.5);}' + + '@keyframes ploSpin{to{transform:rotate(360deg);}}'; + document.head.appendChild(css); + const ov = document.createElement('div'); + ov.id = 'play-loading-overlay'; + ov.innerHTML = '
กำลังโหลดเกม…
'; + document.body.insertBefore(ov, document.body.firstChild); + } + /* safety กันค้าง — แต่ "อย่าซ่อนถ้าเกมยังไม่ render" (เน็ตช้า join นาน → ถ้าซ่อน 8 วิ = จอดำเปล่า ไม่รู้สถานะ) + เช็คทุก 1.5 วิ: เกมเริ่มวาดแล้ว (playLoadingHideScheduled) → ปล่อย path ปกติซ่อน · ยังไม่วาด+ช้า → โชว์ "เน็ตช้า (N วิ)" คง spinner · เกิน 30 วิ ค่อยซ่อนกันค้างถาวร */ + const _ploT0 = Date.now(); + const _ploCheck = function () { + if (playLoadingHidden || playLoadingHideScheduled) return; /* ซ่อนแล้ว/เกมเริ่มวาดแล้ว */ + const waited = Date.now() - _ploT0; + if (waited > 30000) { hidePlayLoadingOverlay(); return; } /* hard limit กันค้างถาวร */ + if (waited > 5000) { + const txt = document.querySelector('#play-loading-overlay .plo-text'); + if (txt) txt.textContent = 'เน็ตช้า… กำลังโหลด (' + Math.floor(waited / 1000) + ' วิ)'; + } + setTimeout(_ploCheck, 1500); + }; + setTimeout(_ploCheck, 1500); + } catch (_e) { playLoadingHidden = true; } + })(); + const playMapIdFromQuery = (params.get('map') || '').trim(); + /** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */ + let playSessionMapId = playMapIdFromQuery; + /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */ + const quizCarryMissionSummaryMapId = 'mnorwqx1'; + /* ใช้ map จริง (currentPlayMapId) ไม่ใช่ ?map= ใน URL — live play ไม่มี ?map= → เดิม false → + mission flow ไม่ทำงาน → MG4 จบหลังตอบ 1 ข้อ (bug #41). เป็นฟังก์ชันเพราะ map รู้ตอน join */ + function quizCarryUseMissionSummaryOverlay() { return currentPlayMapId() === quizCarryMissionSummaryMapId; } + /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */ + const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07'; + /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */ + const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; + /** Space shooter ฉาก Violent Crime — flow เดียวกับ crown/howto (รูปใน /img/ViolentCrime/) */ + const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp'; + /** Quiz ฉากสอบสวน — flow เดียวกับ crown/howto แต่รูปใน /img/QUESTION/ */ + const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o'; + /** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */ + const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; + /** Stack Tower: บัฟเฟอร์วาดคงที่ 16:9 — 1920×1080 */ + const STACK_TOWER_FIXED_RENDER_W = 1920; + const STACK_TOWER_FIXED_RENDER_H = 1080; + /** คูณ zDraw ให้มุมมองโลกเท่าเดิมเมื่อบัฟเฟอร์กว้างกว่าฐานอ้างอิง 1280w (= 1.5 ที่ 1920w) */ + const STACK_TOWER_FIXED_RENDER_Z_MUL = STACK_TOWER_FIXED_RENDER_W / 1280; + /** เชือก Stack Tower: ปลายล่างตามเส้น (1 = ถึงจุดยึดบล็อก) */ + const STACK_TOWER_ROPE_DRAW_T1 = 1.0; + /** ปลายบนเชือก (จอ): ยืดเหนือขอบบนแคนวาสเป็นเศษส่วนของความสูง (เช่น 0.25 = 25% นอกจอด้านบน) */ + const STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC = 0.25; + /** progress ≥ นี้ (ฉาก Tower mnn93hpi): รูป heavy + ความกว้าง heavy = เท่าเดียวกับปกติ × ค่าด้านล่าง + แอนิเมตซูม/เลื่อน */ + const STACK_TOWER_POST50_PROGRESS_THRESH = 60; + const STACK_TOWER_POST50_ZOOM_MUL = 1.1; + /** เลื่อน BG แนวตั้งเท่าเศษส่วนของความสูงแคนวาส (จอ) */ + const STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC = 0.1; + const STACK_TOWER_POST50_ANIM_MS = 680; + /** แมป mnn93hpi: สเกลความกว้าง/ความสูงชั้นของบล็อกในโลก (1 = ขนาดเดิม) */ + const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5; + /** หลังเกณฑ์ progress (Tower): เลื่อนกล้องลงในมุมมองจอเท่าเศษส่วนของความสูงแคนวาส (เช่น 0.25 = 25% ของความสูงจอ) */ + const STACK_TOWER_POST50_VIEW_SHIFT_SCREEN_FRAC = 0.25; + /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */ + const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; + /** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */ + const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9; + /** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */ + 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; /* ต้องตรงกับ server — miss เมื่อ overlap < 50% บล็อก */ + /** จุดกลางแท่งที่วาง vs กลางโซน land — เกิน (ความกว้างโซน × นี้) = พลาด (ชั้นที่ 2 ขึ้นไป) */ + const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38; + /** ไม่มี stackLandArea: ใช้กลางแมป — เยื้องได้ไม่เกิน fallW × นี้ (รวม) */ + const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0; + /** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */ + const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62; + /** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */ + const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314; + /** Tower live + progress ≥เกณฑ์ + heavy: ความกว้างบล็อก = ปกติ × ค่านี้ (เช่น 0.6 = 60% ของช่องปกติ — รูปใหญ่วาดในกรอบเดียวกัน) */ + const STACK_TOWER_POST50_WIDTH_MULT = 0.6; + + function stackBlockWidthTilesForPlay(baseTiles, heavy) { + const b = Number(baseTiles); + const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2; + const towerHeavyNarrow = + !!heavy && + isStackTowerMissionUiMapPlay() && + stackTowerMissionPhase === 'live' && + stackMini && + Number.isFinite(Number(stackMini.progressPct)) && + Number(stackMini.progressPct) >= STACK_TOWER_POST50_PROGRESS_THRESH; + if (towerHeavyNarrow) { + const w = base * STACK_TOWER_POST50_WIDTH_MULT; + return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200)); + } + if (!heavy) return base; + const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT; + return Math.min(3.2, Math.round(w * 200) / 200); + } + /** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */ + const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3; + const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400; + const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12; + /** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */ + const SPACE_SHOOTER_ASTEROID_MAX_HP = 5; + const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 34; + const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 8; + + /* PRNG แบบ seed ได้ (mulberry32) — ใช้สุ่มสิ่งแวดล้อมมินิเกมแบบ deterministic + seed เดียวกัน (อิง liveBeginsAt ของ server) → ทุกเครื่องได้ลำดับสุ่มเดียวกัน = อุกาบาต/บอลลูนชุดเดียวกัน */ + function makeSeededRng(seed) { + let t = (seed >>> 0) || 1; + return function () { + t = (t + 0x6D2B79F5) >>> 0; + let r = Math.imul(t ^ (t >>> 15), 1 | t); + r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; + } + /* seed ต่อ index ของ spawn — รวม anchor (liveBeginsAt) กับ index ให้ก้อนแต่ละลำดับสุ่มต่างกันแต่ตรงกันทุกเครื่อง */ + function envSpawnSeed(anchorMs, index) { + return (((anchorMs >>> 0) ^ Math.imul((index | 0) + 1, 2654435761)) >>> 0); + } + + function spaceShooterAsteroidRadiusFromHpPlay(hp) { + const cap = SPACE_SHOOTER_ASTEROID_MAX_HP; + const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0)); + const frac = h <= 0 ? 0 : h / cap; + return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP + + (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac; + } + + function spaceShooterShipFootprintCellsPlay(md) { + const m = md || mapData; + if (!m) return { cw: 1, ch: 1 }; + const { cw, ch } = getCharacterFootprintWH(m); + return { + cw: Math.max(1, Math.min(4, cw)), + ch: Math.max(1, Math.min(4, ch)), + }; + } + + /** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */ + function spaceShooterShipBodyScreenPxPlay(zDrawVal) { + const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1); + const ts = tileSize || 32; + const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData); + const kw = 14 / 32; + const kh = 22 / 32; + return { + bodyW: cw * ts * zD * kw, + bodyH: ch * ts * zD * kh, + }; + } + + /** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */ + function spaceShooterShipHitRadiusWorldPxPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS; + const ts = tileSize || 32; + const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData); + const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS; + const geom = Math.sqrt(Math.max(1, cw * ch)); + const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts)); + return Math.max(9, Math.min(cap, base * geom)); + } + + /** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */ + function spaceShooterShipEdgePadWorldPxPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return 18; + const ts = tileSize || 32; + const { cw } = spaceShooterShipFootprintCellsPlay(mapData); + return Math.max(18, cw * ts * 0.48); + } + + function spaceShooterResetMissionShipStateForAllPlay() { + function resetEnt(ent) { + if (!ent) return; + ent.spaceShooterHits = 0; + ent.spaceShooterEliminated = false; + ent.spaceShooterInvulnUntil = 0; + } + resetEnt(me); + others.forEach(function (o) { + resetEnt(o); + }); + } + + function spaceShooterMissionResolveShipAsteroidHitsPlay() { + if (!mapData || mapData.gameType !== 'space_shooter') return; + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return; + const now = performance.now(); + const shipR = spaceShooterShipHitRadiusWorldPxPlay(); + + function tryHit(ent, cx, cy) { + if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return; + if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return; + for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) { + const a = spaceShooterAsteroids[ai]; + if (!a) continue; + const dx = cx - a.x; + const dy = cy - a.y; + const rr = (a.r + shipR) * (a.r + shipR); + if (dx * dx + dy * dy > rr) continue; + ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1; + ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS; + spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r); + spaceShooterAsteroids.splice(ai, 1); + spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 }); + if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) { + ent.spaceShooterEliminated = true; + spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 }); + } + break; + } + } + + const wasElim = !!me.spaceShooterEliminated; + if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy); + /* คิดการชนเฉพาะยานของเรา (เจ้าของตัดสินเอง) + บอท (host ตัดสิน) เท่านั้น + ผู้เล่นมนุษย์คนอื่นรอสถานะ eliminated/hits จาก move ของเจ้าตัว — กัน "ตายไม่พร้อมกัน" + (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่งดาวเคราะห์ local ที่ต่างกัน → ตัดสินไม่ตรงกัน) */ + others.forEach(function (o, id) { + if (!o) return; + /* host คิดการชนให้ "บอท" รวม takeover bot (id=socket-id, flag isSpaceShooterServerBot) — เดิมเช็คแค่ isPreviewBotId → ข้าม takeover bot = มันไม่มีวันตาย */ + if (!isMePlayHost()) return; + if (!(isPreviewBotId(id) || o.isSpaceShooterServerBot)) return; + if (spaceShooterServerAuthActive()) { + /* server-auth (MG6 เกมจริง): host คิดการชนบอท (asteroid deterministic + ตำแหน่งบอทจาก server) + แล้ว "รายงาน" จำนวนชนให้ server ตัดสินตายจริง — ไม่ตัดสินตายเอง local (กันสถานะตายขัดกัน) */ + const h0 = Math.max(0, Number(o.spaceShooterHits) || 0); + tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + const h1 = Math.max(0, Number(o.spaceShooterHits) || 0); + if (h1 > h0 && socket) { + try { socket.emit('space-shooter-bot-hit', { id, hits: h1 }); } catch (e) { /* ignore */ } + } + } else { + tryHit(o, o.spaceShooterCx, o.spaceShooterCy); + } + }); + /* เพิ่งตาย → broadcast ทันที ไม่รอ throttle 90ms ให้ทุกเครื่องเห็นพร้อมกัน */ + if (!wasElim && me.spaceShooterEliminated && socket && myId != null) { + try { + spaceShooterLastMoveEmit = Date.now(); + socket.emit('move', { + x: me.x, y: me.y, direction: me.direction, + spaceShooterScore: Math.max(0, me.spaceShooterScore | 0), + spaceShooterHits: Math.max(0, Number(me.spaceShooterHits) || 0), + spaceShooterEliminated: true, + }); + } catch (eE) { /* ignore */ } + } + if (spaceShooterMissionEveryParticipantEliminatedPlay()) { + /* host ตัดสิน all_dead + broadcast คะแนน; non-host รอรับ space-shooter-over */ + if (isMePlayHost()) triggerSpaceShooterOverPlay('all_dead'); + } + } + + /** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */ + function spaceShooterMissionEveryParticipantEliminatedPlay() { + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false; + if (myId == null) return false; + if (!me.spaceShooterEliminated) return false; + let anyAlive = false; + others.forEach(function (o) { + if (!o) return; + if (!o.spaceShooterEliminated) anyAlive = true; + }); + return !anyAlive; + } + + /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ + const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; + let firstCharacterDefaultResolved = null; + /** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */ + let playCharacterIdRoster = []; + const firstCharacterDefaultPromise = fetch(BASE + '/api/characters') + .then((r) => r.json()) + .then((list) => { + playCharacterIdRoster = []; + if (Array.isArray(list)) { + list.forEach((c) => { + if (c && c.id) playCharacterIdRoster.push(String(c.id)); + }); + } + if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) { + firstCharacterDefaultResolved = String(list[0].id); + } else { + firstCharacterDefaultResolved = ''; + } + }) + .catch(() => { + firstCharacterDefaultResolved = ''; + playCharacterIdRoster = []; + }) + .finally(() => { + try { + if (playBotsEnabled() && mapData) rebalancePreviewBots(); + } catch (e) { /* map ยังไม่พร้อม */ } + }); + const lobbyLevelParam = params.get('lobbyLevel'); + const lobbyCaseParam = params.get('case'); + if (lobbyLevelParam || lobbyCaseParam) { + try { + window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam }; + } catch (e) { /* ignore */ } + } + + const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h'; + + function isDetectiveMinigamePlay() { + if (params.get('detectiveReturn') === '1') return true; + try { + return sessionStorage.getItem('detectiveMinigameReturn') === '1'; + } catch (e) { + return false; + } + } + + function buildRoomLobbyReturnHref() { + let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID); + const drParam = (params.get('displayRoom') || '').trim(); + if (drParam) { + h += '&displayRoom=' + encodeURIComponent(drParam); + } else { + try { + const dr = localStorage.getItem('lastCreatedSpaceName'); + if (dr) h += '&displayRoom=' + encodeURIComponent(dr); + } catch (e) { /* ignore */ } + } + try { + const meta = window.__detectiveLobbyMeta; + if (meta && meta.level != null && String(meta.level).trim()) { + h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim()); + } + if (meta && meta.caseId != null && String(meta.caseId).trim()) { + h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim()); + } + } catch (e2) { /* ignore */ } + try { + if (sessionStorage.getItem('detectiveMinigameReturn') === '1') { + h += '&detectiveReturn=1'; + sessionStorage.removeItem('detectiveMinigameReturn'); + } + } catch (e3) { /* ignore */ } + if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) { + h += '&detectiveReturn=1'; + } + return h; + } + + function finishDetectiveMinigameAndReturnLobby() { + quizCarrySessionEnded = true; + cancelQuizCarryAnswerRoundTimeupAuto(); + quizCarryAnswerTimeupAwaitNext = false; + hideQuizCarryTimeupOnDeskLayer(); + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + const go = function () { + window.location.href = buildRoomLobbyReturnHref(); + }; + /* รายงาน score/survived ของเกมที่ client นับเอง (stack/jump_survive) → server ใช้จัดอันดับเหรียญ + (เดิมส่ง {} เปล่า → reportedMiniScore/Survived ไม่ถูกตั้ง → coin ranking MG3/MG5 เพี้ยน) */ + let finishReport = {}; + try { + if (isStack() && stackMini) finishReport = { score: Math.max(0, stackMini.score | 0), survived: true }; + else if (isJumpSurvive()) finishReport = { score: 0, survived: !jumpSurviveEliminated }; + } catch (eFR) { /* ignore */ } + if (socket && socket.connected) { + socket.emit('detective-minigame-finished', finishReport, function (res) { + try { + if (res && Array.isArray(res.myPlayerEvidence)) { + var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; }); + if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence)); + else sessionStorage.removeItem('justiceDetectiveMyEvidence'); + } + } catch (e) { /* ignore */ } + /* MG2: เข้าเส้นชัยแล้วแต่ยังมีคนวิ่งอยู่ → รอในฉาก (server จะพากลับ LobbyB พร้อมกันตอนทุกคนจบ) */ + if (res && res.holdInScene) { + showGauntletFinishWaitOverlay(); + return; + } + /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */ + if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) { + showInSceneEvidenceReveal(res, go); + } else { + go(); + } + }); + } else { + go(); + } + } + + /** MG2: ป้าย "เข้าเส้นชัยแล้ว — รอผู้เล่นอื่น" ระหว่างรอคนที่ยังวิ่งอยู่ (จะกลับ LobbyB พร้อมกันตอนเกมจบ) */ + function showGauntletFinishWaitOverlay() { + var ov = document.getElementById('gauntlet-finish-wait'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'gauntlet-finish-wait'; + ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:radial-gradient(ellipse at center,rgba(10,16,32,.82),rgba(6,9,20,.95));backdrop-filter:blur(3px)'; + ov.innerHTML = + '
🏁 เข้าเส้นชัยแล้ว!
' + + '
รอผู้เล่นคนอื่นเข้าเส้นชัย...
' + + '
• • •
'; + document.body.appendChild(ov); + } + ov.style.display = 'flex'; + } + + /* แจ้ง host ว่าทำไม START ยังกดเริ่มไม่ได้ (เดิม reply ถูก ignore เงียบ → ปุ่มเหมือนเสีย) + ใช้ร่วม MG2/5/6/7 (crown lobby shell) — โชว์ "รอผู้เล่น x/N" หรือ "ยังมีคนไม่ Ready" แล้วซ่อนเอง */ + function showCrownStartBlockedNoticePlay(r) { + if (!r) return; + let msg = r.error || 'ยังเริ่มไม่ได้'; + if (r.waitingPlayers) msg = 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + (r.present || 0) + '/' + (r.total || 0) + ')'; + let ov = document.getElementById('crown-start-notice'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'crown-start-notice'; + ov.style.cssText = 'position:fixed;left:50%;top:14%;transform:translateX(-50%);z-index:100001;padding:12px 22px;border-radius:14px;background:rgba(20,12,16,.92);border:1px solid rgba(255,120,120,.5);color:#ffd9d9;font:700 18px/1.3 Kanit,system-ui,sans-serif;text-align:center;box-shadow:0 6px 24px rgba(0,0,0,.5);pointer-events:none'; + document.body.appendChild(ov); + } + ov.textContent = '⏳ ' + msg; + ov.style.display = 'block'; + clearTimeout(ov._hideT); + ov._hideT = setTimeout(function () { ov.style.display = 'none'; }, 2600); + } + + /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ LobbyB) ===== */ + var __evidenceConfigCache = null; + function ensureEvidenceConfigLoaded(cb) { + if (__evidenceConfigCache) { cb(__evidenceConfigCache); return; } + try { + fetch(BASE + '/api/evidence-cards?_=' + Date.now(), { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (j) { __evidenceConfigCache = (j && j.cases) ? j : { cases: {} }; cb(__evidenceConfigCache); }) + .catch(function () { cb({ cases: {} }); }); + } catch (e) { cb({ cases: {} }); } + } + + var EVIDENCE_RARITY_GLOW = { + common: 'rgba(168,180,200,.55)', + rare: 'rgba(94,170,255,.7)', + legendary: 'rgba(255,186,72,.85)', + }; + var EVIDENCE_RARITY_LABEL = { common: 'การ์ดธรรมดา', rare: 'การ์ด Rare', legendary: 'การ์ดชี้คนร้าย' }; + + /** กล่องดูการ์ดหลักฐานแบบขยาย (zoom) — ปิดด้วยปุ่ม ×, คลิกพื้นหลัง, หรือ Esc */ + function openEvidenceCardLightboxPlay(imageUrl, label, rarity) { + if (!imageUrl) return; + var rar = String(rarity || 'common').toLowerCase(); + var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common; + var ov = document.getElementById('evidence-card-lightbox'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'evidence-card-lightbox'; + ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)'; + ov.innerHTML = + '' + + '
' + + '' + + '
' + + '
'; + document.body.appendChild(ov); + var closeFn = function () { ov.style.display = 'none'; }; + ov.addEventListener('click', function (e) { if (e.target === ov || (e.target.classList && e.target.classList.contains('ecl-close'))) closeFn(); }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ov.style.display !== 'none') closeFn(); }); + } + var img = ov.querySelector('.ecl-img'); + var cap = ov.querySelector('.ecl-cap'); + if (img) { img.style.boxShadow = '0 0 44px ' + glow + ',0 22px 60px rgba(0,0,0,.6)'; img.src = imageUrl; } + if (cap) cap.textContent = label || ''; + ov.style.display = 'flex'; + } + + function showInSceneEvidenceReveal(res, onDone) { + var done = false; + var finish = function () { if (done) return; done = true; onDone(); }; + var si = res.suspectIndex; + /* การ์ดที่เพิ่งได้รอบนี้ (object: rarity/imageUrl) — fallback ใช้ N ใบท้ายของ owned */ + var awarded = (res.awardedCards && Array.isArray(res.awardedCards)) + ? res.awardedCards.filter(function (c) { return c && c.imageUrl; }) + : []; + if (!awarded.length) { + var owned = (res.myPlayerEvidence && res.myPlayerEvidence[si]) || []; + var freeN = Math.max(0, Number(res.freeEvidenceCount) || 0); + var newN = Math.min(owned.length, 1 + freeN); + awarded = owned.slice(owned.length - newN).filter(function (c) { return c && c.imageUrl; }); + } + if (!awarded.length) { finish(); return; } + var newN = awarded.length; + var ov = document.createElement('div'); + ov.id = 'inscene-evidence-reveal'; + /* scroll container + inner margin:auto → มือถือแนวตั้งที่เนื้อหาสูงเกินจอ เลื่อนถึงปุ่มได้ (เดิม justify center ไม่มี scroll → ปุ่มหลุดล่างจอ) */ + ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;overflow-y:auto;-webkit-overflow-scrolling:touch;backdrop-filter:blur(3px);padding:16px 12px;box-sizing:border-box'; + var inner = document.createElement('div'); + inner.style.cssText = 'margin:auto;display:flex;flex-direction:column;align-items:center;gap:clamp(12px,2.4vh,22px);width:100%;max-width:640px'; + var title = document.createElement('div'); + title.style.cssText = 'font:900 clamp(20px,5.5vw,30px)/1.2 Kanit,system-ui,sans-serif;color:#ffe7a3;text-shadow:0 0 18px rgba(255,214,102,.5);text-align:center'; + title.textContent = newN > 1 ? ('คุณได้รับหลักฐาน ' + newN + ' ใบ!') : 'คุณได้รับหลักฐาน!'; + if (res.grade) { + var gradeEl = document.createElement('div'); + gradeEl.style.cssText = 'font:800 16px/1 Kanit,system-ui,sans-serif;color:#bfe3ff;opacity:.9'; + gradeEl.textContent = 'ผลงานทีม: เกรด ' + res.grade; + title.appendChild(document.createElement('br')); + title.appendChild(gradeEl); + } + var freeBonus = Math.max(0, Number(res.freeEvidenceCount) || 0); + if (freeBonus > 0) { + var bonusEl = document.createElement('div'); + bonusEl.style.cssText = 'font:800 15px/1.3 Kanit,system-ui,sans-serif;color:#9ece6a;margin-top:4px'; + bonusEl.textContent = '⭐ การ์ด Free Evidence: ทุกคนรับหลักฐานฟรี +' + freeBonus + ' ใบ'; + title.appendChild(document.createElement('br')); + title.appendChild(bonusEl); + } + var row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:18px;flex-wrap:wrap;justify-content:center;align-items:center;max-width:92vw'; + awarded.forEach(function (c, i) { + var rar = String(c.rarity || 'common').toLowerCase(); + var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common; + var cardWrap = document.createElement('div'); + cardWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;opacity:0;transform:translateY(24px) scale(.92);transition:opacity .45s ease,transform .45s cubic-bezier(.2,.9,.3,1.3)'; + var img = document.createElement('img'); + img.src = c.imageUrl; + img.alt = EVIDENCE_RARITY_LABEL[rar] || 'หลักฐาน'; + img.style.cssText = 'display:block;width:auto;height:auto;max-width:min(72vw,360px);max-height:52vh;border-radius:14px;box-shadow:0 0 36px ' + glow + ',0 18px 50px rgba(0,0,0,.6);cursor:zoom-in'; + img.title = 'แตะเพื่อดูใหญ่'; + img.addEventListener('click', function () { openEvidenceCardLightboxPlay(c.imageUrl, EVIDENCE_RARITY_LABEL[rar] || '', rar); }); + img.onerror = function () { this.onerror = null; this.style.width = '200px'; this.style.height = '300px'; this.style.background = '#1b2240'; this.removeAttribute('src'); }; + var tag = document.createElement('div'); + tag.style.cssText = 'font:800 14px/1 Kanit,system-ui,sans-serif;color:#fff;opacity:.92'; + tag.textContent = EVIDENCE_RARITY_LABEL[rar] || ''; + cardWrap.appendChild(img); + cardWrap.appendChild(tag); + row.appendChild(cardWrap); + setTimeout(function () { cardWrap.style.opacity = '1'; cardWrap.style.transform = 'translateY(0) scale(1)'; }, 120 + i * 160); + }); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'คุณได้หลักฐาน · เก็บเข้าแฟ้ม'; + btn.style.cssText = 'cursor:pointer;border:none;border-radius:999px;padding:clamp(11px,1.8vh,14px) clamp(26px,8vw,40px);font:800 clamp(15px,4.4vw,18px)/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 30px rgba(240,180,41,.45);flex:0 0 auto'; + btn.addEventListener('click', function () { + ov.style.transition = 'opacity .25s'; ov.style.opacity = '0'; + setTimeout(finish, 220); + }); + inner.appendChild(title); + inner.appendChild(row); + inner.appendChild(btn); + ov.appendChild(inner); + document.body.appendChild(ov); + /* กันค้าง: ถ้าไม่กดภายใน 12 วิ ให้ไปต่อเอง */ + setTimeout(function () { if (!done) { try { ov.remove(); } catch (e) {} finish(); } }, 12000); + } + + /** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */ + function tryFinishDetectiveMinigameAndReturnLobby() { + if (!isDetectiveMinigamePlay()) return false; + finishDetectiveMinigameAndReturnLobby(); + return true; + } + + /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */ + const previewFillBots = previewMode && editorEmbedReturn; + let detectiveCaseFillBots = false; + let detectiveBotSlotCount = 0; + function playBotsEnabled() { + return previewFillBots || detectiveCaseFillBots; + } + function playBotTargetHeadcount() { + if (previewFillBots) return previewTargetHeadcount; + if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount; + return countPlayHumans(); + } + /** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */ + const PLAY_EMBED_USER_ZOOM_MIN = 0.5; + const PLAY_EMBED_USER_ZOOM_MAX = 3; + const PLAY_EMBED_ZOOM_STEP_KEY = 1.12; + let playEmbedUserZoomMul = 1; + let lastPlayZDrawForInput = 1.4; + /** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */ + function playDomSyncZoom() { + const z = lastPlayZDrawForInput; + return Number.isFinite(z) && z > 0 ? z : zoom; + } + const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6)); + const PREVIEW_BOT_PREFIX = '__pv_bot_'; + let previewBotSeq = 0; + /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */ + let lastStackPreviewActorId = null; + let lastStackPreviewActorUntil = 0; + /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */ + const STACK_PREVIEW_TURN_COUNT = 6; + let stackPreviewTurnOrder = null; + let stackPreviewTurnIndex = 0; + let stackPreviewBotThinkUntil = 0; + /* MG3 server-authoritative: เปิดเมื่อได้รับ stack-state ที่ srv:true → client เลิก host-sim + (ลำดับตา/แกว่ง/ดรอปบอท/จบเกม = server เป็นเจ้าของ; ผู้เล่นส่งเจตนา stack-drop-req) */ + let stackServerAuthPlay = false; + /* server-auth swing: extrapolate phase จาก sample ล่าสุดของ server (กัน drift → บล็อกวาปตอนปล่อย) */ + let stackSrvPhaseAnchor = 0; + let stackSrvPhaseAnchorAt = 0; + /* freeze เฟสที่เห็น ณ ตอนกดวาง ไว้จน echo ดรอปกลับมา → บล็อกไม่แกว่งต่อแล้วเด้งกลับช่วงรอ RTT */ + let stackLocalSwingFrozen = false; + let stackLocalSwingFrozenAt = 0; + + /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower) + (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */ + function rebuildStackPreviewTurnOrder() { + if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') { + stackPreviewTurnOrder = null; + return; + } + if (stackServerAuthPlay) return; /* server-auth: ลำดับตามาจาก stack-state เท่านั้น */ + if (!detectiveCaseFillBots) { + /* editor preview (คนเดียว): คงพฤติกรรมเดิม [human ตัวเอง seat1 + บอท] */ + const bots = [...others.keys()].filter(isPreviewBotId).sort(); + stackPreviewTurnOrder = [{ kind: 'human', seat: 1, id: myId != null ? String(myId) : '__me__' }]; + for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) { + stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, id: bots[i] || null, botId: bots[i] || null }); + } + stackPreviewTurnIndex = 0; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + return; + } + /* เล่นจริง: non-host ไม่สร้าง order เอง — ใช้ของ host จาก stack-state (กัน order ไม่ตรง/client ตกหล่นช่วงโหลด) */ + if (!isMePlayHost()) return; + /* host: ผู้เล่นจริงทุกคน (ตัด banned spectator กัน stall) + บอท เรียง id → ที่นั่งคงที่; preserve คนที่กำลังถึงตา */ + const prevActorId = (stackCurrentTurnEntryPlay() && stackCurrentTurnEntryPlay().id) || null; + const parts = []; + /* dedup คนซ้ำ (ghost reconnect) ก่อนสร้างคิวตา — กัน seat ผี (ตาที่ไม่มีใครวาง = เกมค้าง) + ⚠️ เทียบด้วย playerKey เท่านั้น — **ห้าม dedup ด้วยชื่อ**: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละ key = คนละคน + ต้องได้ seat ทั้งคู่ (เดิม fallback ชื่อ → client ที่ชื่อซ้ำ host ถูกตัด = "ไม่ได้เล่นเลย") */ + const seenHumanKey = new Set(); + const tryAddHuman = (id, ent) => { + const pk = (ent && ent.playerKey) ? String(ent.playerKey) : (String(id) === String(myId) ? (typeof getJdPlayerKey === 'function' ? getJdPlayerKey() : '') : ''); + if (pk) { + if (seenHumanKey.has(pk)) return; /* playerKey เดียวกัน = ghost reconnect → ข้าม */ + seenHumanKey.add(pk); + } + parts.push({ kind: 'human', id: String(id) }); + }; + if (myId != null && !playBannedSpectator) tryAddHuman(myId, me); + others.forEach((o, id) => { + if (isPreviewBotId(id)) { parts.push({ kind: 'bot', id: String(id), botId: String(id) }); return; } + if (o && o.bannedSpectator) return; /* คนถูกแบน = ดูเฉยๆ ไม่เข้าคิววาง */ + tryAddHuman(id, o); + }); + parts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const capped = parts.slice(0, STACK_PREVIEW_TURN_COUNT); + stackPreviewTurnOrder = capped.map((p, i) => ({ + kind: p.kind, seat: i + 1, id: p.id, botId: p.kind === 'bot' ? p.botId : null, + })); + let ni = -1; + if (prevActorId != null) ni = stackPreviewTurnOrder.findIndex((e) => String(e.id) === String(prevActorId)); + if (ni >= 0) stackPreviewTurnIndex = ni; + else if (stackPreviewTurnIndex >= stackPreviewTurnOrder.length) stackPreviewTurnIndex = 0; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + broadcastStackStatePlay(); + } + + /** host broadcast ลำดับตา + index ปัจจุบัน → non-host ใช้ตรงกัน (single source of truth) */ + function broadcastStackStatePlay() { + if (stackServerAuthPlay) return; /* server-auth: server เป็นเจ้าของ stack-state */ + if (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return; + try { + socket.emit('stack-state', { + idx: stackPreviewTurnIndex, + /* theme ต่อที่นั่ง (host เป็นเจ้าของ) → ทุกจอลงสี block ตรงกัน (เดิม non-host คำนวณสีเองจาก playTint → ไม่ตรง 2 จอ) */ + order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null, theme: stackSeatThemeForBroadcastPlay(e.seat) })), + /* สีของ block ที่ "วางแล้ว" ทั้งหอ (host authoritative) → client sync เขียนทับ layer ที่เพี้ยนค้าง (จากจังหวะ dual-host ต้นเกม) */ + lthemes: (stackMini && Array.isArray(stackMini.layers)) ? stackMini.layers.map(function (l) { return (l && l.theme >= 1 && l.theme <= 8) ? l.theme : 0; }) : [], + }); + logStackSeatColors('host-bcast'); + } catch (e) { /* ignore */ } + } + + /** order พร้อมใช้ (มีอย่างน้อย 1 ที่นั่ง) */ + function stackTurnOrderReadyPlay() { + return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1); + } + /** (host) คิวตาตรงกับผู้เล่น/บอทปัจจุบันไหม — ใช้ auto-heal late-join (client เข้าทีหลังต้องได้ seat ไม่งั้น "เล่นไม่ได้" + ไม่มีสี) + เทียบรายชื่อคน (me + others ที่ไม่ใช่บอท/ไม่ถูกแบน) + บอท กับ entry ในคิว; ต่างเมื่อไหร่ = ต้อง rebuild */ + let _stackHumansSig = ''; + function stackOrderNeedsRebuildPlay() { + if (!stackTurnOrderReadyPlay() || !Array.isArray(stackPreviewTurnOrder)) return true; + /* เช็คเฉพาะ "คน" (humans ≤ maxPlayers จึงลงคิวครบเสมอ ไม่โดน cap ตัด) — บอทจัดการใน rebuild เอง */ + const cur = []; + if (myId != null && !playBannedSpectator) cur.push(String(myId)); + others.forEach((o, id) => { + if (isPreviewBotId(id)) return; + if (o && o.bannedSpectator) return; + cur.push(String(id)); + }); + cur.sort(); + const sig = cur.join('|'); + if (sig === _stackHumansSig) return false; /* คนไม่เปลี่ยน → ไม่ต้องเช็ค (กัน rebuild ถี่) */ + const ord = []; + stackPreviewTurnOrder.forEach((e) => { if (e && e.kind !== 'bot' && !e.botId && e.id != null) ord.push(String(e.id)); }); + ord.sort(); + const need = (sig !== ord.join('|')); + if (!need) _stackHumansSig = sig; /* ตรงแล้ว → cache กันเช็คซ้ำทุกเฟรม */ + return need; + } + function stackCurrentTurnEntryPlay() { + if (!stackTurnOrderReadyPlay()) return null; + return stackPreviewTurnOrder[stackPreviewTurnIndex % stackPreviewTurnOrder.length] || null; + } + function isMyStackTurn() { + if (!playBotsEnabled()) return true; /* เล่นเดี่ยว → กดได้ */ + if (detectiveCaseFillBots && !stackTurnOrderReadyPlay()) return false; /* เล่นจริง: รอ order จาก host ก่อน */ + if (!stackTurnOrderReadyPlay()) return true; + const cur = stackCurrentTurnEntryPlay(); + if (!cur || cur.kind !== 'human') return false; + if (!detectiveCaseFillBots) return true; /* editor preview: human = previewer คนเดียว */ + return String(cur.id) === String(myId); + } + function isStackCurrentBotTurnPlay() { + const cur = stackCurrentTurnEntryPlay(); + return !!(cur && cur.kind === 'bot'); + } + /** เจ้าของตาที่วาง broadcast ผลการวาง → ทุกเครื่อง apply เหมือนกัน + เลื่อนตาพร้อมกัน */ + function broadcastStackDropPlay(hit, seat, heavy, actorId, isBot) { + if (!detectiveCaseFillBots || !socket || myId == null || !hit) return; /* broadcast เฉพาะเล่นจริง */ + try { + socket.emit('stack-drop', { + placedCx: hit.placedCx, placedW: hit.placedW, miss: !!hit.miss, perfect: !!hit.perfect, + pts: hit.pts || 0, progressDelta: hit.progressDelta || 0, landOffsetTiles: hit.landOffsetTiles || 0, + overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta || 0, + seat: seat || 1, heavy: !!heavy, actorId: actorId != null ? String(actorId) : '', isBot: !!isBot, + /* สี (theme) ที่ host/เจ้าของตา resolve ตอนวาง → ส่งไปด้วย ให้บล็อกที่วางบนทุกจอใช้สีเดียวกัน (กัน host/client คนละสี) — never-null */ + theme: stackSeatThemeForBroadcastPlay(seat), + }); + } catch (e) { /* ignore */ } + } + + function advanceStackPreviewTurn() { + if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return; + if (stackServerAuthPlay) return; /* server-auth: idx มาจาก server (stack-state) เท่านั้น — กัน double-advance */ + /* เล่นจริง: index คุมโดย host เท่านั้น (non-host รับจาก stack-state) กันลำดับตาเหลื่อมกัน */ + if (detectiveCaseFillBots && !isMePlayHost()) return; + stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackPreviewTurnOrder.length; + stackPreviewBotThinkUntil = 0; + markStackNextDropVisualDirty(); + broadcastStackStatePlay(); + } + + function isStackPreviewHumanTurn() { + if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return true; + const cur = stackCurrentTurnEntryPlay(); + return !!(cur && cur.kind === 'human'); + } + + function getStackPreviewCurrentSeat() { + if (!stackTurnOrderReadyPlay()) return 1; + const cur = stackCurrentTurnEntryPlay(); + return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1; + } + + function getStackDropActorSeat() { + if (playBotsEnabled() && stackTurnOrderReadyPlay()) { + return getStackPreviewCurrentSeat(); + } + return 1; + } + + function markStackNextDropVisualDirty() { + if (stackMini) stackMini.nextDropVisualDirty = true; + } + + /* #4 MG3 block สีตาม user — Block-Code-1..8 ตรง lobby theme (PLAY_LOBBY_THEME_HEX) + heavy '-2' */ + const STACK_BLOCK_THEME_NAMES = ['1-red', '2-orange', '3-Yellow', '4-Green', '5-Blue', '6-Navy', '7-Violet', '8-Pink']; + const STACK_BLOCK_THEME_NORMAL = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '.png'; }); + const STACK_BLOCK_THEME_HEAVY = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '-2.png'; }); + function stackThemeNormalUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_NORMAL[theme - 1] : ''; } + function stackThemeHeavyUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_HEAVY[theme - 1] : ''; } + /** theme (1-8) ของผู้เล่นที่ครอบครอง seat นั้นในลำดับตา — ใช้ลงสี block ตามผู้วาง (ไม่แตะ seat/turn logic) */ + function stackSeatColorThemePlay(seat) { + if (!isStack() || !stackTurnOrderReadyPlay()) return null; + const s = Math.floor(Number(seat)) || 0; + const entry = stackPreviewTurnOrder.find(function (e) { return e && e.seat === s; }); + if (!entry) return null; + /* (1) สีที่ host broadcast มาทาง stack-state = SINGLE SOURCE OF TRUTH → client ยึดค่า host เสมอ (สีตรง host ทุกจอ) + เกม MG3 host เป็นเจ้าของลำดับตา/สี; host เองไม่มี entry.theme (คำนวณสดตอน broadcast) → ตกไป (2) authoritative + เหตุที่ต้อง broadcast-first: ถ้า client คำนวณเอง (lobbyThemeIndexSrv/playLobbyBotThemes ของ client) แล้วข้อมูลมาช้า/ไม่ตรง + → สีหลุดจาก host เพี้ยนเรื่อย ๆ. ใช้ค่า host = ไม่มีทางหลุด */ + const bcTheme = parsePlayLobbyThemeIndex(entry.theme); + if (bcTheme) return bcTheme; + /* (2) authoritative ต่อ entity (สำหรับ "host เอง" + ตอน client ยังไม่ได้รับ broadcast): + bot → playLobbyBotThemes[slot] (server) ; human → lobbyThemeIndexSrv (= server lobbyColorThemeIndex) */ + const botId = entry.botId || (entry.kind === 'bot' ? entry.id : null); + if (botId && isPreviewBotId(botId)) { + const slot = parseInt(String(botId).slice(PREVIEW_BOT_PREFIX.length), 10); + const bt = (slot >= 0 && Array.isArray(playLobbyBotThemes)) ? playLobbyBotThemes[slot] : null; + const botIdx = bt ? parsePlayLobbyThemeIndex(bt.themeIndex) : null; + if (botIdx) return botIdx; + } else if (entry.id != null) { + const entH = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id))); + const humanIdx = entH ? parsePlayLobbyThemeIndex(entH.lobbyThemeIndexSrv) : null; + if (humanIdx) return humanIdx; + } + /* (3) ทางเลือกสุดท้าย: เดาจาก playTint (nearest hex — อาจไม่ตรงข้ามจอ) */ + let ent = null; + if (entry.id != null) ent = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id))); + if (!ent && entry.botId) ent = others.get(entry.botId) || others.get(String(entry.botId)); + return ent ? playLobbyThemeIndexForEntityPlay(ent) : null; + } + + /** สีที่ host จะ broadcast — "ไม่มีทาง null": resolve ปกติ ไม่ได้ค่อย default ตาม seat + → client (broadcast-first) ยึดค่า host ได้เสมอ ไม่ตกไปคำนวณเอง = สีตรง host ทุกจอแน่นอน */ + function stackSeatThemeForBroadcastPlay(seat) { + const t = stackSeatColorThemePlay(seat); + if (t >= 1 && t <= 8) return t; + const s = Math.floor(Number(seat)) || 1; + return ((s - 1) % 8) + 1; + } + + /** ส่ง log ไป /gamelog (ดูรวมที่หน้าเว็บ) */ + function gamelogPost(cat, ev, detail, who) { + try { + fetch('/gamelog/log.php', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, + body: JSON.stringify({ cat: cat || 'อื่นๆ', room: (typeof spaceId === 'string' ? spaceId : ''), who: who || '', ev: ev || '', detail: detail || '', src: 'client' }), + }).catch(function () {}); + } catch (e) { /* ignore */ } + } + /** วินิจฉัยสี block MG3 ต่อ seat — เทียบ "สี avatar (playTint)" vs "สี block (resolved)"; ถ้าไม่ตรง → ส่งเข้า /gamelog + เรียกเอง: window.__mg3ColorLog() หรือ auto throttle */ + let _mg3ColorLogAt = 0; + let _mg3SnapAt = 0; + let _mg3LastMismatchKey = ''; + function logStackSeatColors(tag, force) { + try { + if (!isStack() || !stackTurnOrderReadyPlay()) return; + const now = Date.now(); + if (!force && now - _mg3ColorLogAt < 2000) return; + _mg3ColorLogAt = now; + const rows = stackPreviewTurnOrder.map(function (e) { + let ent = null; + if (e.id != null) ent = (myId != null && String(e.id) === String(myId)) ? me : (others.get(e.id) || others.get(String(e.id))); + if (!ent && e.botId) ent = others.get(e.botId) || others.get(String(e.botId)); + const tint = ent && ent.playTint && ent.playTint.body ? ent.playTint.body : ''; + const avatarIdx = tint ? nearestIndexFromHexPlay(tint, PLAY_LOBBY_THEME_HEX) : null; /* สีจริงของ avatar (จาก playTint) */ + const blockIdx = stackSeatColorThemePlay(e.seat); /* สี block ที่จะวาด */ + return { seat: e.seat, kind: e.kind, nick: (ent && ent.nickname) || String(e.id || e.botId || '').slice(0, 6), avatarIdx: avatarIdx, blockIdx: blockIdx, srv: (ent && ent.lobbyThemeIndexSrv) || null, bc: e.theme || null, ok: (avatarIdx && blockIdx) ? (avatarIdx === blockIdx) : null }; + }); + console.log('[mg3-color/' + (tag || '') + '] host=' + isMePlayHost(), JSON.stringify(rows)); + const role = isMePlayHost() ? 'host' : 'client'; + /* (A) snapshot ทุก ~5 วิ จาก "ทั้ง host และ client" → เทียบใน /gamelog ได้: block(=สีที่วาด) av(=สี avatar) srv bc */ + if (now - _mg3SnapAt > 5000) { + _mg3SnapAt = now; + /* รายชื่อ "คน" ใน others (เทียบกับคิว) — หา root "client หายจากคิว" */ + const othH = []; + if (myId != null) othH.push((me && me.nickname ? me.nickname : 'me') + '(self' + (playBannedSpectator ? ',BAN' : '') + ')'); + others.forEach(function (o, id) { + if (isPreviewBotId(id)) return; + othH.push((o && o.nickname ? o.nickname : String(id).slice(0, 5)) + (o && o.bannedSpectator ? '(BAN)' : '')); + }); + const ordSeats = rows.length; + /* block ที่ "วางแล้ว" (frozen layer.theme) — ตัวที่ user เห็นเพี้ยน; เทียบ host vs client ทีละ layer (seat:theme) */ + let lyr = ''; + if (stackMini && Array.isArray(stackMini.layers)) { + const L = stackMini.layers; + lyr = ' || layers(' + L.length + ')=[' + L.slice(-12).map(function (l) { return (l && l.seat != null ? l.seat : '?') + ':' + (l && l.theme != null ? l.theme : 'null'); }).join(',') + ']'; + } + const summary = 'order=' + ordSeats + 'seat othHumans=[' + othH.join(',') + '] :: ' + + rows.map(function (r) { return 's' + r.seat + ':' + (r.nick || '?').slice(0, 5) + ' blk=' + r.blockIdx + ' av=' + r.avatarIdx; }).join(' | ') + lyr; + gamelogPost('MG3', 'color-snap', summary, role); + } + /* (B) mismatch (avatar≠block บนจอเดียวกัน) → ส่งทันที (เปลี่ยนค่อยส่งซ้ำ) */ + const bad = rows.filter(function (r) { return r.ok === false; }); + if (bad.length) { + const key = role + '|' + bad.map(function (r) { return r.seat + ':' + r.avatarIdx + '!=' + r.blockIdx; }).join(','); + if (key !== _mg3LastMismatchKey) { + _mg3LastMismatchKey = key; + const detail = bad.map(function (r) { return r.nick + ' seat' + r.seat + ' avatar#' + r.avatarIdx + ' แต่ block#' + r.blockIdx + ' (srv' + r.srv + '/bc' + r.bc + ')'; }).join(' | '); + gamelogPost('MG3', 'color-mismatch', detail, role); + } + } + } catch (e) { /* ignore */ } + } + window.__mg3ColorLog = function () { logStackSeatColors('manual', true); }; + + function pickStackBlockHeavyVisual(seat) { + const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1)); + let hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || ''); + /* ถ้าใช้สี theme (Block-Code) — heavy variant '-2' มีครบ ใช้แทน admin slot */ + const _theme = stackSeatColorThemePlay(seat); + if (_theme && stackThemeHeavyUrlPlay(_theme)) hU = stackThemeHeavyUrlPlay(_theme); + /** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */ + if (isStackTowerMissionUiMapPlay()) { + if (!(stackTowerMissionPhase === 'live' && stackMini)) return false; + const p = Number(stackMini.progressPct); + if (!Number.isFinite(p) || p < STACK_TOWER_POST50_PROGRESS_THRESH) return false; + return !!hU; + } + const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0))); + if (pct <= 0) return false; + if (!hU) return false; + if (Math.random() * 100 >= pct) return false; + return true; + } + + function ensureStackNextDropVisual() { + if (!stackMini || !stackMini.nextDropVisualDirty) return; + stackMini.pendingDropSeat = getStackDropActorSeat(); + stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat); + stackMini.pendingDropTheme = null; /* ดรอป local/host → ใช้ stackSeatColorThemePlay เอง (remote ตั้งทับทีหลัง) */ + stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy); + stackMini.nextDropVisualDirty = false; + } + + function normalizePlayStackSixUrlsFromTiming(arr) { + const out = ['', '', '', '', '', '', '', '']; /* 8 slot ตาม lobby theme (#4) */ + if (!Array.isArray(arr)) return out; + for (let i = 0; i < 8; i++) { + out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : ''); + if (out[i]) ensureGauntletAssetImage(out[i]); + } + return out; + } + + function resolveStackBlockSpriteRec(seat, heavy, themeOverride) { + const tryHeavy = !!heavy; + /* #4 สี block = theme ของผู้เล่นที่ seat นั้น (Block-Code-1..8) ถ้า resolve ได้ (มี turn order) + — fallback ไป admin slot URLs ถ้าไม่มี theme (เกม stack อื่น/ยังไม่มีลำดับตา) + themeOverride = สีที่ "freeze" ไว้บน layer ตอนวาง → กัน re-resolve เปลี่ยนสีเมื่อ turn order เปลี่ยน (สี block ที่วางแล้วคงที่+ตรงทุกจอ) */ + const theme = (themeOverride >= 1 && themeOverride <= 8) ? themeOverride : stackSeatColorThemePlay(seat); + if (theme) { + const tn = stackThemeNormalUrlPlay(theme); + const th = stackThemeHeavyUrlPlay(theme); + const torder = tryHeavy ? [th, tn] : [tn, th]; + for (let ti = 0; ti < torder.length; ti++) { + if (torder[ti]) return ensureGauntletAssetImage(torder[ti]); + } + } + const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1)); + const order = tryHeavy + ? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]] + : [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]]; + for (let ti = 0; ti < order.length; ti++) { + const u = normalizeGauntletAssetUrlForPlay(order[ti] || ''); + if (!u) continue; + return ensureGauntletAssetImage(u); + } + return null; + } + + /** + * @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback + * ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain + * เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult + */ + function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) { + const o = opts || {}; + const rec = resolveStackBlockSpriteRec(seat, heavy, o.themeOverride); + const img = rec && rec.img; + if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { + const iw = img.naturalWidth; + const ih = img.naturalHeight; + if (drawW > 0 && drawH > 0) { + const scale = Math.max(drawW / iw, drawH / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = sx + (drawW - dw) * 0.5; + const dy = sy + (drawH - dh) * 0.5; + const prevSmooth = ctx.imageSmoothingEnabled; + ctx.save(); + ctx.beginPath(); + ctx.rect(sx, sy, drawW, drawH); + ctx.clip(); + try { + ctx.imageSmoothingEnabled = false; + ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); + } finally { + ctx.imageSmoothingEnabled = prevSmooth; + } + ctx.restore(); + if (o.missOverlay) { + ctx.fillStyle = 'rgba(247, 118, 190, 0.42)'; + ctx.fillRect(sx, sy, drawW, drawH); + } + return true; + } + } + const hue = ((hueFallback % 360) + 360) % 360; + ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)'); + ctx.fillRect(sx, sy, drawW, drawH); + return false; + } + + if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; } + + const socket = io({ path: BASE + '/socket.io' }); + window.__jdGameSocket = socket; + const canvas = document.getElementById('game-canvas'); + const ctx = canvas.getContext('2d'); + + /** popup แจ้งเตือน/ผิดพลาด ดีไซน์เอง (แทน alert() ของเบราว์เซอร์ที่หน้าตาไม่เข้าธีม) + opts: { type:'error'|'info', okText, onClose } — onClose เรียกเมื่อกดตกลง/ปิด */ + function showPlayAlert(message, opts) { + opts = opts || {}; + const prev = document.getElementById('play-alert-modal'); + if (prev) prev.remove(); + const overlay = document.createElement('div'); + overlay.id = 'play-alert-modal'; + overlay.className = 'play-alert-modal' + (opts.type === 'error' ? ' play-alert-modal--error' : ''); + const card = document.createElement('div'); + card.className = 'play-alert-card'; + card.setAttribute('role', 'alertdialog'); + card.setAttribute('aria-modal', 'true'); + const backdrop = document.createElement('div'); + backdrop.className = 'play-alert-backdrop'; + const icon = document.createElement('div'); + icon.className = 'play-alert-icon'; + icon.textContent = opts.type === 'error' ? '!' : 'i'; + const msg = document.createElement('div'); + msg.className = 'play-alert-msg'; + msg.textContent = (message == null ? '' : String(message)); + const ok = document.createElement('button'); + ok.type = 'button'; + ok.className = 'play-alert-ok'; + ok.textContent = opts.okText || 'ตกลง'; + card.appendChild(icon); + card.appendChild(msg); + card.appendChild(ok); + overlay.appendChild(backdrop); + overlay.appendChild(card); + document.body.appendChild(overlay); + let closed = false; + const done = function () { + if (closed) return; + closed = true; + overlay.remove(); + document.removeEventListener('keydown', onKey, true); + if (typeof opts.onClose === 'function') { try { opts.onClose(); } catch (e) { /* ignore */ } } + }; + const onKey = function (e) { + if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); done(); } + }; + ok.addEventListener('click', done); + if (!opts.requireButton) backdrop.addEventListener('click', done); + document.addEventListener('keydown', onKey, true); + setTimeout(function () { try { ok.focus(); } catch (e) { /* ignore */ } }, 30); + return { close: done }; + } + window.__jdShowPlayAlert = showPlayAlert; + let mapData = null, tileSize = 32, myId = null; + let stackMini = null; + let stackFall = null; + let stackLastSwingEmit = 0; /* throttle broadcast เฟสแกว่ง MG3 (เจ้าของตา → คนอื่น mirror) */ + let stackLastStateEmit = 0; /* throttle broadcast ลำดับตา+index MG3 (host → non-host adopt) */ + let stackRemoteDropQueue = []; /* คิวดรอปจาก host/คนอื่น (MG3) — ทยอย apply ทีละอันกันทิ้ง */ + /** เศษบล็อกที่พลาด/หล่น → ตกแบบ physics หลุดขอบจอ (visual เท่านั้น ไม่กระทบคะแนน/sync) */ + let stackMissDebris = []; + let _stackDebrisLastT = 0; + /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */ + let stackTowerSlingImg = null; + /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */ + let stackTowerScorePlus10Img = null; + let stackTowerScorePlus20Img = null; + let stackTowerScoreLightImg = null; + let stackTowerScoreX2Img = null; + /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */ + let stackTowerHeartMinusImg = null; + /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */ + let stackTowerLifeBarImg = null; + let stackTowerLifeHitImg = null; + let stackTowerLifeFullImg = null; + /** Space Shooter (violent_crime): life HUD โหลดจาก /img/ViolentCrime/ แยกจาก TowerBlock ของ Stack */ + let violentCrimeLifeBarImg = null; + let violentCrimeLifeHitImg = null; + let violentCrimeLifeFullImg = null; + /** @type {{ until: number } | null} */ + let stackTowerHeartMinusFx = null; + const STACK_TOWER_HEART_MINUS_FX_MS = 1500; + const STACK_TOWER_PERFECT_GLOW_MS = 1500; + /** ป๊อปอัป +10 / +20 เหนือบล็อก */ + const STACK_TOWER_SCORE_POPUP_MS = 2000; + /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */ + const STACK_TOWER_PERFECT_X2_MS = 2200; + /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */ + const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998; + let lastStackTickMs = performance.now(); + let lastStackTowerScrollBgTickMs = performance.now(); + let mapBackgroundImg = null; + /** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */ + const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp'; + const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png'; + const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png'; + let playScrollBgIntroImg = null; + let playScrollBgLoopImg = null; + /** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */ + let playScrollBgPx = 0; + let playScrollBgSpeedPxPerSec = 56; + let playScrollBgOff = false; + /** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */ + let playScrollBgFlowDown = false; + + function playScrollBgMapEligible() { + return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID); + } + + function playScrollBgDrawActive() { + if (!playScrollBgMapEligible() || playScrollBgOff) return false; + const a = playScrollBgIntroImg; + const b = playScrollBgLoopImg; + return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth); + } + + function reloadPlayScrollBgFromMap() { + playScrollBgIntroImg = null; + playScrollBgLoopImg = null; + playScrollBgPx = 0; + if (!playScrollBgMapEligible()) return; + const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {}; + playScrollBgOff = raw.enabled === false; + playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56)); + const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase(); + playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom'); + if (playScrollBgOff) return; + const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO; + const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP; + let pending = 2; + const bump = () => { + pending--; + if (pending <= 0) { + playScrollBgSyncInitialScrollToBottom(); + try { draw(); } catch (e) { /* ignore */ } + } + }; + const im1 = new Image(); + im1.onload = () => { playScrollBgIntroImg = im1; bump(); }; + im1.onerror = () => { bump(); }; + im1.src = introSrc; + const im2 = new Image(); + im2.onload = () => { playScrollBgLoopImg = im2; bump(); }; + im2.onerror = () => { bump(); }; + im2.src = loopSrc; + } + + function playScrollBgSyncInitialScrollToBottom() { + const intro = playScrollBgIntroImg; + if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return; + const cwR = Math.max(1, Math.round(canvas.width)); + const chR = Math.max(1, Math.round(canvas.height)); + const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); + /* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */ + if (playScrollBgFlowDown) { + playScrollBgPx = 0; + } else { + playScrollBgPx = Math.max(0, drawHIntro - chR); + } + } + + function drawPlayScrollBgFullCanvas(cw, ch) { + const intro = playScrollBgIntroImg; + const loop = playScrollBgLoopImg; + if (!intro || !intro.complete || !loop || !loop.complete) return; + const cwR = Math.max(1, Math.round(cw)); + const chR = Math.max(1, Math.round(ch)); + const scaleI = cwR / intro.naturalWidth; + const drawHIntro = Math.round(intro.naturalHeight * scaleI); + const scaleL = cwR / loop.naturalWidth; + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); + const S = playScrollBgPx; + const vp0 = S; + const vp1 = S + chR; + const yLimit = vp1 + drawHLoop * 2; + const flowDown = playScrollBgFlowDown; + + function stripTopToDestY(stripTop, drawH) { + if (!flowDown) return stripTop - S; + return S + chR - (stripTop + drawH); + } + + ctx.save(); + /* BG อวกาศเป็นภาพ illustration (loop 1024w สเกลขึ้น ~1.9x) — เปิด smoothing ไม่ให้แตกเป็นบล็อก (stack tower BG กว้าง ~1920 จึงคงปิดไว้) */ + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.beginPath(); + ctx.rect(0, 0, cwR, chR); + ctx.clip(); + + if (vp0 < 0) { + let k = 1; + if (vp0 < -drawHLoop * 4) { + k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2); + } + for (; k < 50000; k++) { + const y0 = -k * drawHLoop; + if (y0 >= vp1 + drawHLoop) break; + if (y0 + drawHLoop <= vp0) continue; + const dy = Math.round(stripTopToDestY(y0, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + } + } + + if (drawHIntro > vp0 && vp1 > 0) { + const dy = Math.round(stripTopToDestY(0, drawHIntro)); + ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro); + } + + let y = drawHIntro; + if (y < yLimit && vp1 > drawHIntro) { + if (vp0 > drawHIntro) { + const n = Math.floor((vp0 - drawHIntro) / drawHLoop); + y = drawHIntro + Math.max(0, n - 1) * drawHLoop; + } + while (y < yLimit) { + const dy = Math.round(stripTopToDestY(y, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + y += drawHLoop; + } + } + ctx.restore(); + } + + function tickPlayScrollBg(dtSec) { + if (!playScrollBgDrawActive()) return; + playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec; + } + + /** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */ + const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07'; + /** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */ + const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000; + /** ความเร็วเดินเข้าเส้นชัยตอนจบ (ช่อง/วินาที) — ค่อยๆ เดินแทนกระพริบ snap */ + const GAUNTLET_FINISH_WALK_TILES_PER_SEC = 7; + /** เพดานเวลาเดินเข้าเส้นชัย (กันค้างถ้าใครติด) — ต้อง < server grace (GAUNTLET_FINISH_WALK_MS) */ + const GAUNTLET_FINISH_WALK_MAX_MS = 2600; + const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png'; + const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png'; + const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png'; + const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png'; + const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png'; + let gauntletCrownRunwayBgImgs = [null, null, null, null, null]; + let gauntletCrownRunwayBgOff = false; + let gauntletCrownRunwayBgSpeedPxPerSec = 48; + let gauntletCrownRunwayBgScrollPx = 0; + /* G1: scroll ผูกเวลา server (ทุกเครื่องตรงกัน ไม่ขึ้น resolution/frame-rate/host) — scroll = base + speed × (serverNow − anchor) */ + let gauntletCrownRunwayLiveAnchorServerMs = 0; /* เวลาเริ่ม live (server epoch) */ + let gauntletCrownRunwayServerLiveStartMs = 0; /* liveStartMs จาก server (gauntlet-sync) — anchor เดียวกันทุกเครื่อง */ + let gauntletCrownRunwayBaseScrollPx = 0; /* ค่า scroll ตอน snap (resolution-independent เท่ากันทุกเครื่อง) */ + let gauntletCrownRunwayBgFinishLatched = false; + /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */ + let gauntletFinishPhaseSignaled = false; + /** กำลังเดินค่อยๆ เข้าเส้นชัย (ก่อน freeze) — true ระหว่างทุกคนทยอยเดินมาที่เส้น */ + let gauntletCrownRunwayWalkActive = false; + /** เวลาเริ่มเดินเข้าเส้นชัย (ms) — ใช้เพดานเวลา GAUNTLET_FINISH_WALK_MAX_MS */ + let gauntletCrownRunwayWalkStartMs = 0; + /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */ + let gauntletCrownRunwayBgStripFreezeSinceMs = 0; + /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */ + let gauntletCrownRunwayFinishAlignLatchedWorldX = null; + /** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */ + let gauntletCrownRunwayClientMissionShown = false; + /** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */ + const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8; + const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png'; + let gauntletCrownScorePenaltyImg = null; + function ensureGauntletCrownScorePenaltyImgPlay() { + if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg; + const im = new Image(); + im.src = GAUNTLET_CROWN_SCORE_POP_URL; + gauntletCrownScorePenaltyImg = im; + return im; + } + let lastGauntletCrownRunwayBgTickMs = 0; + /** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */ + let gauntletCrownRunwaySpawnScrollSnapped = false; + + function gauntletCrownRunwayBgMapEligiblePlay() { + return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID); + } + + function gauntletCrownRunwayBgNatDims(im) { + if (!im) return null; + if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height }; + if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight }; + return null; + } + + function gauntletCrownRunwayBgImageSlotReadyPlay(im) { + return !!gauntletCrownRunwayBgNatDims(im); + } + + function gauntletCrownRunwayBgStripWidthsAtCh(chR) { + const ch0 = Math.max(1, Math.round(chR)); + const wOf = (im) => { + const d = gauntletCrownRunwayBgNatDims(im); + if (!d) return 0; + return Math.max(1, Math.round(d.nw * (ch0 / d.nh))); + }; + return { + w0: wOf(gauntletCrownRunwayBgImgs[0]), + w1: wOf(gauntletCrownRunwayBgImgs[1]), + w2: wOf(gauntletCrownRunwayBgImgs[2]), + w3: wOf(gauntletCrownRunwayBgImgs[3]), + w4: wOf(gauntletCrownRunwayBgImgs[4]), + }; + } + + function gauntletCrownRunwayBgImagesReadyPlay() { + for (let i = 0; i < 5; i++) { + if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false; + } + return true; + } + + /** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */ + function ensureGauntletCrownRunwayBgPlaceholdersPlay() { + if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return; + const W = 960; + const H = 540; + const hues = [352, 210, 165, 225, 38]; + for (let i = 0; i < 5; i++) { + if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue; + const c = document.createElement('canvas'); + c.width = W; + c.height = H; + const g = c.getContext('2d'); + const h0 = hues[i]; + const grd = g.createLinearGradient(0, 0, W, H * 0.9); + grd.addColorStop(0, `hsl(${h0},26%,20%)`); + grd.addColorStop(0.45, `hsl(${h0},18%,14%)`); + grd.addColorStop(1, `hsl(${h0},12%,10%)`); + g.fillStyle = grd; + g.fillRect(0, 0, W, H); + g.strokeStyle = 'rgba(255,255,255,0.06)'; + g.lineWidth = 1; + for (let x = 0; x < W; x += 48) { + g.beginPath(); + g.moveTo(x, 0); + g.lineTo(x + H * 0.04, H); + g.stroke(); + } + const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85); + vg.addColorStop(0, 'rgba(0,0,0,0)'); + vg.addColorStop(1, 'rgba(0,0,0,0.38)'); + g.fillStyle = vg; + g.fillRect(0, 0, W, H); + g.fillStyle = 'rgba(255,255,255,0.1)'; + g.font = '10px system-ui,sans-serif'; + g.textAlign = 'right'; + g.fillText(String(i + 1), W - 10, H - 8); + gauntletCrownRunwayBgImgs[i] = c; + } + } + + function gauntletCrownRunwayBgDrawActivePlay() { + if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false; + return gauntletCrownRunwayBgImagesReadyPlay(); + } + + function gauntletCrownRunwayFinishLineInArtFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let lineInArt = Number(raw.finishLineInFinishArtFrac); + if (!Number.isFinite(lineInArt)) { + lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.34 : 0.12; + } + return Math.max(0, Math.min(0.92, lineInArt)); + } + + function gauntletCrownRunwayFinishLineStripXPlay(g) { + if (!g) return null; + return g.finishStart + g.w4Safe * gauntletCrownRunwayFinishLineInArtFracPlay(); + } + + /** โลกแมป X ของจุดอ้างอิงในงานศิลป์ FINISH = stripX − scrollPx (ไม่ขึ้นกับกล้อง) */ + function gauntletCrownRunwayFinishLineMapWorldXPlay() { + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return null; + const stripX = gauntletCrownRunwayFinishLineStripXPlay(g); + if (stripX == null) return null; + return stripX - gauntletCrownRunwayBgScrollPx; + } + + function gauntletCrownRunwayStripFrozenPlay() { + return gauntletCrownRunwayWalkActive || gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched; + } + + /** จุดอ้างอิงกลางตัวนำ (map world px) — ใช้หยุด FINISH ตรงตัวละคร ไม่ใช่ขอบขวาแมป */ + function gauntletCrownRunwayLeadingEntityAlignWorldXPlay() { + if (!mapData || !isGauntletCrownHeistMapPlay()) return null; + const ts = mapData.tileSize || 32; + const { cw } = getCharacterFootprintWH(mapData); + const mx = getGauntletCrownHeistMaxEntityXPlay(mapData); + /* แนบเส้นชัยกับ "ตัวนำหน้าสุดที่ยังไม่ตาย" (tx มากสุด) → ตัวนำหยุดที่เส้นพอดี ไม่เลยเส้น + (ตอนหยุด เราจะ snap ตัวที่เหลือมายืนที่เส้นเดียวกันด้วย ดู gauntletSnapLivingToFinishPlay) */ + let leadTx = -Infinity; + const consider = (ent) => { + if (!ent || ent.gauntletEliminated) return; + let tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); + if (!Number.isFinite(tx)) return; + leadTx = Math.max(leadTx, tx); + }; + consider(me); + others.forEach((o, id) => { + if (!playBotsEnabled() && isPreviewBotId(id)) return; + consider(o); + }); + if (!Number.isFinite(leadTx) || leadTx === -Infinity) return null; + let alignWorld = (leadTx + cw * 0.5) * ts; + if (mx != null && Number.isFinite(mx)) { + alignWorld = Math.min(alignWorld, (mx + cw * 0.5) * ts); + } + return alignWorld; + } + + /** ตอนถึงเส้นชัย: ดึงตัวที่ยังไม่ตายทุกตัวมายืนที่ "เส้นชัย" — ไม่มีใครเลยเส้น, คนตายไม่ย้าย + เป้าหมาย = ตำแหน่งเส้นชัยที่ latch ไว้ (คงที่) ถ้ายังไม่ latch ใช้ tx ตัวนำสุด + เรียกทุกเฟรมระหว่าง freeze/latched เพื่อกันบอทที่ sim ยังขยับ "ไหล" หลุดจากเส้น */ + function gauntletSnapLivingToFinishPlay() { + if (!mapData || !isGauntletCrownHeistMapPlay()) return; + const ts = mapData.tileSize || 32; + const { cw } = getCharacterFootprintWH(mapData); + let targetTx = null; + if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { + targetTx = gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5; + } else { + let leadTx = -Infinity; + const consider = (ent) => { + if (!ent || ent.gauntletEliminated) return; + const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); + if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx); + }; + consider(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); }); + if (Number.isFinite(leadTx) && leadTx !== -Infinity) targetTx = leadTx; + } + if (targetTx == null || !Number.isFinite(targetTx)) return; + const snap = (ent) => { + if (!ent || ent.gauntletEliminated) return; + if (Number.isFinite(ent.x)) ent.x = targetTx; + if (ent.tx != null) ent.tx = targetTx; + }; + snap(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; snap(o); }); + } + + /** tx เป้าหมายที่เส้นชัย (คอลัมน์ที่ทุกคนต้องมายืน) — null ถ้าคำนวณไม่ได้ */ + function gauntletFinishTargetTxPlay() { + if (!mapData || !isGauntletCrownHeistMapPlay()) return null; + const ts = mapData.tileSize || 32; + const { cw } = getCharacterFootprintWH(mapData); + if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { + return gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5; + } + let leadTx = -Infinity; + const consider = (ent) => { + if (!ent || ent.gauntletEliminated) return; + const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x); + if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx); + }; + consider(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); }); + return (Number.isFinite(leadTx) && leadTx !== -Infinity) ? leadTx : null; + } + + /** เดินทุกคนที่ยังไม่ตาย "ค่อยๆ" เข้าหาเส้นชัยทีละเฟรม (แทน snap กระพริบ) + คืน true เมื่อทุกคนถึงเส้นแล้ว (เริ่ม freeze→จบเกมต่อ) */ + function gauntletWalkLivingToFinishStepPlay(dtSec) { + const targetTx = gauntletFinishTargetTxPlay(); + if (targetTx == null || !Number.isFinite(targetTx)) return true; + const step = Math.max(0.04, GAUNTLET_FINISH_WALK_TILES_PER_SEC * Math.max(0, Number(dtSec) || 0)); + const eps = 0.06; + let allArrived = true; + const walk = (ent) => { + if (!ent || ent.gauntletEliminated) return; + let x = Number.isFinite(ent.x) ? ent.x : (Number.isFinite(ent.tx) ? ent.tx : targetTx); + if (x < targetTx - eps) { + x = Math.min(targetTx, x + step); + ent.direction = 'right'; + ent.isWalking = true; + allArrived = false; + } else { + x = targetTx; /* ไม่ให้ใครเลยเส้นชัย (เลยมา → ดึงกลับมาที่เส้น) */ + ent.isWalking = false; + } + ent.x = x; + ent.tx = x; + }; + walk(me); + others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; walk(o); }); + return allArrived; + } + + /** เริ่มเฟส "เดินเข้าเส้นชัย": ล็อก align → snap scroll → แจ้ง server หยุด obstacle ทันที (กันตายระหว่างเดิน) + จากนั้น tick จะค่อยๆ เดินทุกคนมาที่เส้น แล้วค่อยเข้า freeze→จบเกม */ + function gauntletCrownRunwayBeginWalkToFinishPlay() { + if (gauntletCrownRunwayWalkActive || gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched) return; + if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) { + const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); + if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign; + } + gauntletCrownRunwaySnapScrollToFinishStopPlay(); + gauntletCrownRunwayWalkActive = true; + gauntletCrownRunwayWalkStartMs = Date.now(); + /* G3: เข้าเฟสเดินเข้าเส้นชัย → เคลียร์ท่ากระโดด (กันค้างลอยกลางอากาศ — ต้องเดินเข้าเส้น ไม่ใช่กระโดดค้าง) */ + meGauntletJumpTicks = 0; meGauntletJumpVis = 0; + others.forEach((o) => { if (o) { o.gauntletJumpTicks = 0; o.gauntletJumpVis = 0; } }); + /* แจ้ง server ครั้งเดียว: เข้าโซนเส้นชัย → หยุด obstacle + collision (กันโดนชน/-10 ตอนกำลังเดินเข้าเส้น) + server จะรอ grace (GAUNTLET_FINISH_WALK_MS) ให้เดินครบก่อนค่อยจบ */ + if (!gauntletFinishPhaseSignaled) { + gauntletFinishPhaseSignaled = true; + try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ } + } + try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } + } + + + /** + * คอลัมน์บนกริดที่เส้น FINISH ต้องแนบ (map world px) + * gauntletCrownRunwayBg.finishAlignMapTileX | finishAlignMapTileFrac — ถ้าไม่ตั้ง ใช้ตำแหน่งตัวนำ + */ + function gauntletCrownRunwayFinishAlignMapWorldXPlay() { + if (!mapData) return null; + if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) { + return gauntletCrownRunwayFinishAlignLatchedWorldX; + } + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const ts = mapData.tileSize || 32; + const tileX = Number(raw.finishAlignMapTileX); + if (Number.isFinite(tileX)) return (tileX + 0.5) * ts; + const tileFrac = Number(raw.finishAlignMapTileFrac); + if (Number.isFinite(tileFrac)) { + const w = mapData.width || 20; + return (Math.max(0, Math.min(1, tileFrac)) * w + 0.5) * ts; + } + if (gauntletCrownRunwayBgMapEligiblePlay()) { + return gauntletCrownRunwayLeadingEntityAlignWorldXPlay(); + } + return null; + } + + /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */ + function gauntletCrownRunwayFinishLineScreenFracPlay(g) { + if (!g) return null; + const finishLineWorldX = gauntletCrownRunwayFinishLineStripXPlay(g); + if (finishLineWorldX == null) return null; + return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld); + } + + function gauntletCrownRunwayFinishStopViewportFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75; + let vp = Number(raw.finishStopViewportFrac); + if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp)); + let legacy = Number(raw.finishStopScreenFrac); + if (Number.isFinite(legacy)) { + legacy = Math.max(0.08, Math.min(0.96, legacy)); + return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42)); + } + /* mno9kb07: หยุดเลื่อนเร็วขึ้น (เส้น FINISH หยุดที่ ~0.55 จากซ้าย — ขอบขวาของแถบ FINISH ใกล้กลางจอ) */ + return gauntletCrownRunwayBgMapEligiblePlay() ? 0.55 : 0.34; + } + + function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let v = Number(raw.obstacleFadeStartViewportFrac); + /* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */ + if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9; + return Math.max(0.45, Math.min(1.15, v)); + } + + /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */ + function gauntletCrownRunwayObstacleDrawAlphaPlay() { + if (!gauntletCrownRunwayBgDrawActivePlay()) return 1; + if (gauntletCrownRunwayBgFinishLatched) return 0; + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return 1; + const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); + if (screenFrac == null) return 1; + const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay(); + const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay(); + if (screenFrac >= fadeStart) return 1; + if (screenFrac <= stopAt) return 0; + return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt); + } + + function gauntletCrownRunwayBgHideObstaclesPlay() { + return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001; + } + + /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */ + function gauntletCrownRunwayAvatarRunAllowedPlay() { + if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) { + if (gauntletCrownRunwayBgFinishLatched) return false; + if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false; + return true; + } + return true; + } + + function drawImageCoverCanvasPlay(ctx, img, cwR, chR) { + const d = gauntletCrownRunwayBgNatDims(img); + if (!d) return; + const iw = d.nw; + const ih = d.nh; + if (!iw || !ih) return; + const scale = Math.max(cwR / iw, chR / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = (cwR - dw) / 2; + const dy = (chR - dh) / 2; + ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh); + } + + function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) { + const d0 = gauntletCrownRunwayBgNatDims(img); + if (!d0 || segW <= 0) return; + const natW = d0.nw; + const natH = d0.nh; + const v0 = scrollLeft; + const v1 = scrollLeft + cwR; + const o0 = Math.max(worldX0, v0); + const o1 = Math.min(worldX0 + segW, v1); + if (o1 <= o0) return; + const u0 = ((o0 - worldX0) / segW) * natW; + const u1 = ((o1 - worldX0) / segW) * natW; + const dx = o0 - scrollLeft; + const dw = o1 - o0; + /** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */ + ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR); + } + + /** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */ + function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) { + const start = gauntletCrownRunwayBgImgs[0]; + const L2 = gauntletCrownRunwayBgImgs[1]; + const L3 = gauntletCrownRunwayBgImgs[2]; + const L4 = gauntletCrownRunwayBgImgs[3]; + const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR); + const cycle = w1 + w2 + w3; + const S = scrollView; + ctx.imageSmoothingEnabled = true; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, cwR, chR); + if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR); + if (cycle <= 0) return; + const ws = [w1, w2, w3]; + const ims = [L2, L3, L4]; + const uTotal = Math.max(0, S - w0); + let cycN = Math.floor(uTotal / cycle); + let uRem = uTotal - cycN * cycle; + let si = 0; + while (si < 3 && uRem >= ws[si]) { + uRem -= ws[si]; + si++; + } + if (si >= 3) { + cycN++; + si = 0; + uRem = 0; + } + let acc = 0; + for (let k = 0; k < si; k++) acc += ws[k]; + let curLeft = w0 + cycN * cycle + acc; + let guard = 0; + let idx = si; + while (curLeft < S + cwR && guard++ < 500) { + drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR); + curLeft += ws[idx]; + idx = (idx + 1) % 3; + } + } + + /** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */ + function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) { + const S = scrollView; + const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR); + ctx.imageSmoothingEnabled = true; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, cwR, chR); + const segs = [ + { im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 }, + { im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 }, + { im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 }, + { im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 }, + { im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 }, + ]; + for (let i = 0; i < segs.length; i++) { + const g = segs[i]; + if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR); + } + } + + /** + * @param {number} canvasW + * @param {number} canvasH + * @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง) + */ + function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) { + gauntletCrownRunwayMaintainFinishScrollPlay(); + const finish = gauntletCrownRunwayBgImgs[4]; + const align = worldAlign && typeof worldAlign === 'object' + && Number.isFinite(worldAlign.worldMinX) + && Number.isFinite(worldAlign.worldMaxX) + && Number.isFinite(worldAlign.camX) + && Number.isFinite(worldAlign.camY) + && Number.isFinite(worldAlign.zDraw) + && Number.isFinite(worldAlign.mapWpx) + && Number.isFinite(worldAlign.mapHpx); + /* หลัง latch ยังวาดแถบรันเวย์ที่ scroll ค้าง — อย่า switch เป็น cover เต็มแผนที่ (ทำให้ FINISH กระโดดซ้าย) */ + /* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */ + const cwR = align + ? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX) + : Math.max(1, Math.round(canvasW)); + const chR = align + ? Math.max(1, Math.round(worldAlign.mapHpx)) + : Math.max(1, Math.round(canvasH)); + const scrollView = align + ? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX) + : gauntletCrownRunwayBgScrollPx; + if (align) { + const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign; + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, canvasW, canvasH); + ctx.save(); + ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw); + ctx.beginPath(); + /* แนวนอน: คลิปเต็มความกว้างที่มองเห็น (worldMinX..worldMaxX) แทนตัดที่ความกว้างแมพ (mapWpx) + → จอกว้างทุก resolution รันเวย์โชว์ยาวเต็ม ไม่เหลือขอบดำซ้าย-ขวา · แนวตั้งคงคลิป=ความสูงแมพ (ไม่กระทบเส้นชัย) */ + ctx.rect(worldMinX, 0, cwR, mapHpx); + ctx.clip(); + ctx.translate(worldMinX, 0); + if (gauntletCrownRunwayBgMapEligiblePlay()) { + drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView); + } else { + drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView); + } + ctx.restore(); + return; + } + ctx.save(); + if (gauntletCrownRunwayBgMapEligiblePlay()) { + drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView); + } else { + drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView); + } + ctx.restore(); + } + + /** + * Shared runway strip math (viewport in strip space). null if not drawable. + * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34) + * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12) + * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9) + */ + function gauntletCrownRunwayScrollGeometryPlay() { + if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; + const zGaRaw = computePlayCameraZDrawPlay(); + const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); + if (!gCam) return null; + const halfW = canvas.width / (2 * zGaRaw); + const worldMinX = gCam.px - halfW; + const cwWorld = canvas.width / zGaRaw; + const h = mapData.height || 15; + const ts = mapData.tileSize || 32; + const mapHpx = Math.max(1, Math.round(h * ts)); + const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx); + const w4Safe = Math.max(w4, 1); + const totalStrip = w0 + w1 + w2 + w3 + w4Safe; + if (!(totalStrip > 8)) return null; + const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX; + const right = scrollView + cwWorld; + const finishStart = w0 + w1 + w2 + w3; + const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe); + const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0; + const minScrollForFinishPass = geoMinScroll + wideVpPad; + return { + scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass, + }; + } + + /** True when เส้น FINISH ถึงตำแหน่งหยุด (แนบคอลัมน์แผนที่ หรือสัดส่วนจอแบบเดิม) */ + function gauntletCrownRunwayScrollStripStopZoneReachedPlay() { + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return false; + const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); + if (alignWorldX != null) { + /* world-based: หยุดเมื่อ "เส้นชัย (world X)" ถึงตัวนำ — ไม่ขึ้นกับความละเอียดจอ + (เดิมมี gate g.scrollView >= minScrollForFinishPass ที่ผูก cwWorld = canvas.width/zoom + → จอกว้างต้อง scroll มากกว่าก่อนหยุด = ตัวละครวิ่งเลยเส้นชัยบนจอที่ resolution ต่างกัน) */ + const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay(); + if (finishMapX == null) return false; + return finishMapX <= alignWorldX + 1.5; + } + /* fallback (ไม่มีตัวนำให้ align) — ใช้ gate scroll + สัดส่วนจอแบบเดิม */ + if (g.scrollView < g.minScrollForFinishPass - 0.01) return false; + const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); + if (screenFrac == null) return false; + return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008; + } + + /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */ + function gauntletCrownRunwayFinishStopScrollPxPlay() { + if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return null; + const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g); + if (finishLineStripX == null) return null; + const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay(); + if (alignWorldX != null) { + return finishLineStripX - alignWorldX; + } + const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay(); + const targetScrollView = finishLineStripX - stopVp * g.cwWorld; + const zGaRaw = computePlayCameraZDrawPlay(); + const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw); + if (!gCam) return null; + const worldMinX = gCam.px - canvas.width / (2 * zGaRaw); + return targetScrollView - worldMinX; + } + + /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */ + function gauntletCrownRunwaySnapScrollToFinishStopPlay() { + const t = gauntletCrownRunwayFinishStopScrollPxPlay(); + if (t == null) return; + gauntletCrownRunwayBgScrollPx = t; + } + + /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → เริ่มเฟสเดินค่อยๆ เข้าเส้นชัย (ไม่ snap กระพริบ) + tick จะเดินทุกคนมาที่เส้นจนครบแล้วค่อยเข้า freeze→จบเกม */ + function gauntletCrownRunwayBeginFinishStopPlay() { + gauntletCrownRunwayBeginWalkToFinishPlay(); + try { draw(); } catch (_d) { /* ignore */ } + } + + /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */ + function gauntletCrownRunwayMaintainFinishScrollPlay() { + if (!gauntletCrownRunwayBgStripFreezeSinceMs && !gauntletCrownRunwayBgFinishLatched) return; + if (!gauntletCrownRunwayBgDrawActivePlay()) return; + if (gauntletCrownRunwayFinishAlignMapWorldXPlay() != null) return; + gauntletCrownRunwaySnapScrollToFinishStopPlay(); + } + + function resetGauntletCrownRunwaySpawnScrollSnap() { + gauntletCrownRunwaySpawnScrollSnapped = false; + } + + /** + * จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0) + * ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32) + * Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง + * Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand. + */ + function trySnapGauntletCrownRunwayScrollToSpawnsPlay() { + if (gauntletCrownRunwaySpawnScrollSnapped) return; + if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return; + if (!gauntletCrownRunwayBgImagesReadyPlay()) return; + const h = mapData.height || 15; + const ts = mapData.tileSize || 32; + const mapHpx = Math.max(1, Math.round(h * ts)); + const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx); + if (!(w0 > 0)) return; + const slots = collectGauntletSpawnSlotsPlay(mapData); + let minTX = Infinity; + if (slots.length) minTX = Math.min(...slots.map((s) => s.x)); + if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x)); + others.forEach((o, id) => { + if (!o || o.gauntletEliminated) return; + if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x)); + }); + if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1; + const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts; + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32)); + gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX; + /* G1: เก็บ base (ค่า snap นี้ — เท่ากันทุกเครื่อง) + anchor เวลาเริ่ม (server epoch) → scroll คำนวณจากเวลา server */ + gauntletCrownRunwayBaseScrollPx = gauntletCrownRunwayBgScrollPx; + /* anchor = liveStartMs จาก server (gauntlet-sync) — authoritative เดียวกันทุกเครื่อง + สดเสมอ + (เดิมใช้ playMissionLiveBeginsAt ที่ค้างจากเกมก่อน → scroll กระโดด/host-client ไม่ตรง) + fallback: liveBeginsAt ถ้าสด ไม่งั้น serverNow() */ + if (gauntletCrownRunwayServerLiveStartMs > 0) { + gauntletCrownRunwayLiveAnchorServerMs = gauntletCrownRunwayServerLiveStartMs; + } else { + const _lba = (typeof playMissionLiveBeginsAt !== 'undefined') ? Number(playMissionLiveBeginsAt) : 0; + const _nowS = serverNow(); + const _el = _nowS - _lba; + gauntletCrownRunwayLiveAnchorServerMs = (_lba > 0 && _el >= -2000 && _el < 10000) ? _lba : _nowS; + } + gauntletCrownRunwaySpawnScrollSnapped = true; + } + + function reloadGauntletCrownRunwayBgFromMap() { + gauntletCrownRunwayBgImgs = [null, null, null, null, null]; + gauntletCrownRunwayBgScrollPx = 0; + gauntletCrownRunwayLiveAnchorServerMs = 0; /* G1: รีเซ็ต anchor รอบใหม่ */ + gauntletCrownRunwayServerLiveStartMs = 0; + gauntletCrownRunwayBaseScrollPx = 0; + gauntletCrownRunwayBgFinishLatched = false; + gauntletFinishPhaseSignaled = false; + gauntletCrownRunwayWalkActive = false; + gauntletCrownRunwayWalkStartMs = 0; + gauntletCrownRunwayBgStripFreezeSinceMs = 0; + gauntletCrownRunwayFinishAlignLatchedWorldX = null; + gauntletCrownRunwayClientMissionShown = false; + lastGauntletCrownRunwayBgTickMs = 0; + resetGauntletCrownRunwaySpawnScrollSnap(); + if (!gauntletCrownRunwayBgMapEligiblePlay()) return; + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + gauntletCrownRunwayBgOff = raw.enabled === false; + gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48)); + if (gauntletCrownRunwayBgOff) return; + const srcs = [ + (typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START, + (typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2, + (typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3, + (typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4, + (typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH, + ]; + let pending = 5; + const bump = () => { + pending--; + if (pending <= 0) { + ensureGauntletCrownRunwayBgPlaceholdersPlay(); + try { draw(); } catch (e) { /* ignore */ } + } + }; + for (let i = 0; i < 5; i++) { + const im = new Image(); + const idx = i; + im.onload = () => { + gauntletCrownRunwayBgImgs[idx] = im; + bump(); + }; + im.onerror = () => { bump(); }; + im.src = srcs[i]; + } + } + + function tickGauntletCrownRunwayBgPlay(dtSec) { + if (!gauntletCrownRunwayBgDrawActivePlay()) return; + /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */ + if (specialQuizFreeze) return; + /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */ + if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return; + if (gauntletCrownRunwayBgFinishLatched) { gauntletSnapLivingToFinishPlay(); return; } + + /* เฟส "เดินเข้าเส้นชัย" — ค่อยๆ เดินทุกคนมาที่เส้นแทน snap กระพริบ แล้วค่อยเข้า freeze→จบ */ + if (gauntletCrownRunwayWalkActive) { + const forceDone = (Date.now() - gauntletCrownRunwayWalkStartMs) >= GAUNTLET_FINISH_WALK_MAX_MS; + const arrived = gauntletWalkLivingToFinishStepPlay(dtSec); + if (arrived || forceDone) { + gauntletCrownRunwayWalkActive = false; + gauntletSnapLivingToFinishPlay(); /* จัดเป๊ะที่เส้นชัยครั้งเดียว */ + gauntletCrownRunwayBgStripFreezeSinceMs = Date.now(); + } + try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ } + try { draw(); } catch (_d) { /* ignore */ } + return; + } + + if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) { + gauntletCrownRunwayMaintainFinishScrollPlay(); + gauntletSnapLivingToFinishPlay(); /* ตรึงทุกคนที่ยังไม่ตายไว้ที่เส้นชัยทุกเฟรม กันบอทไหลหลุด */ + if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) { + gauntletCrownRunwayBgFinishLatched = true; + gauntletCrownRunwayBgStripFreezeSinceMs = 0; + if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) { + gauntletCrownRunwayClientMissionShown = true; + window.setTimeout(function () { + try { + gauntletCrownPregamePhase = null; + gauntletCrownHowtoVisible = false; + const hto = document.getElementById('gauntlet-crown-howto-overlay'); + if (hto) hto.classList.add('is-hidden'); + const gcc = document.getElementById('gauntlet-crown-countdown'); + if (gcc) gcc.classList.add('is-hidden'); + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + /* ตามกฎใหม่: เข้าเส้นชัย/รอด ≥1 คน (ไม่ถูกดันตกขอบ) = result-complete · ตายหมด = result-gameover (ไม่มี timeup) */ + const mg2LocalMission = gauntletCrownHeistBuildLocalCrownMissionPlay(); + const mg2LocalWin = missionSurvivorsPlay(mg2LocalMission) >= 1 || missionAnyScorePlay(mg2LocalMission); + beginSimpleResultFlashThenGcmPlay(mg2LocalMission, mg2LocalWin ? 'result-complete.png' : 'result-gameover.png'); + } catch (_e) { /* ignore */ } + try { + draw(); + } catch (_e2) { /* ignore */ } + }, 340); + } + } + return; + } + + if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) { + gauntletCrownRunwayBeginWalkToFinishPlay(); /* เริ่มเดินค่อยๆ เข้าเส้นชัย (แทน snap+freeze ทันที) */ + try { + draw(); + } catch (_d) { /* ignore */ } + return; + } + /* G1: scroll = base + speed × (serverNow − anchor) → ทุกเครื่องเลื่อนตรงกัน ไม่ขึ้น resolution/frame-rate/host + (เดิม scrollPx += speed×dt นับ local จากเวลาเริ่มของตัวเอง → เลื่อนไม่ตรงกัน + host หลุด/resize เพี้ยน) + fallback เป็นนับ local ถ้ายังไม่มี anchor (กันค้าง) */ + const gcrSpeed = gauntletCrownRunwayBgSpeedPxPerSec || 48; + let gcrTarget; + if (gauntletCrownRunwayLiveAnchorServerMs > 0) { + gcrTarget = gauntletCrownRunwayBaseScrollPx + gcrSpeed * Math.max(0, (serverNow() - gauntletCrownRunwayLiveAnchorServerMs)) / 1000; + } else { + gcrTarget = gauntletCrownRunwayBgScrollPx + gcrSpeed * dtSec; + } + /* ถ้า "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น แล้วเริ่มจบเกมทันที */ + const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay(); + if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null + && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01 + && gcrTarget >= gcrLiveStopPx) { + gauntletCrownRunwayBgScrollPx = gcrLiveStopPx; + gauntletCrownRunwayBeginFinishStopPlay(); + return; + } + /* monotonic — เลื่อนหน้าเท่านั้น (กัน clock jitter ดึงถอย) */ + if (gcrTarget > gauntletCrownRunwayBgScrollPx) gauntletCrownRunwayBgScrollPx = gcrTarget; + } + + /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */ + const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png'; + const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png'; + let stackTowerScrollBgIntroImg = null; + let stackTowerScrollBgLoopImg = null; + let stackTowerScrollBgPx = 0; + let stackTowerScrollBgSpeedPxPerSec = 40; + let stackTowerScrollBgOff = false; + let stackTowerScrollBgFlowDown = false; + /** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */ + let stackTowerScrollBgSrawWorldBaselinePlay = null; + /** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */ + let stackTowerScrollBgLoopDrawHPlay = 0; + /** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */ + let stackTowerBgScrollStepAnimPlay = null; + let stackTowerBgScrollPendingStepDeltaPlay = 0; + + function stackTowerScrollBgMapEligible() { + return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID); + } + + function stackTowerScrollBgImagesReady() { + if (!stackTowerScrollBgMapEligible()) return false; + const a = stackTowerScrollBgIntroImg; + const b = stackTowerScrollBgLoopImg; + return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth); + } + + /** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */ + function stackTowerScrollBgDrawActive() { + return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff; + } + + function reloadStackTowerScrollBgFromMap() { + stackTowerScrollBgIntroImg = null; + stackTowerScrollBgLoopImg = null; + stackTowerScrollBgPx = 0; + stackTowerScrollBgSrawWorldBaselinePlay = null; + stackTowerScrollBgLoopDrawHPlay = 0; + stackTowerBgScrollStepAnimPlay = null; + stackTowerBgScrollPendingStepDeltaPlay = 0; + if (!stackTowerScrollBgMapEligible()) return; + const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {}; + stackTowerScrollBgOff = raw.enabled !== true; + { + const spNum = Number(raw.speedPxPerSec); + stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum) + ? Math.max(0, Math.min(400, Math.floor(spNum))) + : 0; + } + /* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */ + const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); + const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom'); + stackTowerScrollBgFlowDown = !rawIsDownKeyword; + if (stackTowerScrollBgOff) return; + const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO; + const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP; + let pending = 2; + const bump = () => { + pending--; + if (pending <= 0) { + const intro0 = stackTowerScrollBgIntroImg; + const loop0 = stackTowerScrollBgLoopImg; + if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) { + const cwR0 = Math.max(1, Math.round(canvas.width)); + stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth))); + } + stackTowerScrollBgSyncInitialScrollToBottom(); + try { draw(); } catch (e) { /* ignore */ } + } + }; + const im1 = new Image(); + im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); }; + im1.onerror = () => { bump(); }; + im1.src = introSrc; + const im2 = new Image(); + im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); }; + im2.onerror = () => { bump(); }; + im2.src = loopSrc; + } + + function stackTowerScrollBgSyncInitialScrollToBottom() { + const intro = stackTowerScrollBgIntroImg; + if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return; + const cwR = Math.max(1, Math.round(canvas.width)); + const chR = Math.max(1, Math.round(canvas.height)); + const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); + if (stackTowerScrollBgFlowDown) { + stackTowerScrollBgPx = 0; + } else { + /* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */ + stackTowerScrollBgPx = drawHIntro - chR; + } + } + + /** + * พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop + * เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ + */ + function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) { + const dH = Math.max(1, Math.round(Number(drawHLoop) || 1)); + const dI = Math.max(0, Math.round(Number(drawHIntro) || 0)); + const s = Number(S) || 0; + if (s < dI) return s; + return s - Math.floor((s - dI) / dH) * dH; + } + + /** + * offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0) + * เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด) + */ + function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) { + if (Sraw0 == null) return 0; + const dH = Math.max(1, Math.round(Number(drawHLoop) || 1)); + const dI = Math.max(0, Math.round(Number(drawHIntro) || 0)); + const s = Number(Sraw) || 0; + const s0 = Number(Sraw0) || 0; + const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH); + const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH); + return fs - fs0; + } + + /** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */ + function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) { + const intro = stackTowerScrollBgIntroImg; + const loop = stackTowerScrollBgLoopImg; + if (!intro || !intro.complete || !loop || !loop.complete) return; + const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom; + const cwR = Math.max(1, Math.round(cw)); + const chR = Math.max(1, Math.round(ch)); + const scaleI = cwR / intro.naturalWidth; + const drawHIntro = Math.round(intro.naturalHeight * scaleI); + const scaleL = cwR / loop.naturalWidth; + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); + const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0; + const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0; + const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll; + const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop); + const vp0 = S; + const vp1 = S + chR; + const yLimit = vp1 + drawHLoop * 96; + const flowDown = stackTowerScrollBgFlowDown; + + function stripTopToDestY(stripTop, drawH) { + if (!flowDown) return stripTop - S; + return S + chR - (stripTop + drawH); + } + + ctx.save(); + ctx.imageSmoothingEnabled = false; + ctx.beginPath(); + ctx.rect(0, 0, cwR, chR); + ctx.clip(); + + if (vp0 < 0) { + let k = 1; + if (vp0 < -drawHLoop * 4) { + k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2); + } + for (; k < 50000; k++) { + const y0 = -k * drawHLoop; + if (y0 >= vp1 + drawHLoop) break; + if (y0 + drawHLoop <= vp0) continue; + const dy = Math.round(stripTopToDestY(y0, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + } + } + + if (drawHIntro > vp0 && vp1 > 0) { + const dy = Math.round(stripTopToDestY(0, drawHIntro)); + ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro); + } + + let y = drawHIntro; + if (y < yLimit && vp1 > drawHIntro) { + if (vp0 > drawHIntro) { + const n = Math.floor((vp0 - drawHIntro) / drawHLoop); + /* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */ + y = drawHIntro + n * drawHLoop; + } + while (y < yLimit) { + const dy = Math.round(stripTopToDestY(y, drawHLoop)); + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop); + y += drawHLoop; + } + } + ctx.restore(); + } + + /** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */ + function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) { + if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return; + const loop = stackTowerScrollBgLoopImg; + if (!loop || !loop.complete || !loop.naturalWidth) return; + const cwR = Math.max(1, Math.round(cw)); + const chR = Math.max(1, Math.round(ch)); + const scaleL = cwR / loop.naturalWidth; + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL)); + const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4); + const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx; + const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw + + getStackTowerPost50BgScrollExtraPxPlay(chR); + const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop; + let y = -scroll; + ctx.save(); + ctx.imageSmoothingEnabled = false; + while (y < skyScreenH + drawHLoop) { + if (y + drawHLoop > 0) { + ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop); + } + y += drawHLoop; + } + ctx.restore(); + } + + function tickStackTowerBgScrollStepAnimPlay() { + const q = stackTowerBgScrollStepAnimPlay; + if (!q) return; + const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur)); + const e = 1 - Math.pow(1 - t, 3); + stackTowerScrollBgPx = q.from + (q.to - q.from) * e; + if (t >= 1) { + stackTowerScrollBgPx = q.to; + stackTowerBgScrollStepAnimPlay = null; + stackTowerScrollBgSrawWorldBaselinePlay = null; + if (stackTowerBgScrollPendingStepDeltaPlay !== 0) { + const d = stackTowerBgScrollPendingStepDeltaPlay; + stackTowerBgScrollPendingStepDeltaPlay = 0; + startStackTowerBgScrollStepAnimPlay(d); + } + } + } + + function getStackTowerBgScrollStepEveryLayersPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!raw || typeof raw !== 'object') return 0; + const n = Math.floor(Number(raw.stepEveryLayers)); + return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0; + } + + function getStackTowerBgScrollStepAnimMsPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!raw || typeof raw !== 'object') return 520; + const t = Math.floor(Number(raw.stepAnimMs)); + return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520; + } + + function getStackTowerBgScrollStepScrollPxPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!raw || typeof raw !== 'object') return 0; + const d = Math.floor(Number(raw.stepScrollPx)); + return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0; + } + + function startStackTowerBgScrollStepAnimPlay(deltaPx) { + if (!stackTowerScrollBgDrawActive() || !deltaPx) return; + if (stackTowerBgScrollStepAnimPlay) { + stackTowerBgScrollPendingStepDeltaPlay += deltaPx; + return; + } + const dur = getStackTowerBgScrollStepAnimMsPlay(); + stackTowerBgScrollStepAnimPlay = { + from: stackTowerScrollBgPx, + to: stackTowerScrollBgPx + deltaPx, + t0: performance.now(), + dur, + }; + } + + function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) { + if (!stackTowerScrollBgDrawActive()) return; + const every = getStackTowerBgScrollStepEveryLayersPlay(); + if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return; + let delta = getStackTowerBgScrollStepScrollPxPlay(); + if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200); + delta = Math.min(8000, Math.max(8, delta)); + startStackTowerBgScrollStepAnimPlay(delta); + } + + function getStackTowerStripReleaseGapWorldPxPlay() { + const raw = mapData && mapData.stackTowerBgScroll; + if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null; + const g = Number(raw.releaseGapWorldPx); + if (!Number.isFinite(g) || g < 0) return null; + return Math.min(800, g); + } + + function updateStackTowerSwingYForStripGapPlay() { + if (!stackMini || !isStackTowerMissionUiMapPlay()) return; + const gap = getStackTowerStripReleaseGapWorldPxPlay(); + if (gap == null) return; + const m = stackMini; + const lh = m.layerWorldH || Math.max(14, tileSize * 0.3); + const nLay = m.layers ? m.layers.length : 0; + const topBlockTopY = m.floorWorldY - nLay * lh; + const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh); + m.swingWorldY = topBlockTopY - gap + swingLift; + } + + function tickStackTowerScrollBg(dtSec) { + if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return; + if (!stackTowerScrollBgImagesReady()) return; + tickStackTowerBgScrollStepAnimPlay(); + if (stackTowerBgScrollStepAnimPlay) return; + const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec; + if (spd <= 0) return; + /* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */ + stackTowerScrollBgPx += spd; + } + + /** + * ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50) + * แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง) + */ + function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) { + if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0; + const zPn = Number(zPan) > 0 ? zPan : zoom; + const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720)); + const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn; + const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx); + const intro = stackTowerScrollBgIntroImg; + const loop = stackTowerScrollBgLoopImg; + let totalScreen = boostScreen + post50Screen; + if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth + && loop && loop.complete && loop.naturalWidth && canvas && canvas.width) { + const cwR = Math.max(1, Math.round(canvas.width)); + const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth)); + const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth))); + const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen; + if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw; + const S0 = stackTowerScrollBgSrawWorldBaselinePlay; + let stripY; + if (stackTowerScrollBgFlowDown) { + const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop); + stripY = (boostScreen + post50Screen) * 2 - Sview; + } else { + stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0); + } + totalScreen = stripY; + } else if (stackTowerScrollBgDrawActive()) { + const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx; + totalScreen = px + boostScreen + post50Screen; + } + return totalScreen; + } + + /** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */ + let mapGridImageImgs = []; + let mapGridImageHeldImgs = []; + let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 }; + const others = new Map(); + /** ===== MG3 terminal log (เกมจริง) — hacking-terminal ตามสเปก #mg3-terminal-log ===== */ + let mg3TermLog = []; + let mg3TermLineNo = 0; + const MG3_TERM_MAX = 240; + function mg3TermPush(lines) { + const arr = Array.isArray(lines) ? lines : [lines]; + for (let i = 0; i < arr.length; i++) mg3TermLog.push(String(arr[i] == null ? '' : arr[i])); + while (mg3TermLog.length > MG3_TERM_MAX) mg3TermLog.shift(); + } + function mg3TermReset() { mg3TermLog = []; mg3TermLineNo = 0; } + function mg3TermBoot() { + mg3TermLog = []; mg3TermLineNo = 0; + mg3TermPush([ + '[INIT] Booting decryption sequence...', + '[SYS] Locating target server...', + '[SYS] Connection established. Status: SECURE.', + '[HACK] Bypassing "Vault Lockdown" protocol...', + '[HACK] Disabling Cyber Security Check...', + '[LOAD] Injecting Decryption_Core.exe...', + '[READY] Awaiting manual code block alignment...', + '>>> STACKING SEQUENCE START <<<', + ]); + } + function mg3TermOnPlace(perfect, progressPct) { + mg3TermLineNo += 1; + const n = mg3TermLineNo; + if (n >= 100 || (Number.isFinite(Number(progressPct)) && Number(progressPct) >= 99.5)) { + mg3TermPush([ + '>>> Compile Line [100]: SUCCESS. Maximum stack reached.', + '[DATA]: Decryption progress: 100%. Master key generated.', + '[HACK]: "Vault Lockdown" bypassed. Firewall disabled.', + '[SYS]: Evidence files unlocked. Initiating secure download...', + '>>> MISSION ACCOMPLISHED. Disconnecting safely. <<<', + ]); + } else if (perfect) { + mg3TermPush('>>> Compile Line [' + n + ']: PERFECT. Synergy bonus!'); + } else { + mg3TermPush('>>> Compile Line [' + n + ']: SUCCESS. Stack stable.'); + } + } + function mg3TermOnMiss(attemptsRemaining) { + if (attemptsRemaining <= 0) { + mg3TermPush([ + '[CRITICAL]: Packet loss detected. Compile FAILED. (Attempts remaining: 0)', + '[ALERT]: Cyber Security Check triggered! Intruder traced.', + '[SYS]: Initiating "Vault Lockdown" protocol...', + '>>> FATAL ERROR: Connection Terminated. SYSTEM LOCKED. <<<', + ]); + } else { + mg3TermPush('[WARNING]: Packet loss detected. Compile FAILED. (Attempts remaining: ' + attemptsRemaining + '/3)'); + } + } + + /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */ + const STACK_PREVIEW_HUD_LOG_MAX = 8; + let stackPreviewHudLog = []; + function stackPreviewPushHudLog(line) { + if (!playBotsEnabled()) return; + stackPreviewHudLog.push(String(line || '').slice(0, 96)); + while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift(); + } + function stackPreviewLogStackDrop(hit, actor) { + if (!playBotsEnabled() || !hit) return; + const bid = actor && actor.botId; + let nm = 'NODE'; + if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12); + else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12); + if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`); + else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`); + else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`); + } + const keys = {}; + const characterImages = {}; + /** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */ + const cyberHudScoreAvatarUrlCache = new Map(); + const CYBER_HUD_AV_URL_CACHE_CAP = 56; + function createDefaultAvatarImg() { + const c = document.createElement('canvas'); + c.width = 64; c.height = 64; + const ctx = c.getContext('2d'); + ctx.fillStyle = '#7aa2f7'; + ctx.beginPath(); + ctx.arc(32, 22, 14, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#9ece6a'; + ctx.beginPath(); + ctx.arc(32, 48, 18, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#1a1b26'; + ctx.beginPath(); + ctx.arc(28, 20, 3, 0, Math.PI * 2); + ctx.arc(36, 20, 3, 0, Math.PI * 2); + ctx.fill(); + const img = new Image(); + img.src = c.toDataURL('image/png'); + return img; + } + const defaultAvatarImg = createDefaultAvatarImg(); + const characterAnimations = {}; + const characterIdleAnimations = {}; + const CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */ + /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */ + function gauntletRunnerAnimActivePlay() { + try { + return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay(); + } catch (e) { return false; } + } + + function walkAnimPhaseIndex(now, isWalking) { + if (!isWalking) return 0; + const t = (typeof now === 'number' ? now : Date.now()); + /* gauntlet วิ่งหลบ: เร่งจังหวะขาให้ "วิ่ง" — 0.40 (80ms/เฟรม) เร็วสุดที่ยังลื่น ไม่กระตุก + (sprite มีแค่ 4 เฟรม ถ้าเร็วกว่านี้ เช่น 0.27 เฟรมจะสลับถี่เกินจนดูสั่น/กระตุก) */ + const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.40 : CHARACTER_ANIM_FRAME_MS; + return Math.floor(t / frameMs) % CHARACTER_ANIM_FRAMES; + } + + /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */ + function pickLoadedWalkFrameIndex(anim, phase) { + if (!anim || !anim.frames || !anim.frames.length) return -1; + const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1); + for (let k = maxK; k >= 0; k--) { + const f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */ + function isUploadedCharAssetId(id) { + if (!id) return false; + const s = String(id).trim(); + return /^char-\d/i.test(s); + } + + function useMultiFrameIdleSpriteSheets(id) { + if (!id) return true; + return !isUploadedCharAssetId(id); + } + + function ensureCharacterIdleAnim(id, dir) { + const d = dir || 'down'; + /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */ + if (!useMultiFrameIdleSpriteSheets(id)) { + const key = String(id) + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } + const key = id + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } + + /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */ + function idleAnimPhaseIndex(now) { + const t = typeof now === 'number' ? now : Date.now(); + return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; + } + + function characterIdleSpritesVisible(id, dir) { + if (!id) return false; + const anim = ensureCharacterIdleAnim(id, dir || 'down'); + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true; + for (let i = 0; i < anim.frames.length; i++) { + const f = anim.frames[i]; + if (f && f.complete && f.naturalWidth) return true; + } + return false; + } + + /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */ + const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' }; + const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1']; + const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe']; + const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe']; + const playLayerMode = {}; + /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */ + const playLayerAllDirsQueued = {}; + /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */ + let playCharLayerApi = { status: 'idle', map: null }; + /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */ + const playCharDiskLayersById = Object.create(null); + + function ensurePlayCharLayerListFetch() { + if (playCharLayerApi.status !== 'idle') return; + playCharLayerApi.status = 'loading'; + fetch(BASE + '/api/characters') + .then((r) => r.json()) + .then((list) => { + const map = Object.create(null); + playCharacterIdRoster = []; + if (Array.isArray(list)) { + list.forEach((c) => { + if (!c || !c.id) return; + playCharacterIdRoster.push(String(c.id)); + map[c.id] = !!c.hasLayerFiles; + if (Array.isArray(c.layers) && c.layers.length) { + const st = new Set(); + c.layers.forEach((n) => { + if (typeof n === 'string' && n.length) st.add(n); + }); + if (st.size) playCharDiskLayersById[c.id] = st; + else delete playCharDiskLayersById[c.id]; + } else { + delete playCharDiskLayersById[c.id]; + } + }); + } + playCharLayerApi = { status: 'done', map }; + }) + .catch(() => { + playCharacterIdRoster = []; + playCharLayerApi = { status: 'done', map: Object.create(null) }; + }); + } + + /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */ + function playCharLayerFromApi(characterId) { + if (playCharLayerApi.status !== 'done' || !characterId) return null; + if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) { + return playCharLayerApi.map[characterId]; + } + return null; + } + + /** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */ + function playCharDiskLayersSet(characterId) { + if (!characterId || playCharLayerApi.status !== 'done') return null; + const s = playCharDiskLayersById[characterId]; + return s && s.size ? s : null; + } + + const playLayerImageCache = new Map(); + const playLayerCompositeCache = new Map(); + function pickRandomPlayTint() { + return { + head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)], + hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)], + body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)], + }; + } + + /** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */ + function hashStringFnv1a32(str) { + let h = 2166136261 >>> 0; + const s = String(str || ''); + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h >>> 0; + } + + function playTintFromPeerId(id) { + const s = String(id || 'x'); + const h0 = hashStringFnv1a32(s); + const h1 = hashStringFnv1a32(s + '\n1hair'); + const h2 = hashStringFnv1a32(s + '\n2body'); + return { + head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length], + hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length], + body: PLAY_TINT_BODY[h2 % PLAY_TINT_BODY.length], + }; + } + + const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint'; + /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */ + const PLAY_LOBBY_THEME_HEX = [ + '#d72520', '#ef8508', '#efe237', '#5bb443', + '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe', + ]; + const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1']; + let playLobbyBotThemes = []; + + function parsePlayLobbyThemeIndex(n) { + const v = Math.floor(Number(n)); + return (v >= 1 && v <= 8) ? v : null; + } + + function parsePlayLobbySkinIndex(n) { + const v = Math.floor(Number(n)); + return (v >= 1 && v <= 3) ? v : null; + } + + function playTintFromLobbyThemeIndices(themeIndex, skinIndex) { + const ti = parsePlayLobbyThemeIndex(themeIndex); + const si = parsePlayLobbySkinIndex(skinIndex); + const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : ''; + const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : ''; + if (!bodyHex && !headHex) return null; + return playTintFromLobbyRgb(bodyHex || null, headHex || null); + } + + function playTintFromJoinPeer(peerRow) { + if (!peerRow) return null; + return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex); + } + + function playTintForPreviewBotId(peerId) { + if (!isPreviewBotId(peerId)) return null; + const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10); + if (!Number.isFinite(idx) || idx < 0) return null; + /* ใช้ธีมจาก server (กันสีซ้ำอยู่แล้ว) — ถ้าไม่มี ตกไปธีมแบบ "กระจายตามดัชนี" (ไม่สุ่ม hash ที่ซ้ำได้) + กัน bot สีเดียวกัน: bot N → ธีม ((N % 8)+1), สกิน ((N % 3)+1) */ + /* 1) สีที่ lobby บันทึกไว้ (ให้ตรงกับ lobby เป๊ะ) — ไม่เขียนทับ */ + try { + const savedMap = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); + const saved = savedMap['__lobby_bot_' + idx]; + if (saved && (saved.colorTheme || saved.colorSkin)) { + const st = playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin); + if (st) return st; + } + } catch (e) { /* ignore */ } + /* 2) ไม่มีค่าจาก lobby → ใช้ธีม server / กระจายตามดัชนี แล้วบันทึกไว้ */ + const slot = playLobbyBotThemes[idx] || { themeIndex: (idx % 8) + 1, skinToneIndex: (idx % 3) + 1 }; + const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex); + if (!tint) return null; + try { + const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); + map['__lobby_bot_' + idx] = { + colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '', + colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '', + }; + sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map)); + } catch (e) { /* ignore */ } + return tint; + } + + function lobbyBotTintsStorageKeyPlay() { + const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default'); + return 'justiceLobbyBotTints:' + sp; + } + + function rgbCssToHexPlay(rgb) { + if (!rgb) return ''; + const s = String(rgb).trim(); + if (s.charAt(0) === '#') return s; + const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (!m) return ''; + return '#' + [m[1], m[2], m[3]].map((n) => { + const v = Math.max(0, Math.min(255, parseInt(n, 10))); + return v.toString(16).padStart(2, '0'); + }).join(''); + } + + function playTintFromLobbyRgb(themeRgb, skinRgb) { + const bodyHex = rgbCssToHexPlay(themeRgb); + const headHex = rgbCssToHexPlay(skinRgb); + if (!headHex && !bodyHex) return null; + return { + head: headHex || '#fbd5c4', + hair: bodyHex || '#fb4941', + body: bodyHex || '#fb4941', + }; + } + + function loadPlayTintFromLobbyPlayerStorage() { + try { + const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); + if (raw) { + const j = JSON.parse(raw); + if (j && typeof j === 'object') { + if (j.head && j.body) { + return { + head: String(j.head), + hair: String(j.hair || j.body), + body: String(j.body), + }; + } + const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb); + if (fromJson) return fromJson; + } + } + } catch (e) { /* ignore */ } + try { + const theme = localStorage.getItem('lobbyThemeColor') || ''; + const skin = localStorage.getItem('lobbySkinTone') || ''; + return playTintFromLobbyRgb(theme, skin); + } catch (e2) { + return null; + } + } + + /** แปลง hex/rgb → {r,g,b} (กันค่าเพี้ยน) */ + function hexOrRgbToRgbPlay(v) { + const s0 = String(v || '').trim(); + const m = s0.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i); + if (m) return { r: +m[1], g: +m[2], b: +m[3] }; + let s = s0.replace('#', ''); + if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; + if (!/^[0-9a-fA-F]{6}$/.test(s)) return null; + const n = parseInt(s, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + function nearestIndexFromHexPlay(value, palette) { + const asIdx = Math.floor(Number(value)); + if (asIdx >= 1 && asIdx <= palette.length) return asIdx; /* เก็บเป็น index อยู่แล้ว */ + const t = hexOrRgbToRgbPlay(value); + if (!t) return null; + let best = -1; let bestD = Infinity; + for (let i = 0; i < palette.length; i++) { + const c = hexOrRgbToRgbPlay(palette[i]); + if (!c) continue; + const d = (c.r - t.r) * (c.r - t.r) + (c.g - t.g) * (c.g - t.g) + (c.b - t.b) * (c.b - t.b); + if (d < bestD) { bestD = d; best = i; } + } + return best >= 0 ? best + 1 : null; + } + /** theme/skin index ที่ผู้เล่นเลือกไว้ (จาก localStorage) — ส่งให้ server เพื่อกันบอทใช้สีเดียวกับเรา */ + function getMyChosenLobbyColorIndicesPlay() { + let body = ''; + let skin = ''; + try { + const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); + if (raw) { const j = JSON.parse(raw); if (j) { body = j.body || j.themeRgb || ''; skin = j.skinRgb || j.skin || ''; } } + } catch (e) { /* ignore */ } + try { + if (!body) body = localStorage.getItem('lobbyThemeColor') || ''; + if (!skin) skin = localStorage.getItem('lobbySkinTone') || ''; + } catch (e2) { /* ignore */ } + return { + themeIndex: nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX), + skinIndex: nearestIndexFromHexPlay(skin, PLAY_LOBBY_SKIN_HEX), + }; + } + + function loadPlayTintForBotPeerId(peerId) { + const sid = String(peerId || ''); + let botKey = sid; + if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) { + botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length); + } + try { + const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()); + if (!raw) return null; + const map = JSON.parse(raw); + const saved = map && map[botKey]; + if (!saved) return null; + return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin); + } catch (e) { + return null; + } + } + + /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */ + function resolvePlayTintForPeer(peerId, peerRow) { + const sid = String(peerId || ''); + /* ตัวเราเอง: ยึด "สีที่เลือกไว้" (localStorage) ก่อนทุกอย่าง — กัน peerRow/theme-index จาก server มาทับ (สีกระพริบ) */ + if (myId != null && sid === String(myId)) { + const meChosen = loadPlayTintFromLobbyPlayerStorage(); + if (meChosen) return meChosen; + } + const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null; + if (fromRow) return fromRow; + const previewTint = playTintForPreviewBotId(sid); + if (previewTint) return previewTint; + if (myId != null && sid === String(myId)) { + const meTint = loadPlayTintFromLobbyPlayerStorage(); + if (meTint) return meTint; + } + if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint; + const botTint = loadPlayTintForBotPeerId(sid); + if (botTint) return botTint; + return playTintFromPeerId(sid); + } + + function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) { + const sid = peerId != null ? String(peerId) : ''; + /* server-assigned theme index (อาจถูก reassign ตอนเลือกสีชนกัน) — เก็บไว้ใช้ลงสี "block MG3" ให้ตรงกันทุกจอ + (เดิม self ใช้สีจาก localStorage แต่ others ใช้ index จาก server → block สีไม่ตรง 2 จอ) */ + const tiNum = parsePlayLobbyThemeIndex(themeIndex); + if (myId != null && sid === String(myId)) { + if (tiNum) me.lobbyThemeIndexSrv = tiNum; + /* ตัวเราเอง: ใช้ "สีจาก server index" (authoritative) เป็นหลัก — ให้สีเรา "ตรงกันทุกจอ" + block MG3 ตรงกับ avatar + เดิม override ด้วย localStorage → จอเราเห็นสีที่เลือก แต่จออื่น+block MG3 ใช้ server index = ไม่ตรง + (เคส server reassign สีตอนเลือกชนกัน → server index คือสีจริง ต้องยึดอันนี้) */ + const tintMe = (tiNum) ? playTintFromLobbyThemeIndices(themeIndex, skinIndex) : null; + if (tintMe) { me.playTint = tintMe; return; } + const meChosen = loadPlayTintFromLobbyPlayerStorage(); + if (meChosen) me.playTint = meChosen; + return; + } + const oEarly = others.get(peerId) || others.get(sid); + if (oEarly && tiNum) oEarly.lobbyThemeIndexSrv = tiNum; + const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex); + if (!tint) return; + const o = others.get(peerId) || others.get(sid); + if (o) o.playTint = tint; + if (isPreviewBotId(sid)) playTintForPreviewBotId(sid); + } + + function applyPlayLobbyTintSync(data) { + if (!data || typeof data !== 'object') return; + if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes; + if (Array.isArray(data.peers)) { + data.peers.forEach(function (p) { + if (!p || p.id == null) return; + applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex); + }); + } + if (playBotsEnabled()) rebalancePreviewBots(); + try { + preloadPlayOccupantsTints(); + preloadPlayLobbyIdleAvatarsForOccupants(); + } catch (e) { /* ignore */ } + } + + /** วินิจฉัยสีในเกม: รันใน console บนแต่ละเครื่อง → เทียบสีที่ render ของทุกตัว */ + window.__colorDebug = function () { + var rows = []; + rows.push({ who: '*ME*', id: String(myId == null ? '' : myId).slice(0, 6), renderTint: JSON.stringify(me && me.playTint) }); + others.forEach(function (o, id) { + rows.push({ who: isPreviewBotId(id) ? 'BOT' : 'peer', id: String(id).slice(0, 10), name: o.nickname || '', renderTint: JSON.stringify(o.playTint) }); + }); + try { console.table(rows); } catch (e) { console.log(JSON.stringify(rows, null, 2)); } + var lsTheme = '?'; var lsTint = '?'; + try { lsTheme = localStorage.getItem('lobbyThemeColor'); lsTint = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); } catch (e) { /* ignore */ } + console.log('[color-dbg/game] myId=' + myId + ' playLobbyBotThemes=' + JSON.stringify(playLobbyBotThemes) + + ' LS.lobbyThemeColor=' + lsTheme + ' LS.PLAYER_TINT=' + lsTint); + return rows; + }; + + function getPlayTintForMissionAvatar(peerId) { + const sid = peerId != null ? String(peerId) : ''; + if (myId != null && sid === String(myId)) { + return me.playTint || resolvePlayTintForPeer(sid); + } + const o = others.get(peerId) || others.get(sid); + if (o && o.playTint) return o.playTint; + return resolvePlayTintForPeer(sid); + } + + /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */ + function missionEnsureHumanInTopRanked(rows, maxN) { + const cap = Math.max(1, maxN || 5); + if (!Array.isArray(rows) || !rows.length) return []; + const humanId = myId != null ? String(myId) : null; + const humanRow = humanId + ? rows.find((r) => r && r.id != null && String(r.id) === humanId) + : rows.find((r) => r && r.isLocalHuman); + let top = rows.slice(0, cap); + if (!humanRow) return top; + const hid = humanRow.id != null ? String(humanRow.id) : ''; + if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top; + if (top.length >= cap) top = top.slice(0, cap - 1); + top.push(humanRow); + return top; + } + + function hexToRgb01(hex) { + const h = (hex || '').replace('#', '').trim(); + if (h.length !== 6) return [1, 1, 1]; + return [ + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ]; + } + + function tintPlayLayerImageData(imageData, tintHex) { + const rgb = hexToRgb01(tintHex); + const tr = rgb[0], tg = rgb[1], tb = rgb[2]; + const d = imageData.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 12) continue; + const r = d[i], g = d[i + 1], b = d[i + 2]; + const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + d[i] = Math.min(255, Math.round(tr * 255 * L)); + d[i + 1] = Math.min(255, Math.round(tg * 255 * L)); + d[i + 2] = Math.min(255, Math.round(tb * 255 * L)); + } + } + + function drawPlayTintedLayer(ctx, w, h, img, tintHex) { + if (!tintHex) { + ctx.drawImage(img, 0, 0, w, h); + return; + } + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const x = c.getContext('2d'); + x.drawImage(img, 0, 0, w, h); + try { + const idata = x.getImageData(0, 0, w, h); + tintPlayLayerImageData(idata, tintHex); + x.putImageData(idata, 0, 0); + } catch (e) { + x.clearRect(0, 0, w, h); + x.drawImage(img, 0, 0, w, h); + } + ctx.drawImage(c, 0, 0, w, h); + } + + function ensurePlayLayerImage(url) { + let img = playLayerImageCache.get(url); + if (!img) { + img = new Image(); + img.src = url; + playLayerImageCache.set(url, img); + } + return img; + } + + /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม + * ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */ + const _playPreloadedChars = new Set(); + const _playPreloadedTintKeys = new Set(); + function preloadPlayCharacterLayers(charId, _tries) { + if (!charId || _playPreloadedChars.has(charId)) return; + try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ } + try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ } + const diskSet = playCharDiskLayersSet(charId); + if (!diskSet) { + // manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404) + const t = _tries || 0; + if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450); + return; + } + _playPreloadedChars.add(charId); + const dirs = ['down', 'up', 'left', 'right']; + const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); }; + dirs.forEach(function (dir) { + PLAY_LAYER_ORDER.forEach(function (ln) { + if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์ + if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face + for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) { + warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi)); + } + warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0)); + }); + }); + } + + /** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */ + function preloadPlayTintForAvatar(charId, tint) { + if (!charId || !tint || typeof tint.head !== 'string') return; + const key = [charId, tint.head, tint.hair, tint.body].join('|'); + if (_playPreloadedTintKeys.has(key)) return; + _playPreloadedTintKeys.add(key); + preloadPlayCharacterLayers(charId); + const dirs = ['down', 'up', 'left', 'right']; + const now = Date.now(); + dirs.forEach(function (dir) { + const rawWalk = getCharacterFrame(charId, dir, now, true); + const rawIdle = getCharacterFrame(charId, dir, now, false); + if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint); + if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint); + }); + } + + function preloadPlayOccupantsTints() { + const meCid = me.characterId || getPlayCharacterId(); + if (meCid) { + preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')))); + } + others.forEach(function (o, id) { + if (!o || !o.characterId) return; + preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id)); + }); + } + + /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */ + function layerUrlCandidates(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + add(base + '_layer_' + layerName + '.png'); + return out; + } + + /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */ + function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle'; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + // idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว + add(base + '_layer_' + layerName + '.png'); + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + return out; + } + + function shadowUrlCandidates(id, dir, frameIndex) { + const c = layerUrlCandidates(id, dir, 'shadow', frameIndex); + const def = BASE + '/img/default-shadow-' + dir + '.png'; + if (c.indexOf(def) === -1) c.push(def); + return c; + } + + function shadowUrlCandidatesIdle(id, dir, frameIndex) { + const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex); + const def = BASE + '/img/default-shadow-' + dir + '.png'; + if (c.indexOf(def) === -1) c.push(def); + return c; + } + + /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */ + function resolvePlayLayerImage(urls) { + for (let i = 0; i < urls.length; i++) { + const img = ensurePlayLayerImage(urls[i]); + if (!img.complete) return { status: 'pending' }; + if (img.naturalWidth > 0) return { status: 'ok', img }; + } + return { status: 'missing' }; + } + + function ensurePlayLayerProbesAllDirections(characterId) { + if (!characterId || playLayerAllDirsQueued[characterId]) return; + playLayerAllDirsQueued[characterId] = true; + ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d)); + } + + function playCharAnyDirectionLayered(characterId) { + if (!characterId) return false; + return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered'); + } + + /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */ + function playCharLayerDiscoveryPending(characterId) { + if (!characterId) return true; + const api = playCharLayerFromApi(characterId); + if (api === true || api === false) return false; + if (playCharAnyDirectionLayered(characterId)) return false; + return ['up', 'down', 'left', 'right'].some((d) => { + const m = playLayerMode[characterId + '|' + d]; + return m === undefined || m === 'pending'; + }); + } + + function ensurePlayLayerProbe(characterId, dir) { + const key = characterId + '|' + dir; + if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return; + if (playLayerMode[key] === 'pending') return; + playLayerMode[key] = 'pending'; + const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0); + let ci = 0; + function tryNextProbe() { + if (playLayerMode[key] !== 'pending') return; + if (ci >= cands.length) { + playLayerMode[key] = 'none'; + return; + } + const url = cands[ci++]; + const img = ensurePlayLayerImage(url); + const fin = () => { + if (playLayerMode[key] !== 'pending') return; + if (img.naturalWidth > 0) { + playLayerMode[key] = 'layered'; + return; + } + tryNextProbe(); + }; + img.onload = fin; + img.onerror = fin; + if (img.complete) fin(); + } + tryNextProbe(); + } + + function getCharacterAnimFrameIndex(id, dir, now, isWalking) { + if (!id) return 0; + const key = id + '_' + dir; + const anim = characterAnimations[key]; + if (!anim) return 0; + const phase = walkAnimPhaseIndex(now, isWalking); + const fi = pickLoadedWalkFrameIndex(anim, phase); + return fi >= 0 ? fi : 0; + } + + function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) { + const useIdle = opts && opts.idle; + if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null; + const w = rawImg.naturalWidth, h = rawImg.naturalHeight; + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const cctx = c.getContext('2d'); + let anyTintColorLayer = false; + let skippedShadowWhilePending = false; + const diskLayers = playCharDiskLayersSet(id); + /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */ + const idleLayerStripes = !!useIdle; + const resDir = dir; + const resFi = frameIndex; + for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { + const layerName = PLAY_LAYER_ORDER[li]; + if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue; + /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */ + if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') { + continue; + } + let urls; + if (layerName === 'shadow') { + urls = idleLayerStripes + ? shadowUrlCandidatesIdle(id, dir, frameIndex) + : shadowUrlCandidates(id, resDir, resFi); + } else { + urls = idleLayerStripes + ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) + : layerUrlCandidates(id, resDir, layerName, resFi); + } + const r = resolvePlayLayerImage(urls); + /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */ + if (r.status === 'pending' && layerName === 'shadow') { + skippedShadowWhilePending = true; + continue; + } + if (r.status === 'pending') return null; + if (r.status === 'missing') continue; + const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null; + if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true; + drawPlayTintedLayer(cctx, w, h, r.img, tintHex); + } + /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */ + if (!anyTintColorLayer) return null; + return { canvas: c, skipCache: skippedShadowWhilePending }; + } + + function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) { + if (!tint || !characterId || !rawImg) return rawImg; + ensurePlayCharLayerListFetch(); + ensurePlayLayerProbesAllDirections(characterId); + const dirn = dir || 'down'; + const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking); + const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now()); + + if (playCharLayerDiscoveryPending(characterId)) return rawImg; + + const apiFlag = playCharLayerFromApi(characterId); + /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */ + const charHasLayerFiles = apiFlag === true + || (apiFlag == null && playCharAnyDirectionLayered(characterId)); + + /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */ + if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) { + return rawImg; + } + const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|'); + + if (charHasLayerFiles) { + if (!isWalking) { + const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase; + const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); + const hitI = playLayerCompositeCache.get(cacheKeyIdle); + if (hitI) return hitI; + const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true }); + if (packI && packI.canvas) { + if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas); + return packI.canvas; + } + } + const hit = playLayerCompositeCache.get(cacheKeyWalk); + if (hit) return hit; + const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false }); + if (pack && pack.canvas) { + if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas); + return pack.canvas; + } + return rawImg; + } + + /* + * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม) + */ + return rawImg; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const key = id + '_' + (direction || 'down'); + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png'; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const t = typeof now === 'number' ? now : Date.now(); + if (!isWalking) { + const idleAnim = ensureCharacterIdleAnim(id, dir); + let idlePhase = idleAnimPhaseIndex(t); + if (isUploadedCharAssetId(id)) idlePhase = 0; + const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase); + if (idleFi >= 0) return idleAnim.frames[idleFi]; + const idfb = idleAnim.fallback; + if (idfb && idfb.complete && idfb.naturalWidth) return idfb; + } + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png'; + anim.frames.push(img); + } + anim.fallback = getCharacterImg(id, dir); + } + const phase = walkAnimPhaseIndex(now, isWalking); + const fi = pickLoadedWalkFrameIndex(anim, phase); + if (fi >= 0) return anim.frames[fi]; + const fb = anim.fallback; + if (fb && fb.complete && fb.naturalWidth) return fb; + return null; + } + + function getAvatarImg(characterId, direction, now, isWalking) { + /* 'Chatest' = placeholder ที่ไม่มีไฟล์ sprite จริง → อย่าโหลด (กัน 404 รัว) ใช้ default avatar generated แทน */ + const validId = characterId && characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID; + const img = validId ? getCharacterFrame(characterId, direction, now, isWalking) : null; + if (img) return img; + return defaultAvatarImg; + } + + /** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:'; + const playLobbyIdleAvatarCache = new Map(); + + function playLobbyIdleAvatarSrcForCharacter(characterId) { + const id = String(characterId || '').trim(); + if (!id) return ''; + try { + const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || ''; + if (ss && ss.indexOf('data:image/') === 0) return ss; + const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || ''; + if (ls && ls.indexOf('data:image/') === 0) return ls; + } catch (e) { /* ignore */ } + return ''; + } + + function ensurePlayLobbyIdleAvatarImage(characterId) { + const id = String(characterId || '').trim(); + if (!id) return; + const hit = playLobbyIdleAvatarCache.get(id); + if (hit && hit.complete && hit.naturalWidth) return; + if (playLobbyIdleAvatarCache.get(id + '_loading')) return; + const src = playLobbyIdleAvatarSrcForCharacter(id); + if (!src) return; + const img = new Image(); + img.onload = function () { + playLobbyIdleAvatarCache.set(id, img); + playLobbyIdleAvatarCache.delete(id + '_loading'); + }; + img.onerror = function () { + playLobbyIdleAvatarCache.delete(id + '_loading'); + }; + img.src = src; + playLobbyIdleAvatarCache.set(id + '_loading', img); + } + + function getPlayLobbyIdleAvatarImageSync(characterId) { + const id = String(characterId || '').trim(); + if (!id) return null; + const hit = playLobbyIdleAvatarCache.get(id); + if (hit && hit.complete && hit.naturalWidth) return hit; + ensurePlayLobbyIdleAvatarImage(id); + return null; + } + + /** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง ตอนยืน); ตอนเดินใช้ strip เดิน down_0..3 */ + function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) { + /* placeholder 'Chatest' ไม่มี sprite/เลเยอร์จริง → ปฏิบัติเป็นว่าง (ใช้ default avatar) กัน 404 รัวทุกเส้นทางวาด */ + if (characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) characterId = ''; + const dir = direction || 'down'; + const walk = !!isWalking; + if (dir === 'down' && useLobbyIdleComposite && !walk) { + const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId); + if (lobbyImg) return lobbyImg; + } + const rawImg = getAvatarImg(characterId, dir, now, walk); + if (!playTint || !characterId) return rawImg; + return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg; + } + + function preloadPlayLobbyIdleAvatarsForOccupants() { + const meCid = me.characterId || getPlayCharacterId(); + if (meCid) ensurePlayLobbyIdleAvatarImage(meCid); + others.forEach(function (o) { + if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId); + }); + } + + function playHideTopChrome() { + try { + return document.documentElement.classList.contains('play-hide-top-chrome'); + } catch (e) { + return true; + } + } + + function getStoredCharacterId() { + try { + const v = localStorage.getItem('gameCharacterId'); + if (v) return v; + } catch (e) { /* ignore */ } + if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID; + return firstCharacterDefaultResolved || ''; + } + function getPlayCharacterId() { + if (forceDefaultCharacter) { + if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID; + return firstCharacterDefaultResolved || ''; + } + return getStoredCharacterId(); + } + + /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */ + function getJdPlayerKey() { + try { + let k = (localStorage.getItem('jdPlayerKey') || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem('jdPlayerKey', k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */ + function pickPreviewBotCharacterId(botSlotIndex) { + const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0); + const roster = playCharacterIdRoster; + const humanId = String(getPlayCharacterId() || ''); + if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID; + const alts = roster.filter((id) => id !== humanId); + const pool = alts.length ? alts : roster.slice(); + return pool[idx % pool.length] || humanId || roster[0]; + } + const MOVE_SPEED = 0.15; + /* time-based movement: เดิม step=MOVE_SPEED ต่อเฟรม → จอ 120fps เดินเร็ว 2 เท่า (คน/บอทวิ่งเร็วช้าไม่เท่ากัน) + playDtMul = dt*60 (clamp ≤3 กัน tunneling) คูณเข้ากับ step ทุกที่ → ความเร็วเท่ากันทุก FPS */ + let playDtMul = 1; + let playTickLastTs = 0; + /** Anti-stuck (MG1/MG4): ดันชน "เพื่อน" กี่เฟรมก่อนปลดล็อกเดินผ่าน + ช่วงเวลาที่ผ่านได้ต่อเนื่อง */ + const ANTI_STUCK_PEER_TICKS = 13; + const ANTI_STUCK_WINDOW_MS = 650; + /** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */ + const QUIZ_CARRY_WALK_SPEED_MULT = 1.42; + /** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */ + let quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; + const PATH_ARRIVE_THRESH = 0.15; + + function clampQuizCarryWalkSpeedMultClient(n, def) { + const v = Number(n); + if (!Number.isFinite(v)) return def; + return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100; + } + + function resolveQuizCarryWalkSpeedMultFromSettingsObj(s) { + const base = QUIZ_CARRY_WALK_SPEED_MULT; + if (!s || typeof s !== 'object') return base; + const forId = String(s.carryWalkSpeedMultForMapId ?? '').trim(); + const mult = Number(s.carryWalkSpeedMult); + const mapId = (currentPlayMapId() || '').trim(); + if (forId && mapId && forId === mapId && Number.isFinite(mult)) { + return clampQuizCarryWalkSpeedMultClient(mult, base); + } + return base; + } + + function applyQuizCarryWalkSpeedFromSettingsObj(s) { + if (!isQuizCarry()) { + quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; + return; + } + quizCarryWalkSpeedMultActive = resolveQuizCarryWalkSpeedMultFromSettingsObj(s); + } + + function moveSpeedTilesThisFrameForWalk() { + return MOVE_SPEED * (isQuizCarry() ? quizCarryWalkSpeedMultActive : 1) * playDtMul; + } + + function isMovementKey(code) { + return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1; + } + function isChatFocused() { + return false; + } + let zoom = 1.4; + let froggerScore = 0; + let lastFroggerKey = 0; + let gauntletObstacles = []; + /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */ + let gauntletObsRenderPrev = []; + let gauntletObsRenderNext = []; + let gauntletObsBlendT0 = 0; + let meGauntletJumpTicks = 0; + /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */ + let meGauntletJumpVis = 0; + /** โค้งกระโดด local ของ "เรา" — เล่นเต็มอาร์ค (ขึ้น-ลง) เสมอเมื่อกดกระโดด + (server เซ็ต jumpTicks=0 ทันทีตอนข้ามสำเร็จ = ตัดอาร์ค + สัญญาณลอยหายพร้อมตอนดัน +1 → ดูเป็น "วาปข้าม") + ระหว่างอาร์คนี้ vis คุมเอง + glide แนวนอนช้า → เห็นเป็นกระโดดข้ามจริง ไม่ใช่วาป */ + let meGauntletJumpAnimStart = 0; + let meGauntletJumpAnimDur = 0; + function isMeGauntletJumpAnimActivePlay() { + return meGauntletJumpAnimStart > 0 && (Date.now() - meGauntletJumpAnimStart) < meGauntletJumpAnimDur; + } + /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */ + let gauntletRuntimeTickMs = 220; + let gauntletRuntimeJumpTicks = 6; + let gauntletRuntimeJumpHeightMult = 0.52; /* MG2 ความสูงกระโดด (×tile) — รับจาก server game-timing (Admin ปรับได้) */ + /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */ + let playStackSwingHz = 0.55; + /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */ + let playStackBlockWidthTiles = null; + /** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */ + let playStackTowerMissionTimeSec = 90; + /** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */ + let playStackTeamMissesMax = 3; + /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ + let playStackTowerProgressBlocks = 50; + /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */ + let playStackBlockNormalUrls = ['', '', '', '', '', '', '', '']; + let playStackBlockHeavyUrls = ['', '', '', '', '', '', '', '']; + /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */ + let playStackHeavyBlockPercent = 35; + /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */ + let playJumpSurviveJumpHeightMult = 1.5; + /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */ + let playJumpSurviveMissionTimeSec = 0; + /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ + let playJumpSurvivePlatformTiles = [ + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + ]; + /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */ + let playSpaceShooterMissionTimeSec = 0; + /** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */ + let playSpaceShooterShipImageUrls = ['', '', '', '', '', '']; + /** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */ + let playSpaceShooterAsteroidSpriteUrls = []; + let playSpaceShooterAsteroidExplodeFrameMs = 70; + /** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ + let playSpaceShooterAsteroidIntervalMs = 1040; + /** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */ + let playSpaceShooterShipDamageOverlayUrls = ['', '', '']; + /** balloon_boss / Mega Virus — จาก game-timing */ + let playBalloonBossMissionTimeSec = 0; + let playBalloonBossBossImageUrl = ''; + let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', '']; + let playBalloonBossPlayerBalloonFallbackUrl = ''; + /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */ + let gauntletRuntimeTimeLimitSec = 0; + /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */ + let gauntletEndsAtMs = null; + /** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */ + let gauntletCrownRunHeldRemote = false; + /** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */ + let gauntletCrownPregamePhase = null; + let gauntletCrownLobbyReadyMap = {}; + /** จำนวนผู้เล่น (มนุษย์) ที่ server คาดว่าจะเข้าเล่นรอบนี้ — host เริ่มได้ต่อเมื่อมาครบ + กด Ready ครบ */ + let missionExpectedActiveCount = 0; + let gauntletCrownCountdownTimer = null; + let lastGauntletJumpKey = 0; + /** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */ + let gauntletLaneImageUrls = []; + let gauntletLaserTopUrl = ''; + let gauntletLaserBottomUrl = ''; + let gauntletLaserLineUrl = ''; + let gauntletLaserFillColor = 'rgba(140,230,255,0.42)'; + let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)'; + let gauntletLaserLineWidthPx = 2; + const gauntletAssetImageCache = new Map(); + + function encodeSpacesInUrlPath(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; + } + + 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; + } + + function normalizeGameAssetUrlForWebPlay(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; + } + + /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */ + function normalizeGauntletAssetUrlForPlay(u) { + if (typeof u !== 'string') return ''; + let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim())); + const bare0 = raw.split('?')[0].replace(/\/+$/, ''); + if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) { + raw = '/Game/img/MegaVirus/Artboard%209.png'; + } + if (!raw) return ''; + if (/^https?:\/\//i.test(raw)) { + const z = raw.replace(/\/+$/, ''); + return z ? encodeSpacesInUrlPath(z) : ''; + } + const qIdx = raw.indexOf('?'); + const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, ''); + const qs = qIdx >= 0 ? raw.slice(qIdx) : ''; + if (!base) return ''; + if (/\.{4,}/.test(base)) return ''; + if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs); + if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs); + return encodeSpacesInUrlPath(base + qs); + } + + function ensureGauntletAssetImage(url) { + const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : ''); + if (!u) return null; + let rec = gauntletAssetImageCache.get(u); + if (rec) return rec; + rec = { img: new Image(), ready: false }; + if (/^https?:\/\//i.test(u)) { + try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ } + } + rec.img.onload = function () { rec.ready = true; }; + rec.img.onerror = function () { rec.ready = false; }; + rec.img.src = u; + /** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */ + if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true; + else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false; + gauntletAssetImageCache.set(u, rec); + return rec; + } + + /** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */ + function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) { + const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim()); + const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim()); + if (per) { + const rPer = ensureGauntletAssetImage(per); + const img = rPer && rPer.img; + if (img && img.complete && img.naturalWidth > 0) return rPer; + if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb); + return rPer; + } + if (fb) return ensureGauntletAssetImage(fb); + return null; + } + + function pickGauntletLaneImageRec(obsId) { + if (!gauntletLaneImageUrls.length) return null; + const n = gauntletLaneImageUrls.length; + const idx = Math.abs(Number(obsId) | 0) % n; + const u = gauntletLaneImageUrls[idx]; + return u ? ensureGauntletAssetImage(u) : null; + } + + function clampClientLaserColor(s, def) { + const t = String(s || '').trim().slice(0, 100); + if (!t || /[<>"'`]/.test(t)) return def; + return t; + } + + let playQuizPhaseLocal = null; + let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let playQuizText = ''; + let playQuizPhaseEndsAt = 0; + let playQuizTimerInterval = null; + /** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */ + let playQuizQuestionIndex = 0; + let playQuizQuestionTotal = 0; + /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */ + let previewQuizPool = []; + /** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */ + let previewQuizQIndex = 0; + let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 }; + let previewQuizStep = 'read'; + let previewQuizCurrent = null; + let playLiveQuizScores = {}; + /** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */ + let playQuizEverWrong = {}; + /** MG1: กันสั่งจบซ้ำเมื่อตรวจพบ "ตายหมด" (รวมบอท ฝั่ง client) */ + let mg1AllDeadEndScheduled = false; + /** MG1: ตรวจว่าทุกคน(เรา+บอท+ผู้เล่นอื่น)ถูกล็อกตอบผิดหมด → จบเกมทันที (เฉพาะโหมดสืบสวน) */ + function checkMg1AllDeadAndEnd() { + if (mg1AllDeadEndScheduled || previewMode) return; + if (!isQuiz() || !isDetectiveMinigamePlay()) return; + const meLocked = !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse); + if (!meLocked) return; + let anyAlive = false; + others.forEach(function (o, id) { + if (!o || o.bannedSpectator) return; + if (!playBotsEnabled() && isPreviewBotId(id)) return; + if (!(o.quizCannotTrue && o.quizCannotFalse)) anyAlive = true; + }); + if (anyAlive) return; + mg1AllDeadEndScheduled = true; + /* ทุกคนตอบผิดหมด → host ขอให้ server จบเกม → server ส่ง quiz-ended → โชว์ผล/รอกดรับการ์ด (เหมือนเกมอื่น) + เดิม: auto tryFinish = รับผล/การ์ดเองทันที ไม่รอกด */ + setTimeout(function () { + try { + if (isMePlayHost() && socket && socket.connected) socket.emit('quiz-end-request'); + /* non-host: ไม่ต้องทำอะไร รอ server ส่ง quiz-ended มาโชว์ผล */ + } catch (e) { /* ignore */ } + }, 1600); + } + /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */ + const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png'; + let quizWrongGhostImg = null; + function getQuizWrongGhostImg() { + if (quizWrongGhostImg) return quizWrongGhostImg; + const img = new Image(); + img.decoding = 'async'; + img.src = QUIZ_WRONG_GHOST_URL; + quizWrongGhostImg = img; + return img; + } + /** ===== โซนผี (quizGhostArea): ตอบผิด→วาร์ปมาขังในโซน เดินออกไม่ได้ — gated: ทำงานเฉพาะเมื่อแมปมีการวาดโซนผี ===== */ + function quizGhostAreaGridPlay() { + const g = mapData && mapData.quizGhostArea; + if (!Array.isArray(g)) return null; + for (let y = 0; y < g.length; y++) { if (g[y] && g[y].some((v) => v === 1)) return g; } + return null; + } + function quizMeIsGhostPlay() { + return !!(isQuiz() && playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse)); + } + function quizGhostConfineActivePlay() { + return quizMeIsGhostPlay() && !!quizGhostAreaGridPlay(); + } + /** footprint ของผู้เล่นที่ (x,y) อยู่ในโซนผีครบทุกช่องไหม */ + function quizGhostFootprintInsidePlay(x, y) { + const g = quizGhostAreaGridPlay(); + if (!g) return true; + for (const k of quizTilesFootprintPlay(x, y)) { + const p = k.split(','); const tx = +p[0], ty = +p[1]; + if (!g[ty] || g[ty][tx] !== 1) return false; + } + return true; + } + /** วาร์ป me เข้าโซนผี (ช่องที่ footprint ลงได้ ใกล้กลางโซนสุด) — คืน true ถ้ามีโซนผี */ + function warpMeIntoQuizGhostZonePlay() { + const g = quizGhostAreaGridPlay(); + if (!g) return false; + if (quizGhostFootprintInsidePlay(me.x, me.y)) return true; + const xs = [], ys = []; + for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1) { xs.push(x); ys.push(y); } } } + if (!xs.length) return false; + const cx = Math.round((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2); + const cy = Math.round((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2); + const cand = []; + for (let i = 0; i < xs.length; i++) cand.push([xs[i], ys[i], Math.abs(xs[i] - cx) + Math.abs(ys[i] - cy)]); + cand.sort((a, b) => a[2] - b[2]); + let tx = xs[0], ty = ys[0]; + for (const c of cand) { if (quizGhostFootprintInsidePlay(c[0], c[1])) { tx = c[0]; ty = c[1]; break; } } + me.x = tx; me.y = ty; me.tx = tx; me.ty = ty; + try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ } + return true; + } + + /** สุ่มช่องในโซนผีที่ footprint ลงได้ (ใช้คุมบอทผีให้อยู่ในโซน) — คืน {x,y} หรือ null */ + function randomGhostZoneCellPlay() { + const g = quizGhostAreaGridPlay(); + if (!g) return null; + const cells = []; + for (let y = 0; y < g.length; y++) { + const row = g[y] || []; + for (let x = 0; x < row.length; x++) { + if (row[x] === 1 && quizGhostFootprintInsidePlay(x, y)) cells.push({ x: x, y: y }); + } + } + if (!cells.length) { + for (let y = 0; y < g.length; y++) { + const row = g[y] || []; + for (let x = 0; x < row.length; x++) if (row[x] === 1) cells.push({ x: x, y: y }); + } + } + return cells.length ? cells[Math.floor(Math.random() * cells.length)] : null; + } + + /** ตรวจว่า peer/me ตอบผิดในแมป mng8a80o แล้ว → คุมผีแทน avatar */ + function isQuizWrongGhostActiveForEnt(entId) { + if (!isQuizQuestionMissionUiMapPlay()) return false; + const sid = String(entId == null ? '' : entId); + const isMeEnt = (myId != null && sid === String(myId)); + return isMeEnt + ? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse)) + : !!playQuizEverWrong[sid]; + } + + /** วาด "ผี" แทน avatar ทั้งตัวในแมป mng8a80o เมื่อตอบผิด — คุมผีเลย + * args ทุกตัวเป็น "screen pixels" แล้ว (คำนวณจาก worldToScreen ที่ caller) + * - sxCenter = พิกัด X กึ่งกลาง avatar บนจอ (px) + * - syBottom = พิกัด Y เท้า avatar บนจอ (px) + * - heightPx = ความสูง avatar บนจอ (px) ใช้คุมขนาดผีให้พอดี + */ + function drawQuizWrongGhostOverlayPlay(sxCenter, syBottom, heightPx, entId, nameLabel) { + const img = getQuizWrongGhostImg(); + if (!img || !img.complete || !img.naturalWidth) return; + /* ผีสูงเท่า avatar ตามจริง — คงสัดส่วนภาพ */ + const hPx = Math.max(24, Number(heightPx) || 0); + const ratio = img.naturalWidth / Math.max(1, img.naturalHeight); + const wPx = hPx * ratio; + const sid = String(entId == null ? '' : entId); + const tBob = Math.sin(performance.now() / 360 + (sid ? sid.charCodeAt(0) || 0 : 0)) * (hPx * 0.04); + /* วาดให้ "เท้าผี" ตรงกับเท้า avatar */ + const drawX = sxCenter - wPx / 2; + const drawY = syBottom - hPx + tBob; + ctx.save(); + ctx.globalAlpha = 0.98; + ctx.drawImage(img, drawX, drawY, wPx, hPx); + ctx.restore(); + /* ชื่อใต้ผี (ถ้ามี — เพื่อระบุว่าเป็นใคร) */ + if (nameLabel) { + ctx.save(); + ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.lineWidth = 3; + const ny = syBottom + 14; + ctx.strokeText(nameLabel, sxCenter, ny); + ctx.fillText(nameLabel, sxCenter, ny); + ctx.restore(); + } + } + /** ช่วยแปลง tile coords → screen pos สำหรับ ghost (ใช้เฉพาะที่ caller ใน render scope) + * รับ helpers ที่เป็น scope-local (worldToScreen, zDraw) เพราะถูกเรียกในรอบ draw เท่านั้น */ + /** row ล่างสุดของโซนผี (cache บน mapData) — ใช้ clamp ภาพผีให้อยู่ในโซน */ + function quizGhostZoneMaxRowPlay() { + if (!mapData) return null; + if (mapData._ghostMaxRow !== undefined) return mapData._ghostMaxRow; + const g = mapData.quizGhostArea; + let maxY = null; + if (Array.isArray(g)) { + for (let y = 0; y < g.length; y++) { + const row = g[y] || []; + for (let x = 0; x < row.length; x++) { if (Number(row[x]) === 1) maxY = y; } + } + } + mapData._ghostMaxRow = maxY; + return maxY; + } + function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) { + const md = mapData; + const { cw, ch } = getCharacterFootprintWH(md); + const cxWorld = (axTile + cw * 0.5) * tileSize; + /* anchor เท้าผีตามปกติ (ayTile + ch) แต่ clamp ไม่ให้ต่ำกว่าขอบล่างโซนผี (maxRow + 0.6) + → ภาพผีอยู่ "ในโซนผี" พอดี: ไม่ลอยสูงเกิน (เคส ayTile) และไม่ห้อยลงทับ SAFE/SCAM (เคส ayTile+ch) */ + let bottomTile = ayTile + ch; + const maxRow = quizGhostZoneMaxRowPlay(); + if (maxRow != null) { + const clampBottom = maxRow + 0.6; + if (bottomTile > clampBottom) bottomTile = clampBottom; + } + const cyBottomWorld = bottomTile * tileSize; + const [sx, sy] = w2s(cxWorld, cyBottomWorld); + /* ความสูง = สูง avatar บนจอ (สอดคล้องกับ drawAvatar: boxH = tileSize * zDraw * ch * 1.35) */ + const sizeH = tileSize * zDrawIn * Math.max(ch, 1) * 1.35; + return { sx, sy, sizeH }; + } + /** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */ + const QUIZ_TF_POINTS_PER_CORRECT = 10; + const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png'; + const QUIZ_TF_SCORE_POPUP_MS = 1700; + /** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */ + const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7; + const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112; + let quizTfScorePopups = []; + let quizTfScorePlusImg = null; + /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */ + const QUIZ_CARRY_POINTS_PER_CORRECT = 10; + /** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */ + const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000; + /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */ + let quizCarryPool = []; + let quizCarryCurrent = null; + /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */ + let quizCarryEmbedPendingQuestion = null; + let quizCarryEmbedCountdownStartAt = 0; + let quizCarryEmbedCountdownEndAt = 0; + /** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */ + let quizCarryEmbedPreOptionCountdownStartAt = 0; + let quizCarryEmbedPreOptionCountdownEndAt = 0; + /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */ + let quizCarryOptionRevealAt = 0; + let quizCarryAnswerCloseAt = 0; + /** server-auth (เกมจริง): epoch สิ้นสุดนับ 3-2-1 บนโต๊ะ ช่วง read phase (>0 = กำลังโชว์ countdown) */ + let quizCarrySrvReadCountdownEndAt = 0; + /** MG4 server-auth: ไอคอน ✓/✗ ลอยเหนือหัวตอนตอบ (key = entity id → { ok, until }) ทุกจอเห็นพร้อมกัน */ + const quizCarryEntityFeedbackFx = Object.create(null); + const QUIZ_CARRY_FEEDBACK_FX_MS = 1300; + /** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */ + let quizCarryAnswerTimeupAwaitNext = false; + let quizCarryAnswerRoundTimeupAutoT = null; + let quizCarryAnswerRoundTimeupAdvanceAt = 0; + let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 }; + /** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */ + let quizCarrySessionLength = 0; + let quizCarryRoundsCompleted = 0; + let quizCarrySessionEnded = false; + /** MG4 server-auth (detective): กัน schedule กลับ LobbyB ซ้ำเมื่อ quiz-carry-ended มาหลายครั้ง */ + let quizCarryDetectiveReturnNavDone = false; + /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */ + let quizCarryResultEndTimer = null; + /** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */ + let embedPreviewLobbyReturnTimer = null; + /** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */ + let quizCarrySessionCompleteResultToSummaryT = null; + /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */ + let quizCarryPregameActive = false; + let playHostId = null; + /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */ + let quizCarryLobbyReadyMap = {}; + /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */ + let quizCarryMapPanelTheme = null; + /** จาก /api/quiz-settings quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */ + let quizMapPanelTheme = null; + /** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */ + let quizCarryEmbedCountdownTheme = null; + /** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */ + let quizCarryChoicePlaqueThemes = null; + /** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */ + let quizCarryPlaqueMapScale = 1.25; + const quizCarryChoiceImageCache = new Map(); + /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */ + let quizCarryJoinSettingsSnap = null; + + /** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */ + const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; + + /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนลง (scroll เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */ + let jumpSurviveCamCenterX = 0; + let jumpSurviveCamCenterY = 0; + let jumpSurviveEliminated = false; + /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */ + let jumpSurviveSessionStartMs = 0; + /* เวลา server (epoch ms) ที่ live เริ่มจริง — ใช้คำนวณ scroll/เวลานับถอยหลังจาก serverNow() ร่วมกัน + ทุกเครื่องเห็นแพลตฟอร์มเลื่อนเท่ากัน + จบพร้อมกัน (เดิมสะสม dt local → เลื่อน/จบไม่ตรงกัน = desync) */ + let jumpSurviveLiveAnchorServerMs = 0; + let jumpSurvivePlatformScrollPx = 0; + let jumpSurviveVy = 0; + let jumpSurviveLastTickMs = 0; + let jumpSurviveJumpQueued = false; + let jumpSurviveOnGround = false; + let jumpSurviveLastEmitT = 0; + let jumpSurviveHiddenTimer = null; /* แท็บ hidden → rAF หยุด → แจ้ง server ให้จำลองการตกแทน (คนอื่นเห็นลื่น) */ + let jumpSurviveHiddenLastMs = 0; + let jumpSurviveHiddenSent = false; /* แจ้ง server แล้วว่า hidden (กันส่งซ้ำทุก tick) */ + let jumpSurviveServerAuthPlay = false; /* true เมื่อรับ jump-survive-bot-move/ended ครั้งแรก — server เป็นเจ้าของบอท/รอบ */ + let spaceShooterServerAuthPlay = false; /* MG6: true เมื่อรับ space-shooter-bot-move — server เป็นเจ้าของบอท/รอบ */ + let balloonBossServerAuthPlay = false; /* MG7: true เมื่อรับ balloon-boss-bot-move — server เป็นเจ้าของบอท/รอบ/HP บอส */ + /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */ + let jumpSurviveMissionPhase = null; + let jumpSurviveGameEnded = false; + let jumperMissionCountdownTimer = null; + + /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */ + let spaceShooterBullets = []; + let spaceShooterAsteroids = []; + /** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */ + let spaceShooterAsteroidExplosions = []; + let spaceShooterPopups = []; + let spaceShooterLastTickMs = 0; + let spaceShooterSpawnAccMs = 0; + /* เวลา server ที่ live เริ่ม + ตัวนับลำดับก้อนที่เกิดแล้ว — ใช้ทำ deterministic spawn ให้ทุกเครื่องตรงกัน */ + let spaceShooterLiveAnchorServerMs = 0; + let spaceShooterSpawnCount = 0; + let spaceShooterFireCd = 0; + let spaceShooterSessionStartMs = 0; + let spaceShooterLastMoveEmit = 0; + let spaceShooterGameEnded = false; + let spaceShooterMissionPhase = null; + let spaceShooterMissionCountdownTimer = null; + + /** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */ + let quizQuestionMissionPhase = null; + let quizQuestionMissionCountdownTimer = null; + let quizQuestionMissionDeferredPhase = null; + /** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */ + let quizQuestionMissionJoyVecX = 0; + let quizQuestionMissionJoyVecY = 0; + let quizQuestionMissionJoyPointerId = null; + /** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */ + let stackTowerMissionPhase = null; + let stackTowerMissionCountdownTimer = null; + let stackTowerMissionDeferredPhase = null; + let stackTowerSessionStartAt = 0; + let stackTowerMissionEndedOnce = false; + /** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */ + let stackTowerPost50AnimStartMs = null; + /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */ + let stackTowerResultFlashTimer = null; + const STACK_TOWER_RESULT_FLASH_MS = 5000; + + /** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */ + function spaceShooterAsteroidLiveSpriteIndexPlay(a) { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || !urls.length) return 0; + const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP); + const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp); + if (hp >= maxHp) return 0; + if (urls.length >= 3) return 1; + return 0; + } + + /** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */ + function spaceShooterAsteroidExplosionStartFiPlay() { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || urls.length < 2) return 0; + return urls.length >= 3 ? 1 : 0; + } + + function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || urls.length < 2) return; + spaceShooterAsteroidExplosions.push({ + x: worldX, + y: worldY, + r: Math.max(6, Number(r) || 14), + fi: spaceShooterAsteroidExplosionStartFiPlay(), + acc: 0, + }); + } + + function spaceShooterTickAsteroidExplosions(dt) { + const urls = playSpaceShooterAsteroidSpriteUrls; + if (!urls || urls.length < 2) { + spaceShooterAsteroidExplosions.length = 0; + return; + } + const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70)); + const maxFi = urls.length - 2; + for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) { + const ex = spaceShooterAsteroidExplosions[i]; + ex.acc += dt * 1000; + while (ex.acc >= fms) { + ex.acc -= fms; + ex.fi++; + if (ex.fi > maxFi) { + spaceShooterAsteroidExplosions.splice(i, 1); + break; + } + } + } + } + + /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */ + let balloonBossPendingShots = []; + let balloonBossPlayerBullets = []; + let balloonBossBossBullets = []; + let balloonBossSessionStartMs = 0; + let balloonBossLastTickMs = 0; + let balloonBossLastMoveEmit = 0; + let balloonBossPlayerFireCd = 0; + /* ปุ่ม SHOOT บนจอ (MG7) กด 1 ครั้ง = ยิง 1 นัด — ตั้ง flag ให้ tick ยิงตามมุมลูกศร เหมือนกด Space */ + let balloonBossFireBtnQueued = false; + let balloonBossGameEnded = false; + let balloonBossHitFx = []; + /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */ + let balloonBossScorePopups = []; + let balloonBossBossFireAcc = 0; + /* anchor เวลา server + ตัวนับกระสุนบอส — deterministic boss fire ให้ทุกเครื่องยิงทิศ/เวลาเดียวกัน */ + let balloonBossLiveAnchorServerMs = 0; + let balloonBossBossShotCount = 0; + /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวมเป็นระยะ (balloon-boss-sync) + → non-host แสดง HP บอสตามค่า host เป๊ะ (null = ยังไม่ได้รับ → fallback ผลรวมท้องถิ่น) */ + let balloonBossHostBossDmg = null; + let balloonBossLastSyncEmit = 0; + /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */ + let quizBattleMcqPool = []; + let quizBattleAnsweredComps = new Set(); + let quizBattleModalCompId = null; + let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง) + /** ประวัติคำตอบต่อโดม (สำหรับ MY RESULT): compId → { qIndex, firstTry(bool), correct(bool), submittedIdx, q } */ + let quizBattleAnswerLog = new Map(); + /** เวลาเริ่มเล่นห้อง quiz_battle (ms) — สำหรับ Total time */ + let quizBattleSessionStartMs = 0; + let quizBattleScoreSubmittedAt = 0; + /** ล็อกตัวเลือกระหว่างโชว์ผลถูก/ผิด (กันกดซ้ำ + กันเด้งซ้ำ) */ + let quizBattleChoiceLocked = false; + /** โชว์ popup Completed แล้วหรือยัง (กันเด้งซ้ำเมื่อตอบครบทุกโดม) */ + let quizBattleCompletedShown = false; + /** ถึงจุดจบแล้ว — ล็อกตำแหน่ง ไม่ให้ echo/restore เด้งกลับ start หรือจุดเดิม */ + let quizBattleFinishLock = false; + /** โดมที่ "ผ่านแล้ว" (ตอบถูกหรือผิดก็ผ่าน) — ใช้ปลดล็อกเส้นทาง + จำว่าเคยตอบ ไม่ต้องตอบซ้ำ */ + let quizBattlePassedComps = new Set(); + /** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */ + let quizBattleUserZoomMul = 1; + const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd']; + /** ยานยิงอุกาบาต (MG6) สีตาม lobby theme 1-8 — Rocket-1..8 ตรงกับ PLAY_LOBBY_THEME_HEX */ + const SPACE_SHOOTER_ROCKET_THEME_URLS = [1, 2, 3, 4, 5, 6, 7, 8].map(function (n) { return BASE + '/img/ViolentCrime/Rocket-' + n + '.png'; }); + /** theme index (1-8) ของ entity จากสี body ที่ย้อม (playTint) — ใช้แมป asset สีตามผู้เล่น */ + function playLobbyThemeIndexForEntityPlay(ent) { + const tint = ent && ent.playTint; + const body = (tint && typeof tint.body === 'string') ? tint.body : null; + const idx = body ? nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX) : null; + return (idx >= 1 && idx <= 8) ? idx : null; + } + + document.getElementById('room-id').textContent = spaceId; + + function hidePlayQuizHud() { + const sb = document.getElementById('play-quiz-scoreboard'); + if (sb) sb.classList.add('is-hidden'); + const fb = document.getElementById('play-quiz-feedback'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderPlayQuizScoreboard(scores) { + const wrap = document.getElementById('play-quiz-scoreboard'); + const ul = document.getElementById('play-quiz-scoreboard-list'); + if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return; + if (!scores || typeof scores !== 'object') return; + wrap.classList.remove('is-hidden'); + ul.textContent = ''; + const merged = { ...scores }; + others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; }); + if (myId != null && merged[myId] == null) merged[myId] = 0; + const rows = []; + if (myId != null) { + rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 }); + } + others.forEach((o, id) => { + rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 }); + }); + rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th')); + rows.forEach((row) => { + const li = document.createElement('li'); + if (row.id === myId) li.className = 'play-quiz-scoreboard-me'; + const spN = document.createElement('span'); + spN.className = 'play-quiz-scoreboard-name'; + spN.textContent = row.nick; + const spV = document.createElement('span'); + spV.className = 'play-quiz-scoreboard-val'; + spV.textContent = String(row.sc); + li.appendChild(spN); + li.appendChild(spV); + ul.appendChild(li); + }); + if (isQuizCarry() && useCyberPlayHud()) { + wrap.classList.add('is-hidden'); + } + /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */ + if (isQuizQuestionMissionUiMapPlay()) { + wrap.classList.add('is-hidden'); + } + /* Quiz Battle: ใช้ HUD ใหม่ (#qb-game-hud) — ซ่อนกล่อง "คะแนน" เดิม */ + if (isQuizBattle()) { + wrap.classList.add('is-hidden'); + } + } + + function initPlayLiveQuizScoresZeros() { + if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return; + playLiveQuizScores = {}; + playLiveQuizScores[myId] = 0; + others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); + if (isQuiz()) { playQuizEverWrong = {}; mg1AllDeadEndScheduled = false; } + renderPlayQuizScoreboard(playLiveQuizScores); + } + + function showPlayQuizFeedback(r) { + const el = document.getElementById('play-quiz-feedback'); + if (!el || !r || !r.results || myId == null) return; + let mine = null; + let botRight = 0, botWrong = 0; + for (let i = 0; i < r.results.length; i++) { + const row = r.results[i]; + if (row.id === myId) mine = row; + else if (isPreviewBotId(row.id)) { + if (row.right) botRight++; + else botWrong++; + } + } + if (!mine) return; + el.classList.remove('is-hidden'); + const botExtra = playBotsEnabled() && (botRight + botWrong > 0) + ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)' + : ''; + if (mine.right) { + el.className = 'play-quiz-feedback play-quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra; + } else { + el.className = 'play-quiz-feedback play-quiz-feedback-bad'; + el.textContent = (mine.choice == null + ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด' + : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra; + } + if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT); + window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200); + } + + function ensureQuizTfScorePlusImgPlay() { + if (quizTfScorePlusImg && quizTfScorePlusImg.src) return; + quizTfScorePlusImg = new Image(); + quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL; + quizTfScorePlusImg.onerror = function () { + this.onerror = null; + }; + } + + /** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */ + function spawnQuizTrueFalseScorePopupPlay(correctTrue) { + if (!isQuiz() || !mapData) return; + const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea; + const b = getTileBoundsForOnesGrid(grid); + if (!b) return; + const ts = tileSize || 32; + const wx = ((b.minX + b.maxX + 1) / 2) * ts; + const wy = ((b.minY + b.maxY + 1) / 2) * ts; + const now = Date.now(); + ensureQuizTfScorePlusImgPlay(); + quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS }); + } + + function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) { + if (!quizTfScorePopups.length) return; + const img = quizTfScorePlusImg; + if (!img || !img.complete || !img.naturalWidth) return; + const now = typeof timeMs === 'number' ? timeMs : Date.now(); + quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now); + const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT); + for (let i = 0; i < quizTfScorePopups.length; i++) { + const p = quizTfScorePopups[i]; + const span = Math.max(1, p.untilMs - p.startMs); + const t = Math.max(0, Math.min(1, (now - p.startMs) / span)); + const [sx, sy] = worldToScreen(p.wx, p.wy); + const rise = t * 56 * zDraw; + const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28); + const pulse = 1 + Math.sin(t * Math.PI) * 0.1; + const scale = (baseW / img.naturalWidth) * pulse; + const w = img.naturalWidth * scale; + const h = img.naturalHeight * scale; + ctx.save(); + ctx.globalAlpha = fade; + ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h); + ctx.restore(); + } + } + + function getTileBoundsForOnesGrid(grid) { + if (!grid || !grid.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let yy = 0; yy < grid.length; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < row.length; xx++) { + if (Number(row[xx]) === 1) { + if (xx < minX) minX = xx; + if (yy < minY) minY = yy; + if (xx > maxX) maxX = xx; + if (yy > maxY) maxY = yy; + } + } + } + if (minX === Infinity) return null; + return { minX, minY, maxX, maxY }; + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + return getTileBoundsForOnesGrid(md.quizQuestionArea); + } + + /** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */ + function getQuizCarryQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz_carry') return null; + return getTileBoundsForOnesGrid(md.quizQuestionArea); + } + + function clearQuizMapPanelThemeInline(panel, textEl) { + if (!panel || !textEl) return; + panel.classList.remove('quiz-map-question-panel--has-kicker'); + panel.classList.remove('quiz-map-question-panel--question-mission-live'); + const qKick = document.getElementById('quiz-map-question-kicker'); + if (qKick) { + qKick.textContent = ''; + qKick.classList.add('is-hidden'); + qKick.setAttribute('aria-hidden', 'true'); + } + panel.style.removeProperty('background-image'); + panel.style.removeProperty('background-size'); + panel.style.removeProperty('background-position'); + delete panel.dataset.qmPlaqueTry; + panel.style.removeProperty('--qmap-bg'); + panel.style.removeProperty('--qmap-border'); + panel.style.removeProperty('--qmap-border-w'); + panel.style.removeProperty('--qmap-shadow'); + panel.style.removeProperty('background'); + panel.style.removeProperty('border'); + panel.style.removeProperty('border-color'); + panel.style.removeProperty('border-width'); + panel.style.removeProperty('border-style'); + panel.style.removeProperty('box-shadow'); + textEl.style.removeProperty('--qmap-text'); + textEl.style.removeProperty('--qmap-text-shadow'); + textEl.style.removeProperty('color'); + textEl.style.removeProperty('text-shadow'); + textEl.style.removeProperty('font-size'); + textEl.style.removeProperty('line-height'); + textEl.style.removeProperty('width'); + textEl.style.removeProperty('box-sizing'); + textEl.style.removeProperty('max-height'); + textEl.style.removeProperty('overflow'); + } + + function clearQuizMapQuestionCarryFeedback() { + if (typeof window.__quizMapCarryFeedbackT === 'number') { + clearTimeout(window.__quizMapCarryFeedbackT); + window.__quizMapCarryFeedbackT = null; + } + const icon = document.getElementById('quiz-map-q-feedback-icon'); + const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap'); + const scoreEl = document.getElementById('quiz-map-q-feedback-score'); + if (icon) { + icon.classList.add('is-hidden'); + icon.removeAttribute('src'); + } + if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop'); + if (scoreWrap) { + scoreWrap.classList.add('is-hidden'); + scoreWrap.setAttribute('aria-hidden', 'true'); + } + } + + /** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */ + function showQuizMapQuestionCarryFeedback(isCorrect) { + if (!isQuizCarry()) return; + const panel = document.getElementById('quiz-map-question-panel'); + const icon = document.getElementById('quiz-map-q-feedback-icon'); + const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap'); + const scoreEl = document.getElementById('quiz-map-q-feedback-score'); + if (!panel || !icon || panel.classList.contains('is-hidden')) return; + clearQuizMapQuestionCarryFeedback(); + if (window.jdSound) { try { window.jdSound.playSfx(isCorrect ? 'correct' : 'wrong', 'mg4'); } catch (e) { /* ignore */ } } + icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect; + icon.classList.remove('is-hidden'); + if (isCorrect && scoreWrap && scoreEl) { + scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus; + scoreWrap.classList.remove('is-hidden'); + scoreWrap.setAttribute('aria-hidden', 'false'); + scoreEl.classList.remove('quiz-map-q-feedback-score--pop'); + void scoreEl.offsetWidth; + scoreEl.classList.add('quiz-map-q-feedback-score--pop'); + } else if (scoreWrap) { + scoreWrap.classList.add('is-hidden'); + scoreWrap.setAttribute('aria-hidden', 'true'); + } + window.__quizMapCarryFeedbackT = setTimeout(() => { + window.__quizMapCarryFeedbackT = null; + clearQuizMapQuestionCarryFeedback(); + }, QUIZ_MAP_CARRY_FEEDBACK_MS); + } + + /** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */ + function defaultCarryMapPanelThemePlay() { + return { + panelBg: 'rgba(12, 14, 28, 0.88)', + panelBorder: 'rgba(255, 214, 102, 0.7)', + borderWidthPx: 2, + textColor: '#f1f5f9', + questionFontMinPx: 10, + questionFontMaxPx: 24, + }; + } + + /** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */ + function quizCarryPlaqueMapScaleClampedPlay() { + const sc = Number(quizCarryPlaqueMapScale); + if (!Number.isFinite(sc)) return 1.25; + return Math.max(0.85, Math.min(2.5, sc)); + } + + /** + * ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps)) + * แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max) + */ + function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) { + const ts = tileSize * zoomVal; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + const base = Math.max(10, Math.min(24, ts * 0.24 * ps)); + const fb = defaultCarryMapPanelThemePlay(); + let mn = Number(th && th.questionFontMinPx); + let mx = Number(th && th.questionFontMaxPx); + if (!Number.isFinite(mn)) mn = fb.questionFontMinPx; + if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx; + mn = Math.round(Math.max(8, Math.min(40, mn))); + mx = Math.round(Math.max(10, Math.min(72, mx))); + if (mx < mn) { + const s = mn; + mn = mx; + mx = s; + } + return Math.max(mn, Math.min(mx, base)); + } + + /** + * ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar) + * zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด + * เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน + */ + function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) { + const cs = window.getComputedStyle(panel); + const padT = parseFloat(cs.paddingTop) || 0; + const padB = parseFloat(cs.paddingBottom) || 0; + const padL = parseFloat(cs.paddingLeft) || 0; + const padR = parseFloat(cs.paddingRight) || 0; + let kickerReserve = 0; + if (panel.classList.contains('quiz-map-question-panel--question-mission-live') + && panel.classList.contains('quiz-map-question-panel--has-kicker')) { + kickerReserve = 30; + } + const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve); + const availW = Math.max(24, panel.clientWidth - padL - padR); + const themeMin = Math.max(8, Math.floor(Number(minPx) || 8)); + const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7))); + let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin))); + textEl.style.setProperty('line-height', '1.28', 'important'); + textEl.style.setProperty('width', '100%', 'important'); + textEl.style.setProperty('box-sizing', 'border-box', 'important'); + textEl.style.removeProperty('max-height'); + textEl.style.setProperty('overflow', 'hidden', 'important'); + for (let i = 0; i < 96 && px > hardMin; i++) { + textEl.style.setProperty('font-size', String(px) + 'px', 'important'); + const sh = textEl.scrollHeight; + const sw = textEl.scrollWidth; + if (sh <= availH + 2 && sw <= availW + 2) break; + px -= 1; + } + textEl.style.setProperty('font-size', String(px) + 'px', 'important'); + textEl.style.setProperty('max-height', availH + 'px', 'important'); + } + + /** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */ + function parseCarryMapPanelThemeObject(raw) { + let t = raw; + if (typeof t === 'string') { + try { + t = JSON.parse(t); + } catch (e) { + t = null; + } + } + if (!t || typeof t !== 'object') return null; + const bw = Number(t.borderWidthPx); + const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; + const fb = defaultCarryMapPanelThemePlay(); + let qMin = Number(t.questionFontMinPx); + let qMax = Number(t.questionFontMaxPx); + if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = fb.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 s = qMin; + qMin = qMax; + qMax = s; + } + return { + panelBg: String(t.panelBg || '').trim().slice(0, 120), + panelBorder: String(t.panelBorder || '').trim().slice(0, 120), + borderWidthPx, + textColor: String(t.textColor || '').trim().slice(0, 120), + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; + } + + /** + * ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง + * (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24) + */ + function parseCarryMapPanelThemeMapOverlay(raw) { + let t = raw; + if (typeof t === 'string') { + try { + t = JSON.parse(t); + } catch (e) { + t = null; + } + } + if (!t || typeof t !== 'object') return null; + const bw = Number(t.borderWidthPx); + const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; + const out = { + panelBg: String(t.panelBg || '').trim().slice(0, 120), + panelBorder: String(t.panelBorder || '').trim().slice(0, 120), + borderWidthPx, + textColor: String(t.textColor || '').trim().slice(0, 120), + }; + const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx'); + const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx'); + if (hasMin || hasMax) { + const fb = defaultCarryMapPanelThemePlay(); + let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx; + let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx; + if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = fb.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 s = qMin; + qMin = qMax; + qMax = s; + } + out.questionFontMinPx = qMin; + out.questionFontMaxPx = qMax; + } + return out; + } + + function setQuizCarryMapPanelThemeFromApi(s) { + quizCarryMapPanelTheme = null; + if (!s || typeof s !== 'object') return; + const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme); + if (!parsed) return; + quizCarryMapPanelTheme = parsed; + } + + function setQuizMapPanelThemeFromApi(s) { + quizMapPanelTheme = null; + if (!s || typeof s !== 'object') return; + const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme); + if (!parsed) return; + quizMapPanelTheme = parsed; + } + + function defaultCarryChoicePlaqueThemePlay() { + 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, + plaqueImageUrl: '', + }; + } + + function parseCarryChoicePlaqueThemeObject(raw) { + const d = defaultCarryChoicePlaqueThemePlay(); + if (!raw || typeof raw !== 'object') return d; + const safeColor = (x, fb) => { + const t = String(x == null ? '' : x).trim().slice(0, 120); + if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb; + 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)) return t.replace(/\s+/g, ' ').trim(); + return fb; + }; + 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: safeColor(raw.fixedBorder, d.fixedBorder), + fillBg: safeColor(raw.fillBg, d.fillBg), + textColor: safeColor(raw.textColor, d.textColor), + borderWidthPx: bw, + plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl), + }; + } + + function buildCarryChoicePlaqueThemesArrayFromApi(s) { + if (!s || typeof s !== 'object') return null; + if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) { + const out = []; + for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) { + out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i])); + } + return out; + } + if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') { + const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme); + return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one })); + } + return null; + } + + function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) { + const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0)); + if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) { + return quizCarryChoicePlaqueThemes[i]; + } + return defaultCarryChoicePlaqueThemePlay(); + } + + function setQuizCarryChoicePlaqueThemeFromApi(s) { + quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s); + } + + function sanitizeQuizCarryImageUrlClient(u) { + const s = String(u == null ? '' : u).trim().slice(0, 512); + if (!s || /[\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 parsed = new URL(s); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + return s; + } catch (e) { + return ''; + } + } + + /** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */ + function quizCarryApplyImageCrossOrigin(img, rawUrl) { + if (!img) return; + try { + img.removeAttribute('crossorigin'); + const u = sanitizeQuizCarryImageUrlClient(rawUrl); + if (!u || u.startsWith('data:')) return; + let imgOrigin = ''; + if (u.startsWith('http://') || u.startsWith('https://')) { + imgOrigin = new URL(u).origin; + } else if (u.startsWith('/')) { + imgOrigin = window.location.origin; + } + if (imgOrigin && imgOrigin !== window.location.origin) { + img.crossOrigin = 'anonymous'; + } + } catch (e) { /* ignore */ } + } + + function preloadQuizCarryChoiceImages(q) { + if (!q) return; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + for (let i = 0; i < q.choiceImageUrls.length; i++) { + const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]); + if (!raw) continue; + if (quizCarryChoiceImageCache.has(raw)) continue; + const im = new Image(); + quizCarryApplyImageCrossOrigin(im, raw); + im.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; + quizCarryChoiceImageCache.set(raw, im); + try { + im.src = raw; + } catch (e) { /* ignore */ } + } + } + if (Array.isArray(q.choices)) { + for (let j = 0; j < q.choices.length; j++) { + const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j); + if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue; + const im2 = new Image(); + quizCarryApplyImageCrossOrigin(im2, raw2); + im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } }; + quizCarryChoiceImageCache.set(raw2, im2); + try { + im2.src = raw2; + } catch (e) { /* ignore */ } + } + } + } + + function getQuizCarryChoiceImageCached(url) { + const u = sanitizeQuizCarryImageUrlClient(url); + if (!u) return null; + const im = quizCarryChoiceImageCache.get(u); + if (im && im.complete && im.naturalWidth > 0) return im; + return null; + } + + function getQuizCarryChoiceImageUrlForIndex(q, idx) { + if (!q || !Array.isArray(q.choiceImageUrls)) return ''; + const u = q.choiceImageUrls[idx]; + return sanitizeQuizCarryImageUrlClient(u); + } + + /** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */ + function getQuizCarryPlaqueImageUrlForIndex(q, idx) { + const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx); + if (perQ) return perQ; + const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx); + const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : ''; + return u || ''; + } + + function defaultCarryEmbedCountdownThemePlay() { + 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 parseCarryEmbedCountdownThemeObject(raw) { + const d = defaultCarryEmbedCountdownThemePlay(); + 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))); + }; + const clipStr = (x, maxLen) => { + const t = String(x == null ? '' : x).trim().slice(0, maxLen); + return t || null; + }; + const safeColor = (x, fb) => { + const t = clipStr(x, 120); + if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb; + 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)) return t.replace(/\s+/g, ' ').trim(); + return fb; + }; + return { + overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop), + innerBg: safeColor(raw.innerBg, d.innerBg), + innerBorder: safeColor(raw.innerBorder, d.innerBorder), + innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), + innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), + digitColor: safeColor(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), + }; + } + + function setQuizCarryEmbedCountdownThemeFromApi(s) { + quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme); + applyQuizCarryEmbedCountdownThemeToDom(); + } + + const CARRY_ECD_VAR_KEYS = [ + '--carry-ecd-overlay', + '--carry-ecd-inner-bg', + '--carry-ecd-inner-border', + '--carry-ecd-inner-border-w', + '--carry-ecd-inner-radius', + '--carry-ecd-digit-color', + '--carry-ecd-map-digit-fs', + '--carry-ecd-screen-vw', + '--carry-ecd-screen-max', + ]; + + function clearQuizCarryEmbedCountdownThemeDomVars() { + const root = document.getElementById('quiz-carry-embed-countdown'); + if (!root) return; + CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } }); + } + + /** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */ + function applyQuizCarryEmbedCountdownThemeToDom() { + const root = document.getElementById('quiz-carry-embed-countdown'); + const numEl = document.getElementById('quiz-carry-embed-countdown-num'); + if (!root) return; + clearQuizCarryEmbedCountdownThemeDomVars(); + const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay(); + root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop); + root.style.setProperty('--carry-ecd-inner-bg', th.innerBg); + root.style.setProperty('--carry-ecd-inner-border', th.innerBorder); + root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`); + root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`); + root.style.setProperty('--carry-ecd-digit-color', th.digitColor); + const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78))); + const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82))); + const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200))); + root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`); + const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28))); + const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132))); + root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`); + root.style.setProperty('--carry-ecd-screen-max', `${smx}px`); + if (numEl && String(numEl.tagName || '').toUpperCase() !== 'IMG') { + numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)'; + } else if (numEl) { + try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ } + } + } + + function setCountdown321QuestionAssetGraphic(numEl, n) { + if (!numEl) return; + const d = Math.max(1, Math.min(3, n)); + if (String(numEl.tagName || '').toUpperCase() === 'IMG') { + numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png'; + numEl.alt = String(d); + } else { + numEl.textContent = String(d); + } + } + + /** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */ + function getEffectiveCarryMapPanelThemeForApply() { + const d = defaultCarryMapPanelThemePlay(); + const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null; + let merged = api ? { ...d, ...api } : { ...d }; + if (mapData && mapData.carryMapPanelTheme != null) { + const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme); + if (mp) merged = { ...merged, ...mp }; + } + return merged; + } + + /** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */ + function getEffectiveQuizMapPanelThemeForApply() { + const d = defaultCarryMapPanelThemePlay(); + const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null; + let merged = api ? { ...d, ...api } : { ...d }; + if (mapData && mapData.quizMapPanelTheme != null) { + const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme); + if (mp) merged = { ...merged, ...mp }; + } + return merged; + } + + function paintQuizMapPanelTheme(panel, textEl, th) { + if (!panel || !textEl || !th) return; + clearQuizMapPanelThemeInline(panel, textEl); + const bgStr = th.panelBg != null ? String(th.panelBg).trim() : ''; + const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : ''; + const txStr = th.textColor != null ? String(th.textColor).trim() : ''; + const wRaw = Number(th.borderWidthPx); + const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0; + const bgUse = bgStr || 'transparent'; + const brUse = brStr || 'transparent'; + const txUse = txStr || '#f1f5f9'; + panel.style.setProperty('--qmap-bg', bgUse); + panel.style.setProperty('--qmap-border', brUse); + panel.style.setProperty('--qmap-border-w', String(w) + 'px'); + panel.style.setProperty('--qmap-shadow', 'none'); + textEl.style.setProperty('--qmap-text', txUse); + textEl.style.setProperty('--qmap-text-shadow', 'none'); + /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */ + panel.style.setProperty('background', bgUse, 'important'); + panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important'); + panel.style.setProperty('box-shadow', 'none', 'important'); + textEl.style.setProperty('color', txUse, 'important'); + textEl.style.setProperty('text-shadow', 'none', 'important'); + } + + function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) { + if (!panel || !textEl) return; + if (!isQuizCarry()) { + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply()); + } + + function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) { + if (!panel || !textEl) return; + if (!isQuiz() || isQuizCarry()) return; + paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply()); + } + + function syncPlayQuizMapPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl || !mapData) return; + /* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะ quiz ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */ + if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) { + clearQuizMapQuestionCarryFeedback(); + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + if (isQuizBattle()) { + clearQuizMapQuestionCarryFeedback(); + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + if (!isQuiz() && !isQuizCarry()) { + clearQuizMapQuestionCarryFeedback(); + return; + } + const bounds = isQuiz() + ? getQuizQuestionAreaTileBounds(mapData) + : (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData)); + const text = (playQuizText || '').trim(); + if (!bounds || !text) { + clearQuizMapQuestionCarryFeedback(); + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null; + const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null; + const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize); + const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize); + const zDom = playDomSyncZoom(); + const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; + textEl.textContent = text; + panel.style.left = Math.round(left) + 'px'; + panel.style.top = Math.round(top) + 'px'; + panel.style.width = Math.round(Math.max(48, wPx)) + 'px'; + panel.style.height = Math.round(Math.max(40, hPx)) + 'px'; + panel.classList.remove('is-hidden'); + panel.setAttribute('aria-hidden', 'false'); + if (isQuiz() && !isQuizCarry()) { + panel.classList.add('quiz-map-question-panel--true-false'); + } else { + panel.classList.remove('quiz-map-question-panel--true-false'); + } + if (isQuizCarry()) { + applyQuizCarryMapPanelThemeIfNeeded(panel, textEl); + } else if (isQuiz()) { + applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl); + } else { + clearQuizMapPanelThemeInline(panel, textEl); + } + const thFont = isQuizCarry() + ? getEffectiveCarryMapPanelThemeForApply() + : getEffectiveQuizMapPanelThemeForApply(); + const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom); + const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10)); + /* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */ + if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') { + panel.classList.add('quiz-map-question-panel--question-mission-live'); + } else { + panel.classList.remove('quiz-map-question-panel--question-mission-live'); + if (panel.dataset.qmPlaqueTry) { + panel.style.removeProperty('background-image'); + panel.style.removeProperty('background-size'); + panel.style.removeProperty('background-position'); + delete panel.dataset.qmPlaqueTry; + } + } + panel.classList.toggle( + 'quiz-map-question-panel--tf-mock-qz', + !!(isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live' && isMissionMockHudPlay()), + ); + const kickerEl = document.getElementById('quiz-map-question-kicker'); + const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'; + const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer'; + if (kickerEl) { + let showK = false; + let line = ''; + if (missionLiveQ && inReadOrAnswer) { + const qi = Math.max(0, Number(playQuizQuestionIndex) || 0); + const qt = Math.max(0, Number(playQuizQuestionTotal) || 0); + if (qi > 0 && qt > 0) { + line = '- Quiz ' + qi + ' / ' + qt + ' -'; + showK = true; + } else if (qi > 0) { + line = '- Quiz ' + qi + ' -'; + showK = true; + } + } + if (showK && line) { + kickerEl.textContent = line; + kickerEl.classList.remove('is-hidden'); + kickerEl.setAttribute('aria-hidden', 'false'); + panel.classList.add('quiz-map-question-panel--has-kicker'); + } else { + kickerEl.textContent = ''; + kickerEl.classList.add('is-hidden'); + kickerEl.setAttribute('aria-hidden', 'true'); + panel.classList.remove('quiz-map-question-panel--has-kicker'); + } + } + fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont); + } + + /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */ + function syncQuizCarryTimeupDeskLayerPosition() { + const layer = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!layer || layer.classList.contains('is-hidden')) return; + const anchor = layer.querySelector('.qc-timeup-desk-anchor') + || layer.querySelector('.qc-timeup-desk-inner') + || (() => { + const img = document.getElementById('quiz-carry-timeup-txt-img'); + return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null; + })(); + if (!anchor || !canvas) return; + const clearToFullStack = () => { + anchor.style.left = '0'; + anchor.style.top = '0'; + anchor.style.width = '100%'; + anchor.style.height = '100%'; + }; + const stack = document.getElementById('play-canvas-stack'); + const panel = document.getElementById('quiz-map-question-panel'); + if (stack && panel && !panel.classList.contains('is-hidden')) { + try { + const sr = stack.getBoundingClientRect(); + const pr = panel.getBoundingClientRect(); + const rw = pr.width; + const rh = pr.height; + if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) { + anchor.style.left = Math.round(pr.left - sr.left) + 'px'; + anchor.style.top = Math.round(pr.top - sr.top) + 'px'; + anchor.style.width = Math.round(rw) + 'px'; + anchor.style.height = Math.round(rh) + 'px'; + return; + } + } catch (e) { /* fallback ด้านล่าง */ } + } + const pl = panel && panel.style && String(panel.style.left || '').trim(); + const pt = panel && panel.style && String(panel.style.top || '').trim(); + const pw = panel && panel.style && String(panel.style.width || '').trim(); + const ph = panel && panel.style && String(panel.style.height || '').trim(); + if (pl && pt && pw && ph) { + anchor.style.left = pl; + anchor.style.top = pt; + anchor.style.width = pw; + anchor.style.height = ph; + return; + } + if (!mapData || mapData.gameType !== 'quiz_carry') { + clearToFullStack(); + return; + } + const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + clearToFullStack(); + return; + } + const cc = getQuizCarryMapCameraWorldCenterPxPlay(); + const camX = cc ? cc.cx : me.x * tileSize; + const camY = cc ? cc.cy : me.y * tileSize; + const zDom = playDomSyncZoom(); + const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; + anchor.style.left = Math.round(left) + 'px'; + anchor.style.top = Math.round(top) + 'px'; + anchor.style.width = Math.round(Math.max(48, wPx)) + 'px'; + anchor.style.height = Math.round(Math.max(40, hPx)) + 'px'; + } + + function carryEmbedCountdownAnchorResolved() { + if (!mapData || !isQuizCarry()) return 'screen'; + const v = mapData.carryEmbedCountdownAnchor; + if (v === 'grid' || v === 'map') return v; + return 'screen'; + } + + function resetQuizCarryEmbedCountdownLayoutDom() { + const ov = document.getElementById('quiz-carry-embed-countdown'); + const inner = document.getElementById('quiz-carry-embed-countdown-inner'); + if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor'); + if (inner) { + inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect'); + inner.style.position = ''; + inner.style.left = ''; + inner.style.top = ''; + inner.style.width = ''; + inner.style.height = ''; + inner.style.transform = ''; + inner.style.maxWidth = ''; + inner.style.boxSizing = ''; + inner.style.display = ''; + inner.style.margin = ''; + } + } + + /** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */ + function syncQuizCarryEmbedCountdownLayout() { + if (!quizCarryEmbedMissionFlowActive() || !mapData) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const ov = document.getElementById('quiz-carry-embed-countdown'); + const inner = document.getElementById('quiz-carry-embed-countdown-inner'); + const canvasEl = document.getElementById('game-canvas'); + if (!ov || !inner || !canvasEl) return; + if (ov.classList.contains('is-hidden')) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const anchor = carryEmbedCountdownAnchorResolved(); + if (anchor === 'screen') { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + let bounds = null; + if (anchor === 'grid') { + bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } else { + bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } + const cc = getQuizCarryMapCameraWorldCenterPxPlay(); + const camX = cc ? cc.cx : me.x * tileSize; + const camY = cc ? cc.cy : me.y * tileSize; + const zDom = playDomSyncZoom(); + const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2; + const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom; + const r = canvasEl.getBoundingClientRect(); + const sl = r.left + l; + const st = r.top + t; + ov.classList.add('quiz-carry-embed-countdown--map-anchor'); + inner.classList.add('quiz-carry-embed-countdown-inner--map-rect'); + inner.style.position = 'fixed'; + inner.style.left = Math.round(sl) + 'px'; + inner.style.top = Math.round(st) + 'px'; + inner.style.width = Math.round(Math.max(100, wPx)) + 'px'; + inner.style.height = Math.round(Math.max(72, hPx)) + 'px'; + inner.style.transform = 'none'; + inner.style.boxSizing = 'border-box'; + inner.style.display = 'flex'; + inner.style.flexDirection = 'column'; + inner.style.alignItems = 'center'; + inner.style.justifyContent = 'center'; + inner.style.margin = '0'; + inner.style.maxWidth = 'none'; + } + + /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */ + function syncQuizCarryEmbedQuestionStrip() { + const wrap = document.getElementById('quiz-carry-embed-q-strip'); + const p = document.getElementById('quiz-carry-embed-q-strip-text'); + if (!wrap || !p) return; + wrap.classList.remove('quiz-carry-embed-q-strip--countdown'); + wrap.classList.add('is-hidden'); + wrap.setAttribute('aria-hidden', 'true'); + } + + function updatePlayQuizTimerDisplay() { + const el = document.getElementById('quiz-game-timer'); + if (!el) return; + if (!playQuizPhaseEndsAt) { + el.textContent = ''; + return; + } + const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - serverNow()) / 1000)); + el.textContent = s + ' วินาที'; + } + + function clampPreviewMs(n, def, minV, maxV) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(minV, Math.min(maxV, Math.floor(v))); + } + + function clampCarrySessionLen(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(500, Math.floor(v))); + } + + function buildQuizPoolFromMap(md) { + if (!md || !Array.isArray(md.quizQuestions)) return []; + return md.quizQuestions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + + function shufflePreviewQuizQuestionsPlay(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; + } + + /** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */ + function clampPreviewQuizRoundQuestionCount(settings) { + const v = Number(settings && settings.quizRoundQuestionCount); + if (!Number.isFinite(v)) return 10; + return Math.max(1, Math.min(50, Math.floor(v))); + } + + function finishPreviewQuizSessionPlay() { + previewQuizStep = 'done'; + playQuizPhaseLocal = null; + previewQuizCurrent = null; + playQuizPhaseEndsAt = 0; + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const qov = document.getElementById('quiz-game-overlay'); + if (qov) qov.classList.add('is-hidden'); + const mapPanel = document.getElementById('quiz-map-question-panel'); + if (mapPanel) { + mapPanel.classList.add('is-hidden'); + mapPanel.setAttribute('aria-hidden', 'true'); + } + if (isQuizQuestionMissionUiMapPlay()) { + quizQuestionMissionPhase = 'ended'; + applyQuizQuestionMissionPanelImages(); + const mission = quizQuestionMissionBuildPayload(); + showGauntletCrownMissionOverlay(mission); + return; + } + playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว'; + if (qov) qov.classList.remove('is-hidden'); + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] จบรอบ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)'; + } + + function clearPreviewBotAnswerPaths() { + if (!playBotsEnabled()) return; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botPath = []; + o.botAnswerWander = false; + }); + } + + function resetPreviewBotsQuizState() { + if (!playBotsEnabled()) return; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.quizCannotTrue = false; + o.quizCannotFalse = false; + o.botPath = []; + o.botAnswerWander = false; + }); + } + + function applyPreviewReadPhase(q, poolLen) { + clearPreviewBotAnswerPaths(); + previewQuizStep = 'read'; + playQuizPhaseLocal = 'read'; + previewQuizCurrent = q; + playQuizText = q.text; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs; + playQuizQuestionIndex = previewQuizQIndex + 1; + playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0); + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) { + phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + poolLen; + } + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ'; + } + + function applyPreviewAnswerPhase() { + previewQuizStep = 'answer'; + playQuizPhaseLocal = 'answer'; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด'; + const leg = document.getElementById('quiz-play-legend'); + if (leg && previewQuizCurrent) { + leg.textContent = previewQuizCurrent.answerTrue + ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า' + : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู'; + } + previewBotsPrepareAnswerRound(); + } + + function applyPreviewBetweenPhase() { + clearPreviewBotAnswerPaths(); + previewQuizStep = 'between'; + playQuizPhaseLocal = null; + playQuizText = 'ข้อถัดไปกำลังจะมา…'; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'รอสักครู่แล้วไปข้อถัดไป (สับแล้วตามจำนวนข้อต่อรอบ)'; + } + + function quizCellOnPlay(grid, x, y) { + return !!(grid && grid[y] && grid[y][x] === 1); + } + + /** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */ + function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) { + if (!mapData || !isQuiz()) return false; + for (const k of quizTilesFootprintPlay(ax, ay)) { + const parts = k.split(','); + const fx = +parts[0]; + const fy = +parts[1]; + if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true; + } + return false; + } + + /** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */ + function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) { + if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false; + if (entId === myId || ent === me) { + return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse); + } + const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse); + const everWrong = !!playQuizEverWrong[String(entId)]; + return locked || everWrong; + } + + function spawnTileWalkablePlay(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 spawnFootprintFitsPlay(md, anchorX, anchorY) { + if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1))); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, anchorX + cellsW); + const maxY = Math.min(h, anchorY + cellsH); + for (let ty = anchorY; ty < maxY; ty++) { + for (let tx = anchorX; tx < maxX; tx++) { + if (!spawnTileWalkablePlay(md, tx, ty)) return false; + } + } + return true; + } + + /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */ + function pickRandomSpawnFromMapPlay(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 && spawnFootprintFitsPlay(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const u = new Uint32Array(1); + (typeof crypto !== 'undefined' && crypto.getRandomValues) + ? crypto.getRandomValues(u) + : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0)); + const pick = pool[u[0] % pool.length]; + return { x: pick.x, y: pick.y }; + } + + function parseLobbyPlayerSpawnsFromMapPlay(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: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */ + function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(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; + } + } + } + } + } + + /** จุดเกิด Quiz Battle — ต้องอยู่บนเส้นทาง (กล่องสไปรต์) ไม่ใช่แค่ snap จาก spawnArea นอกเลน */ + function collectQuizBattleValidSpawnCentersPlay(md) { + const out = []; + if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) return out; + const w = md.width || 20, h = md.height || 15; + const g = md.quizBattlePathArea; + const seen = new Set(); + const prevMap = mapData; + mapData = md; + try { + 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 (!quizBattleSnapTargetValidPlay(nx, ny)) continue; + seen.add(key); + out.push({ tx, ty, x: nx, y: ny }); + } + } + } + } finally { + mapData = prevMap; + } + out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x); + return out; + } + + /** จุดโลกใกล้ pref — ลองกลางช่องก่อน แล้วหาใน spawnArea (ฟ้า) ที่วางตัวได้ */ + function quizBattleNearestValidSpawnWorldPlay(md, prefTx, prefTy) { + const ptx = Math.floor(Number(prefTx)) || 0; + const pty = Math.floor(Number(prefTy)) || 0; + const prevMap = mapData; + mapData = md; + try { + for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) { + if (quizBattleSpawnWallClearPlay(md, nx, ny)) return { x: nx, y: ny }; + } + const pool = collectQuizBattleSpawnPoolPlay(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 = collectQuizBattleValidSpawnCentersPlay(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 }; + } finally { + mapData = prevMap; + } + } + + /** ช่อง spawnArea (ฟ้า) ที่วางตัวได้ — สุ่มในพื้นที่ฟ้า ไม่บังคับอยู่บนเลนม่วง */ + function collectQuizBattleSpawnPoolPlay(md) { + const out = []; + if (!md) return out; + const prevMap = mapData; + mapData = md; + try { + 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 (quizBattlePathModeActive(md)) { + if (!quizBattleSpawnWallClearPlay(md, nx, ny)) continue; + } else if (!spawnFootprintFitsPlay(md, tx, ty)) { + continue; + } + seen.add(key); + out.push({ tx, ty, x: nx, y: ny }); + } + } + if (!out.length && quizBattlePathModeActive(md)) { + return collectQuizBattleValidSpawnCentersPlay(md); + } + } finally { + mapData = prevMap; + } + return out; + } + + function pickQuizBattleRandomSpawnWorldPlay(md) { + const pool = collectQuizBattleSpawnPoolPlay(md); + const fb = md.spawn || { x: 1, y: 1 }; + if (!pool.length) { + return quizBattleNearestValidSpawnWorldPlay(md, fb.x, fb.y); + } + const u = new Uint32Array(1); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(u); + else u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0); + const pick = pool[u[0] % pool.length]; + return { x: pick.x, y: pick.y }; + } + + function quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex) { + if (!md) return { x: 1.5, y: 1.5 }; + const ord = joinOrderIndex | 0; + const mode = md.lobbySpawnMode; + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapPlay(md); + const j = Math.min(Math.max(0, ord), 5); + const slot = slots[j]; + if (slot) return quizBattleNearestValidSpawnWorldPlay(md, slot.x, slot.y); + return pickQuizBattleRandomSpawnWorldPlay(md); + } + if (mode === 'fixed' && md.spawn) { + const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1; + return quizBattleNearestValidSpawnWorldPlay(md, sx, sy); + } + /* random (ค่าเริ่มต้นใน editor) — สุ่มในพื้นที่ฟ้า ไม่เรียงตาม join order */ + return pickQuizBattleRandomSpawnWorldPlay(md); + } + + function pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex) { + const world = quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex); + return { x: Math.floor(world.x), y: Math.floor(world.y) }; + } + + /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */ + function pickSpawnForJoinPlay(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActive(md)) { + const qb = pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(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 (spawnFootprintFitsPlay(md, x, y)) return { x, y }; + return pickRandomSpawnFromMapPlay(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapPlay(md); + augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots); + const pick = slots[j]; + if (pick && spawnFootprintFitsPlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMapPlay(md); + } + return pickRandomSpawnFromMapPlay(md); + } + + const GAUNTLET_PREVIEW_MAX = 6; + function gauntletSpawnYsPlay(md, playerCount) { + const h = md.height || 15; + const lo = 1; + const hi = Math.max(lo, h - 2); + const n = Math.min(GAUNTLET_PREVIEW_MAX, 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; + } + function collectGauntletSpawnSlotsFromSpawnAreaPlay(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 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y }); + } + } + slots.sort((a, b) => a.y - b.y || a.x - b.x); + return slots; + } + function collectGauntletSpawnSlotsPlay(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_PREVIEW_MAX) 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 (!spawnTileWalkablePlay(md, x, y)) continue; + slots.push({ x, y }); + } + if (slots.length > 0) return slots; + } + return collectGauntletSpawnSlotsFromSpawnAreaPlay(md); + } + function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) { + const w = md.width || 20; + if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX; + if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback; + for (let d = 0; d < w; d++) { + if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d; + if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d; + } + return Math.max(0, Math.min(w - 1, spawnColX)); + } + function gauntletSpawnPositionsPlay(md, playerCount) { + const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount)); + const slots = collectGauntletSpawnSlotsPlay(md); + const fallbackYs = gauntletSpawnYsPlay(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 = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX); + out.push({ x, y }); + } + return out; + } + /** + * โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y) + * @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap) + */ + function applyGauntletPreviewSpawnLayout(onlyBots) { + if (!mapData || mapData.gameType !== 'gauntlet') return; + /* เกมจริง (ห้องจริงทุกแบบ: คนล้วน/มีบอท): server เป็นเจ้าของตำแหน่ง/เลนของทุกตัวผ่าน gauntlet-sync + → ห้าม client จัดเลนเอง เพราะ layout นี้สแนป me ไป pos[0] (เลนบนสุด, จุดสตาร์ท) เสมอ + ทำให้ตอน host หลุด me (host ใหม่) ไปทับบอท takeover ที่กินเลนบนสุด + เฉพาะ editor preview (preview=1, ไม่มี server ขับ) เท่านั้นที่ client จัดเลนเอง */ + if (!previewMode) return; + const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); + const botIds = [...others.keys()].filter(isPreviewBotId).sort(); + const humanCount = 1 + realIds.length; + const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length); + const pos = gauntletSpawnPositionsPlay(mapData, total); + if (onlyBots) { + let idx = humanCount; + botIds.forEach((bid) => { + const o = others.get(bid); + if (!o || idx >= pos.length) return; + o.x = pos[idx].x; + o.tx = pos[idx].x; + o.y = pos[idx].y; + o.ty = pos[idx].y; + idx++; + }); + return; + } + let idx = 0; + if (idx < pos.length) { + me.x = pos[idx].x; + me.y = pos[idx].y; + me.tx = me.x; + me.ty = me.y; + idx++; + } + realIds.forEach((rid) => { + const o = others.get(rid); + if (!o || idx >= pos.length) return; + o.x = pos[idx].x; + o.tx = pos[idx].x; + o.y = pos[idx].y; + o.ty = pos[idx].y; + idx++; + }); + botIds.forEach((bid) => { + const o = others.get(bid); + if (!o || idx >= pos.length) return; + o.x = pos[idx].x; + o.tx = pos[idx].x; + o.y = pos[idx].y; + o.ty = pos[idx].y; + idx++; + }); + } + + function isPreviewBotId(id) { + return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0; + } + + function countPlayHumans() { + let n = 1; + others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; }); + return n; + } + + /** ป้ายชื่อบอท "บอท N (tier)" — เรียงตาม id (เหมือนทุกเครื่อง) ใช้ o.botTier (host เป็นเจ้าของ ผ่าน fill-bot-state) */ + function relabelPreviewBotsPlay() { + let k = 0; + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => { + const o = others.get(bid); + if (!o) return; + const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)'; + o.nickname = 'บอท ' + (++k) + ' ' + tag; + }); + } + + /* MG5/6/7 server-auth: server ขับ entity บอทที่ "client นี้ไม่เคยสร้าง" (นับ human ไม่ตรง server / + race LobbyB→play / reconnect / ghost) → fill-bot-state ถูกปิดสำหรับเกม server-auth จึงไม่มี self-heal + → *-bot-move ทุก packet ถูกดรอป (if(!o)return) = บอทตัวนั้นค้าง/หายเฉพาะ client นี้ (= อาการ MG6). + สร้าง preview-bot entity เดี๋ยวนี้ + กำหนด avatar/ป้ายตาม index เรียง (deterministic ทุกเครื่อง). + คืน null ถ้าเป็น takeover id (=socket-id ของคนที่ออก) ที่ entity ยังไม่มา → caller รอ join/snapshot. */ + function ensureServerAuthBotEntityPlay(id, dir) { + if (id == null) return null; + let o = others.get(id); + if (o) return o; + if (!isPreviewBotId(id)) return null; + o = { + x: 1, y: 1, tx: 1, ty: 1, + direction: dir || 'down', + nickname: 'บอท', + characterId: null, + playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id), + botTier: 'avg', + spaceShooterScore: 0, spaceShooterHits: 0, spaceShooterEliminated: false, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossEliminated: false, + jumpSurviveEliminated: false, + }; + try { o.balloonBossBalloons = balloonBossBalloonsStartPlay(); } catch (e) { o.balloonBossBalloons = 5; } + others.set(id, o); + /* avatar ตาม index เรียง (เหมือน rebalancePreviewBots 5967-5969) → ทุกเครื่องได้ชุด avatar เดียวกัน */ + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, bi) => { const bo = others.get(bid); if (bo) bo.characterId = pickPreviewBotCharacterId(bi); }); + try { relabelPreviewBotsPlay(); } catch (e) { /* ignore */ } + try { if (o.characterId) preloadPlayTintForAvatar(o.characterId, o.playTint); } catch (e) { /* ignore */ } + return o; + } + + function rebalancePreviewBots() { + if (!playBotsEnabled() || !mapData) return; + const human = countPlayHumans(); + let wantBots = Math.max(0, playBotTargetHeadcount() - human); + wantBots = Math.min(wantBots, Math.max(0, 6 - human)); /* total ≤ 6 เสมอ (กัน over-count เช่น reconnect หลัง bot-takeover) */ + /* Card 3 Ban — บอทที่ถูกแบนรอบนี้ (slot N) ไม่ลงเล่น: ลบทิ้ง + ลดเป้าจำนวนลง 1 + ข้าม slot นั้นตอนสร้าง */ + if (playBannedBotSlot >= 0) { + others.delete(PREVIEW_BOT_PREFIX + playBannedBotSlot); + wantBots = Math.max(0, wantBots - 1); + } + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const o = others.get(bid); + if (!o) return; + o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid); + if (o.botTier == null) { + const tierRoll = Math.random(); + const stackPrev = mapData && mapData.gameType === 'stack'; + o.botTier = stackPrev + ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak') + : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'); + } + if (o.quizCannotTrue == null) o.quizCannotTrue = false; + if (o.quizCannotFalse == null) o.quizCannotFalse = false; + if (!Array.isArray(o.botPath)) o.botPath = []; + if (o.botAnswerWander == null) o.botAnswerWander = false; + if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0; + if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks; + if (o.gauntletScore == null) { + o.gauntletScore = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0; + } + if (o.gauntletEliminated == null) o.gauntletEliminated = false; + if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) { + const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; + o.botWanderDx = wd[0]; + o.botWanderDy = wd[1]; + } + if (typeof o.botWanderNextTurn !== 'number') { + o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900); + } + }); + let botIds = [...others.keys()].filter(isPreviewBotId); + while (botIds.length > wantBots) { + const drop = botIds.pop(); + if (drop) others.delete(drop); + } + botIds = [...others.keys()].filter(isPreviewBotId); + while (botIds.length < wantBots) { + /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */ + let nextSlot = 0; + while (others.has(PREVIEW_BOT_PREFIX + nextSlot) || nextSlot === playBannedBotSlot) nextSlot++; + previewBotSeq = Math.max(previewBotSeq, nextSlot + 1); + const id = PREVIEW_BOT_PREFIX + nextSlot; + const joinIdx = countPlayHumans() + botIds.length; + let x; + let y; + if (mapData.gameType === 'jump_survive') { + const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx); + x = pos.x; + y = pos.y; + } else if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { + const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, joinIdx); + x = pos.x; + y = pos.y; + } else { + const sp = pickSpawnForJoinPlay(mapData, joinIdx); + const jx = (Math.random() - 0.5) * 0.4; + const jy = (Math.random() - 0.5) * 0.4; + x = sp.x + 0.5 + jx; + y = sp.y + 0.5 + jy; + } + const tierRoll = Math.random(); + const stackPrev = mapData && mapData.gameType === 'stack'; + const botTier = stackPrev + ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak') + : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'); + const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; + const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay(); + others.set(id, { + x, y, tx: x, ty: y, + direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)], + nickname: 'บอท', + characterId: pickPreviewBotCharacterId(botIds.length), + spawnJoinOrder: joinIdx, + playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id), + botTier, + gauntletJumpTicks: 0, + gauntletJumpVis: 0, + gauntletScore: crownStart ? 100 : 0, + gauntletEliminated: false, + quizCannotTrue: false, + quizCannotFalse: false, + quizCarryHeld: null, + botPath: [], + botAnswerWander: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), + jumpSurviveEliminated: false, + spaceShooterScore: 0, + balloonBossScore: 0, + balloonBossBossDmg: 0, + balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5, + balloonBossEliminated: false, + botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160), + }); + /* preload เลเยอร์สี + tint ของบอททันทีตอนสร้าง — กัน score HUD บน host โชว์ avatar บอท "ไม่ย้อมสี" + (เดิม preloadPlayOccupantsTints รันตอน scene-load ก่อนบอทถูกสร้าง → เลเยอร์ยังไม่โหลด) */ + try { + const bo = others.get(id); + if (bo && bo.characterId) preloadPlayTintForAvatar(bo.characterId, bo.playTint || resolvePlayTintForPeer(id)); + } catch (ePB) { /* ignore */ } + botIds.push(id); + } + relabelPreviewBotsPlay(); + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => { + const o = others.get(bid); + if (o) o.characterId = pickPreviewBotCharacterId(botIdx); + }); + if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) { + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const o = others.get(bid); + if (!o) return; + const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y); + o.x = s.x; + o.y = s.y; + o.tx = s.x; + o.ty = s.y; + }); + } + if (mapData.gameType === 'quiz_carry') { + const t0 = performance.now(); + [...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => { + const o = others.get(bid); + if (!o) return; + o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140); + }); + } + if (mapData.gameType === 'gauntlet') { + applyGauntletPreviewSpawnLayout(false); + emitGauntletPreviewRowsToServer(); + } + if (mapData.gameType === 'stack') { + applyStackPreviewSpawnLayout(); + rebuildStackPreviewTurnOrder(); + } + if (mapData.gameType === 'jump_survive') { + applyJumpSurvivePreviewSpawnLayout(false); + } + if (mapData.gameType === 'space_shooter') { + applySpaceShooterSpawnLayoutPlay(); + } + if (mapData.gameType === 'balloon_boss') { + applyBalloonBossSpawnLayoutPlay(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + } + if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud(); + try { + preloadPlayOccupantsTints(); + preloadPlayLobbyIdleAvatarsForOccupants(); + } catch (e) { /* ignore */ } + } + + function pickRandomWalkableCellInAnswerGrid(grid, o) { + if (!grid || !mapData) return null; + const w = mapData.width || 20, h = mapData.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 (row[x] !== 1) continue; + if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y }); + } + } + if (!pool.length) return null; + return pool[Math.floor(Math.random() * pool.length)]; + } + + /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */ + function previewBotsPrepareAnswerRound() { + if (!playBotsEnabled() || !mapData || !isQuiz() || !previewQuizCurrent) return; + if (detectiveCaseFillBots) return; /* เกมจริง: server ตัดสินคำตอบบอทเอง */ + const correctTrue = !!previewQuizCurrent.answerTrue; + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botPath = []; + o.botAnswerWander = false; + const tier = o.botTier || 'avg'; + let pCorrect = 0.52; + if (tier === 'sharp') pCorrect = 0.84; + else if (tier === 'weak') pCorrect = 0.22; + if (Math.random() < 0.14) { + o.botAnswerWander = true; + return; + } + const wantsCorrect = Math.random() < pCorrect; + const targetTrue = wantsCorrect ? correctTrue : !correctTrue; + const zoneGrid = targetTrue ? qt : qf; + const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o); + if (!dest) { + o.botAnswerWander = true; + return; + } + const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o); + if (!path || path.length <= 1) { + o.botAnswerWander = true; + return; + } + o.botPath = path.slice(1); + }); + } + + /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */ + function findNearestOutsideQuizAnswerZonesPlay(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) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(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 quizResolveChoiceFromStanding(ox, oy, correctTrue) { + const tx = Math.floor(ox); + const ty = Math.floor(oy); + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + const inT = quizCellOnPlay(qt, tx, ty); + const inF = quizCellOnPlay(qf, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + const right = choice !== null && choice === correctTrue; + return { choice, right }; + } + + /** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */ + function quizPreviewJoinOrderForRespawn(actorId) { + if (actorId === myId) { + const jm = Number(me && me.spawnJoinOrder); + return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0; + } + const o = others.get(actorId); + if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) { + return Math.max(0, Math.floor(o.spawnJoinOrder)); + } + if (!isPreviewBotId(actorId)) { + const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort(); + const idx = othersReal.indexOf(actorId); + return idx >= 0 ? 1 + idx : 1; + } + const bots = [...others.keys()].filter(isPreviewBotId).sort(); + const bi = bots.indexOf(actorId); + if (bi < 0) return 0; + return countPlayHumans() + bi; + } + + /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */ + function resolvePreviewQuizRound() { + if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return; + const correctTrue = !!previewQuizCurrent.answerTrue; + const results = []; + const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue); + let right = mine.right; + let choice = mine.choice; + if (mine.right) { + playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + QUIZ_TF_POINTS_PER_CORRECT; + } else { + playQuizPlayerLocal.cannotTrue = true; + playQuizPlayerLocal.cannotFalse = true; + if (!warpMeIntoQuizGhostZonePlay()) { + const ordMe = quizPreviewJoinOrderForRespawn(myId); + const sp = pickSpawnForJoinPlay(mapData, ordMe); + const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); + me.x = pos.x; + me.y = pos.y; + me.tx = me.x; + me.ty = me.y; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); + } + } + results.push({ + id: myId, + right, + choice, + score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0, + }); + if (playBotsEnabled()) { + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue); + o.botPath = []; + o.botAnswerWander = false; + if (br.right) { + playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT; + } else { + o.quizCannotTrue = true; + o.quizCannotFalse = true; + const ordB = quizPreviewJoinOrderForRespawn(id); + const sp = pickSpawnForJoinPlay(mapData, ordB); + const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); + o.x = pos.x + (Math.random() - 0.5) * 0.35; + o.y = pos.y + (Math.random() - 0.5) * 0.35; + o.tx = o.x; + o.ty = o.y; + } + results.push({ + id, + right: br.right, + choice: br.choice, + score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0, + }); + }); + } + const r = { results, scores: { ...playLiveQuizScores } }; + results.forEach(function (row) { + if (!row || row.id == null) return; + if (!row.right) playQuizEverWrong[String(row.id)] = true; + }); + renderPlayQuizScoreboard(playLiveQuizScores); + if (results.some(function (row) { return row && row.right; })) { + spawnQuizTrueFalseScorePopupPlay(correctTrue); + } + showPlayQuizFeedback(r); + } + + function advancePreviewQuizIfDue() { + if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return; + if (previewQuizStep === 'done') return; + if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return; + const pool = previewQuizPool; + if (!pool || !pool.length) return; + if (previewQuizStep === 'read') { + applyPreviewAnswerPhase(); + return; + } + if (previewQuizStep === 'answer') { + resolvePreviewQuizRound(); + applyPreviewBetweenPhase(); + return; + } + if (previewQuizStep === 'between') { + previewQuizQIndex += 1; + if (previewQuizQIndex >= pool.length) { + finishPreviewQuizSessionPlay(); + return; + } + const q = pool[previewQuizQIndex]; + if (q) applyPreviewReadPhase(q, pool.length); + } + } + + async function loadPreviewQuizAndStart() { + resetPreviewBotsQuizState(); + playQuizEverWrong = {}; + let settings = null; + try { + const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); + if (r.ok) settings = await r.json(); + } catch (e) { /* use map fallback */ } + let pool = []; + if (settings && Array.isArray(settings.questions) && settings.questions.length) { + pool = settings.questions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData); + const cap = clampPreviewQuizRoundQuestionCount(settings); + const shuffled = shufflePreviewQuizQuestionsPlay(pool); + previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length)); + previewQuizQIndex = 0; + const dRead = 10000; + const dAns = 5000; + const dBet = 3500; + previewQuizTiming = { + readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000), + answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000), + betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000), + }; + previewQuizCurrent = null; + if (!pool.length) { + playQuizPhaseLocal = 'read'; + previewQuizStep = 'read'; + playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)'; + playQuizPhaseEndsAt = 0; + playQuizQuestionIndex = 0; + playQuizQuestionTotal = 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ'; + initPlayLiveQuizScoresZeros(); + startPlayQuizTimer(); + return; + } + const session = previewQuizPool; + const q = session[0]; + if (q) applyPreviewReadPhase(q, session.length); + initPlayLiveQuizScoresZeros(); + startPlayQuizTimer(); + } + + function startPlayQuizTimer() { + if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); + playQuizTimerInterval = setInterval(() => { + updatePlayQuizTimerDisplay(); + advancePreviewQuizIfDue(); + }, 200); + updatePlayQuizTimerDisplay(); + } + + function teardownPlayQuizUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + const panel = document.getElementById('quiz-map-question-panel'); + const mapQText = document.getElementById('quiz-map-question-text'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, mapQText); + } + playQuizPhaseLocal = null; + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + playQuizText = ''; + playQuizPhaseEndsAt = 0; + playQuizQuestionIndex = 0; + playQuizQuestionTotal = 0; + previewQuizPool = []; + previewQuizQIndex = 0; + previewQuizCurrent = null; + previewQuizStep = 'read'; + playLiveQuizScores = {}; + playQuizEverWrong = {}; + quizTfScorePopups = []; + playPath = []; + hidePlayQuizHud(); + quizCarryJoinSettingsSnap = null; + quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; + quizCarryMapPanelTheme = null; + quizMapPanelTheme = null; + quizCarryChoicePlaqueThemes = null; + resetQuizCarryPlayState(); + resetQuizBattlePlayState(); + quizQuestionMissionPhase = null; + quizQuestionMissionDeferredPhase = null; + if (quizQuestionMissionCountdownTimer) { + clearTimeout(quizQuestionMissionCountdownTimer); + quizQuestionMissionCountdownTimer = null; + } + const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay'); + if (gcmTeardown) gcmTeardown.classList.add('is-hidden'); + cancelEmbedPreviewLobbyReturnTimer(); + const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn'); + if (grabBtnTeardown) { + grabBtnTeardown.classList.add('is-hidden'); + grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active'); + grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place'); + grabBtnTeardown.style.pointerEvents = ''; + const im = grabBtnTeardown.querySelector('img'); + if (im) im.src = '/Game/img/quiz-carry/btn-grab.png'; + } + const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn'); + if (gchJumpTeardown) { + gchJumpTeardown.classList.add('is-hidden'); + gchJumpTeardown.setAttribute('aria-hidden', 'true'); + gchJumpTeardown.style.pointerEvents = ''; + } + const stackDropTeardown = document.getElementById('stack-tower-drop-btn'); + if (stackDropTeardown) { + stackDropTeardown.classList.add('is-hidden'); + stackDropTeardown.setAttribute('aria-hidden', 'true'); + stackDropTeardown.style.pointerEvents = ''; + stackDropTeardown.classList.remove('stack-tower-drop-btn--active'); + } + hideStackTowerResultFlashDomOnlyPlay(); + } + + function setupPlayQuizUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) { + const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry()) + || (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'); + if (hideBottomQuizBar) ov.classList.add('is-hidden'); + else ov.classList.remove('is-hidden'); + } + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + const leg = document.getElementById('quiz-play-legend'); + if (previewMode) { + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…'; + if (leg) leg.textContent = ''; + if (isQuizQuestionMissionUiMapPlay()) { + if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ'; + if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/'; + if (leg) leg.textContent = ''; + } else { + loadPreviewQuizAndStart(); + } + } else { + playQuizPhaseLocal = null; + playQuizText = 'รอคำถามจากโฮสต์…'; + playQuizPhaseEndsAt = 0; + playQuizQuestionIndex = 0; + playQuizQuestionTotal = 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)'; + if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby'; + const tEl = document.getElementById('quiz-game-timer'); + if (tEl) tEl.textContent = ''; + } + if (isQuiz() && !isQuizCarry()) { + (async () => { + try { + let s = {}; + const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); + if (r.ok) { + const raw = await r.text(); + const trimmed = (raw || '').trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + s = JSON.parse(trimmed); + } catch (e) { /* ignore */ } + } + } + s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {}); + setQuizMapPanelThemeFromApi(s); + try { + syncPlayQuizMapPanel(); + } catch (e2) { /* ignore */ } + } catch (e) { /* ignore */ } + })(); + } + } + + /** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */ + function applyQuizPhaseFromServer(p) { + if (!p || !mapData || !isQuiz()) return; + if (p.text) playQuizText = p.text; + playQuizPhaseLocal = p.phase; + playQuizPhaseEndsAt = p.endsAt || 0; + playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0); + playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 0); + const phaseEl = document.getElementById('quiz-game-phase-label'); + const qEl = document.getElementById('quiz-game-question'); + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')) + : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')); + } + if (qEl) qEl.textContent = playQuizText || ''; + const ov = document.getElementById('quiz-game-overlay'); + if (isQuizQuestionMissionHudActivePlay()) { + if (ov) ov.classList.add('is-hidden'); + } else if (ov) { + ov.classList.remove('is-hidden'); + } + if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); + playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200); + updatePlayQuizTimerDisplay(); + if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros(); + if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {}; + try { + syncPlayQuizMapPanel(); + } catch (eQm) { /* ignore */ } + } + + function isFrogger() { return mapData && mapData.gameType === 'frogger'; } + /* ===== Sound (jdSound) — scene + end-sfx helpers ===== */ + var JD_SCENE_MAP = { quiz: 'mg1', gauntlet: 'mg2', stack: 'mg3', quiz_carry: 'mg4', jump_survive: 'mg5', space_shooter: 'mg6', balloon_boss: 'mg7' }; + function jdPlayScene() { var gt = mapData && mapData.gameType; return (gt && JD_SCENE_MAP[gt]) || 'mg1'; } + /* เล่นเสียงจบเกมจากชื่อไฟล์แฟลชผล (complete/victory=ชนะ · timeup=หมดเวลา · gameover=แพ้) + หยุด BGM */ + function jdEndSfxFromFile(f) { + if (!window.jdSound) return; + f = String(f || ''); var s = jdPlayScene(); + if (f.indexOf('victory') >= 0 || f.indexOf('complete') >= 0) window.jdSound.playSfx('win', s); + else if (f.indexOf('timeup') >= 0) window.jdSound.playSfx('timeup', s); + else if (f.indexOf('gameover') >= 0) window.jdSound.playSfx('lose', s); + try { window.jdSound.stopBgm(); } catch (e) { /* ignore */ } + } + /* เสียงคลิกปุ่ม (เฉพาะ element ที่เป็นปุ่มจริง) */ + document.addEventListener('click', function (e) { + if (!window.jdSound) return; + var t = (e.target && e.target.closest) ? e.target.closest('button, .btn, [role="button"], [class*="btn-"]') : null; + if (t) { try { window.jdSound.playSfx('button', jdPlayScene()); } catch (err) { /* ignore */ } } + }, true); + function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; } + function isGauntletFaceRightMapMno9kb07() { + if (!isGauntlet()) return false; + /** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */ + return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID; + } + /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */ + function isGauntletCrownHeistMapPlay() { + return isGauntletFaceRightMapMno9kb07(); + } + function isMegaVirusMissionShellMapPlay() { + return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID); + } + /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */ + function usesCrownLobbyShellPlay() { + return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay(); + } + /** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */ + const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49; + /** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */ + const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1; + function isStackTowerEmbedZoomLockedPlay() { + return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay()); + } + function applyPlayEmbedZoomForCurrentMapPlay() { + if (isStackTowerEmbedZoomLockedPlay()) { + playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; + } else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) { + playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL; + } + } + + /** smoothstep 0..1 */ + function stackTowerPost50SmoothstepPlay(t) { + const x = Math.max(0, Math.min(1, t)); + return x * x * (3 - 2 * x); + } + + /** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */ + function getStackTowerPost50AnimEasePlay() { + if (!getStackTowerCameraFollowPlayConfig().enabled) { + stackTowerPost50AnimStartMs = null; + return 0; + } + if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0; + const p = Number(stackMini.progressPct) || 0; + if (p < STACK_TOWER_POST50_PROGRESS_THRESH) { + stackTowerPost50AnimStartMs = null; + return 0; + } + if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now(); + const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS)); + return stackTowerPost50SmoothstepPlay(u); + } + + function getStackTowerPost50ZMulPlay() { + const e = getStackTowerPost50AnimEasePlay(); + if (e <= 0) return 1; + return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e; + } + + function getStackTowerPost50BgScrollExtraPxPlay(ch) { + const e = getStackTowerPost50AnimEasePlay(); + if (e <= 0) return 0; + const chR = Math.max(1, Number(ch) || 720); + return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e; + } + + /** + * Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul). + * Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom. + */ + function computePlayCameraZDrawPlay() { + if (!canvas || !mapData) return zoom; + const w = mapData.width || 20; + const h = mapData.height || 15; + let zDraw = zoom; + if (isSpaceShooter() || isBalloonBoss()) { + const mwPx = w * tileSize; + const mhPx = h * tileSize; + zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; + /* MG7 (balloon_boss) — ขยายให้เต็มจอกว่า fit ทั้งแมป: ซูมพอดีกับพื้นที่ผู้เล่น+บอส (crop ขอบบน-ล่างที่ว่าง) + แมป 20×15 (aspect 1.33) บนจอ 16:9 ถ้า fit ทั้งแมปจะเหลือขอบดำซ้าย-ขวาเยอะ */ + if (isBalloonBoss()) { + const win = getBalloonBossVisibleHalfTilesPlay(mapData); + if (win) { + const needWpx = win.halfW * 2 * tileSize; + const needHpx = win.halfH * 2 * tileSize; + const zContent = Math.min(canvas.width / needWpx, canvas.height / needHpx); + if (Number.isFinite(zContent) && zContent > zDraw) zDraw = zContent; + } + } + } else if (isQuizQuestionMissionHudActivePlay()) { + const qmc = getQuizQuestionMissionMapCenterWorldPxPlay(); + if (qmc) { + /* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */ + zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.90; + } + } else if (isQuizCarry()) { + const mwPx = w * tileSize; + const mhPx = h * tileSize; + zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96; + } else if (isQuizBattle()) { + zDraw *= quizBattleUserZoomMul; + } else if (isGauntletCrownHeistMapPlay() && !(previewMode && editorEmbedReturn)) { + /* #1 map fit: gauntlet เกมจริง — fit "ความสูงแมป (ลู่ทั้งหมด)" ให้พอดีจอ resolution-adaptive + (เดิมใช้ zoom ดิบ ไม่ปรับตาม resolution = ไม่พอดี) · fit สูงอย่างเดียว เพราะ runway เลื่อนแนวนอน */ + const mhPx = h * tileSize; + const fitZ = (canvas.height / mhPx) * 1.0; /* เต็มจอบน-ล่าง (เดิม 0.96 = มีขอบดำบน-ล่าง) */ + if (Number.isFinite(fitZ) && fitZ > 0) zDraw = fitZ; + } + if (isStackTowerEmbedZoomLockedPlay()) { + playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL; + } + if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) { + zDraw *= playEmbedUserZoomMul; + } + if (isStackTowerMissionUiMapPlay()) { + zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL; + zDraw *= getStackTowerPost50ZMulPlay(); + } + return zDraw; + } + let gauntletCrownHowtoVisible = false; + function isGauntletCrownPregameBlockingPlay() { + if (!usesCrownLobbyShellPlay()) return false; + if (gauntletCrownRunHeldRemote) return true; + if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true; + return false; + } + + /** + * Last Light (crown heist): center camera on the whole party bbox instead of only `me` + * — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible. + */ + function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) { + if (!isGauntletCrownHeistMapPlay() || !mapData) return null; + const md = mapData; + const { cw, ch } = getCharacterFootprintWH(md); + const ww = md.width || 20; + const hh = md.height || 15; + const mapWpx = ww * tileSizeRef; + const mapHpx = hh * tileSizeRef; + const halfW = canvasW / (2 * zDrawVal); + const halfH = canvasH / (2 * zDrawVal); + const ents = []; + if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) { + ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef }); + } + others.forEach((o, id) => { + if (!o || o.gauntletEliminated) return; + if (!playBotsEnabled() && isPreviewBotId(id)) return; + if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return; + ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef }); + }); + if (!ents.length) return null; + let minCx = Infinity; + let maxCx = -Infinity; + let sumCy = 0; + for (let i = 0; i < ents.length; i++) { + const e = ents[i]; + minCx = Math.min(minCx, e.cx); + maxCx = Math.max(maxCx, e.cx); + sumCy += e.cy; + } + let px = (minCx + maxCx) * 0.5; + /* แกน Y ตามผู้เล่นเครื่องตัวเอง (ไม่เฉลี่ยทั้งกลุ่ม) → เลนของเราคงที่ใต้กล้อง, host/คนอื่นอยู่เลนของเขาเอง + (เดิมเฉลี่ย Y ทั้งกลุ่ม → ทุกคนถูกเลื่อนจากเลนจริง ทำให้ host ดูมาซ้อนเลนของ client) ; ตายแล้ว → fallback เฉลี่ยกลุ่ม */ + let py = sumCy / ents.length; + if (!me.gauntletEliminated && Number.isFinite(me.y)) { + py = (me.y + ch * 0.5) * tileSizeRef; + } + const minCamX = halfW; + const maxCamX = Math.max(minCamX, mapWpx - halfW); + const minCamY = halfH; + const maxCamY = Math.max(minCamY, mapHpx - halfH); + const viewW = halfW * 2; + const viewH = halfH * 2; + /* + * ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก) + * ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw) + */ + if (viewW >= mapWpx) px = mapWpx * 0.5; + else px = Math.max(minCamX, Math.min(maxCamX, px)); + if (viewH >= mapHpx) py = mapHpx * 0.5; + else py = Math.max(minCamY, Math.min(maxCamY, py)); + return { px, py }; + } + + /** Preview bots only — full tail-off loss needs server support for real humans */ + const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40; + function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) { + if (!playBotsEnabled() || !isMePlayHost() || !isGauntletCrownHeistMapPlay() || !mapData) return; + if (detectiveCaseFillBots) return; /* MG2 เกมจริง: server ตัดสินการตาย/คัดออกของบอทเอง */ + if (gauntletCrownPregamePhase !== 'live') return; + const halfW = canvas.width / (2 * zDrawVal); + const worldMinX = camX - halfW; + const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX; + const { cw } = getCharacterFootprintWH(mapData); + const ts = mapData.tileSize || 32; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return; + const rightWorld = (Number(o.x) + cw) * ts; + if (rightWorld < gate) o.gauntletEliminated = true; + }); + } + + function isStack() { return mapData && mapData.gameType === 'stack'; } + function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; } + /* MG5 server-auth: server เป็นเจ้าของบอท/รอบ → host เลิก sim บอทเอง, ทุกเครื่องรับตำแหน่งบอทจาก server */ + function jumpSurviveServerAuthActive() { + return isJumpSurvive() && (jumpSurviveServerAuthPlay || isDetectiveMinigamePlay()); + } + /* MG6 server-auth: server เป็นเจ้าของบอท/รอบ → host เลิก sim บอท, ทุกเครื่องรับตำแหน่งบอทจาก server */ + function spaceShooterServerAuthActive() { + return isSpaceShooter() && (spaceShooterServerAuthPlay || isDetectiveMinigamePlay()); + } + /* MG7 server-auth: server เป็นเจ้าของบอท/รอบ/HP บอส → host เลิก sim บอท, ทุกเครื่องรับตำแหน่ง/ดาเมจบอทจาก server */ + function balloonBossServerAuthActive() { + return isBalloonBoss() && (balloonBossServerAuthPlay || isDetectiveMinigamePlay()); + } + + function currentPlayMapId() { + const q = (playSessionMapId || '').trim(); + if (q) return q; + if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim(); + return (playMapIdFromQuery || '').trim(); + } + + function isQuizQuestionMissionUiMapPlay() { + return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID; + } + + function isQuizQuestionMissionPregameBlockingPlay() { + if (!isQuizQuestionMissionUiMapPlay()) return false; + return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended'; + } + + function isStackTowerMissionUiMapPlay() { + return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID; + } + + function getStackTowerBlockWorldScalePlay() { + return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1; + } + + function isStackTowerMissionPregameBlockingPlay() { + if (!isStackTowerMissionUiMapPlay()) return false; + return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended'; + } + + function isStackTowerMissionHudActivePlay() { + return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live'; + } + + /** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */ + function isJumpSurviveMissionHudActivePlay() { + return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live'; + } + + /** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */ + function isSpaceShooterMissionHudActivePlay() { + return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live'; + } + + /** Mega Virus mnq1eml7 (MG7) — ช่วงเล่นจริง: ใช้ HUD/score-row แบบเดียวกับ MG6 (qm-mock) + กันคะแนน/ชื่อใช้ layout เก่าที่ text ทับ/ซ่อน */ + function isBalloonBossMissionHudActivePlay() { + return isMegaVirusMissionShellMapPlay() && balloonBossSessionStartMs > 0 && !balloonBossGameEnded; + } + + function stackTowerAssetUrl(file) { + return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, ''); + } + + function ensureStackTowerSlingImagePlay() { + if (!stackTowerSlingImg) { + stackTowerSlingImg = new Image(); + stackTowerSlingImg.decoding = 'async'; + stackTowerSlingImg.src = stackTowerAssetUrl('sling.png'); + } + const im = stackTowerSlingImg; + return im && im.complete && im.naturalWidth > 0 ? im : null; + } + + function ensureStackTowerScorePopupImagesPlay() { + if (!stackTowerScorePlus10Img) { + stackTowerScorePlus10Img = new Image(); + stackTowerScorePlus10Img.decoding = 'async'; + stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png'); + } + if (!stackTowerScorePlus20Img) { + stackTowerScorePlus20Img = new Image(); + stackTowerScorePlus20Img.decoding = 'async'; + stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png'); + } + return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img }; + } + + function ensureStackTowerPerfectFxImagesPlay() { + if (!stackTowerScoreLightImg) { + stackTowerScoreLightImg = new Image(); + stackTowerScoreLightImg.decoding = 'async'; + stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png'); + } + if (!stackTowerScoreX2Img) { + stackTowerScoreX2Img = new Image(); + stackTowerScoreX2Img.decoding = 'async'; + stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png'); + } + return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img }; + } + + /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */ + function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) { + if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return; + const iw = x2Img.naturalWidth; + const ih = x2Img.naturalHeight; + const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72)); + const scale = targetH / ih; + const dw = iw * scale; + const dh = ih * scale; + const cx = sx + drawW + drawW * 0.06; + const cy = sy + drawH * 0.5; + const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1; + const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI); + if (lightImg && lightImg.complete && lightImg.naturalWidth) { + const lp = 2.7; + const lw = dw * lp; + const lh = dh * lp; + const lnx = lightImg.naturalWidth; + const lny = lightImg.naturalHeight; + /* จัดแสงให้ตรงกลางไอคอน ×2 (ไอคอนวาดเยื้องซ้าย cx - dw*0.48 → กลางไอคอน ≈ cx + dw*0.02) */ + const iconCx = cx + dw * 0.02; + ctx.save(); + ctx.globalAlpha = Math.min(0.98, pulse + 0.34); + ctx.globalCompositeOperation = 'screen'; + try { + ctx.drawImage(lightImg, 0, 0, lnx, lny, iconCx - lw * 0.5, cy - lh * 0.5, lw, lh); + } catch (e) { /* ignore */ } + ctx.restore(); + } + ctx.save(); + ctx.globalAlpha = 0.98; + ctx.globalCompositeOperation = 'source-over'; + try { + ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh); + } catch (e) { /* ignore */ } + ctx.restore(); + } + + function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) { + if (!m.scorePopups || !m.scorePopups.length) return; + const now = performance.now(); + const dur = STACK_TOWER_SCORE_POPUP_MS; + const imgs = ensureStackTowerScorePopupImagesPlay(); + m.scorePopups = m.scorePopups.filter((p) => p.until > now); + const hidPop = getStackTowerVisualHiddenLayerCountPlay(); + for (let pi = 0; pi < m.scorePopups.length; pi++) { + const p = m.scorePopups[pi]; + const li = Math.floor(Number(p.layerIndex)) || 0; + if (li < 0 || li >= m.layers.length) continue; + if (li < hidPop) continue; + const L = m.layers[li]; + const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay(); + const cxW = L.cx * tileSize; + const yb = floorY - (li + 1) * layerWorldH; + const xl = cxW - lw / 2; + const [sx, sy] = worldToScreen(xl, yb); + const drawW = lw * zoom; + const drawH = layerWorldH * zoom; + const t0 = p.until - dur; + const t = Math.max(0, Math.min(1, (now - t0) / dur)); + const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t); + const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom; + const im = p.kind === '20' ? imgs.p20 : imgs.p10; + const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom)); + let iw = targetW; + let ih = targetW * 0.38; + if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { + ih = (targetW * im.naturalHeight) / im.naturalWidth; + } + const cx = sx + drawW / 2; + const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5; + ctx.save(); + ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha)); + if (im && im.complete && im.naturalWidth > 0) { + try { + ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih); + } catch (e) { /* ignore */ } + } else { + ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd'; + ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy); + } + ctx.restore(); + } + } + + /** สร้างเศษบล็อกตกจากบล็อกที่พลาด (ใช้ตำแหน่ง/หน้าตาเดิมของ stackFall) */ + function spawnStackMissDebrisPlay(sf) { + if (!sf || !stackMini) return; + const lh = stackMini.layerWorldH || Math.max(14, tileSize * 0.3); + const cxW = sf.xLeft1 + sf.tw / 2; + const landOff = sf.hit && sf.hit.landOffsetTiles ? sf.hit.landOffsetTiles : 0; + const dir = landOff >= 0 ? 1 : -1; + stackMissDebris.push({ + cxW, + topY: sf.y1, + w: sf.tw, + h: lh, + vx: dir * (tileSize * (1.4 + Math.random() * 0.9)), + vy: -tileSize * (0.4 + Math.random() * 0.5), + rot: sf.tiltMax || 0, + vrot: dir * (2.0 + Math.random() * 1.8), + seat: sf.actorSeat, + heavy: !!sf.actorHeavy, + hue: (stackMini.layers.length * 47) % 360, + }); + while (stackMissDebris.length > 6) stackMissDebris.shift(); + _stackDebrisLastT = 0; + } + /** อัปเดต+วาดเศษบล็อกที่ตก (gravity + หมุน + ลอยออกข้าง) จนหลุดขอบจอล่าง */ + function drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorWorldY) { + if (!stackMissDebris.length) return; + const now = performance.now(); + let dt = _stackDebrisLastT ? (now - _stackDebrisLastT) / 1000 : 0.016; + _stackDebrisLastT = now; + if (dt > 0.05) dt = 0.05; + const g = tileSize * 34; + const chCv = canvas && canvas.height ? canvas.height : 720; + const offLimit = floorWorldY + chCv / Math.max(0.2, zoom) + tileSize * 5; + for (let i = stackMissDebris.length - 1; i >= 0; i--) { + const d = stackMissDebris[i]; + d.vy += g * dt; + d.topY += d.vy * dt; + d.cxW += d.vx * dt; + d.rot += d.vrot * dt; + if (d.topY > offLimit) { stackMissDebris.splice(i, 1); continue; } + const sp = worldToScreen(d.cxW - d.w / 2, d.topY); + const drawW = d.w * zoom; + const drawH = d.h * zoom; + ctx.save(); + ctx.translate(sp[0] + drawW / 2, sp[1] + drawH / 2); + ctx.rotate(d.rot); + drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, d.seat, d.heavy, d.hue, { missTint: true, missOverlay: true }); + ctx.restore(); + } + } + + function ensureStackTowerHeartMinusImgPlay() { + if (!stackTowerHeartMinusImg) { + stackTowerHeartMinusImg = new Image(); + stackTowerHeartMinusImg.decoding = 'async'; + stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png'); + } + const im = stackTowerHeartMinusImg; + return im && im.complete && im.naturalWidth > 0 ? im : null; + } + + function ensureStackTowerLifeHudImagesPlay() { + if (!stackTowerLifeBarImg) { + stackTowerLifeBarImg = new Image(); + stackTowerLifeBarImg.decoding = 'async'; + stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png'); + } + if (!stackTowerLifeHitImg) { + stackTowerLifeHitImg = new Image(); + stackTowerLifeHitImg.decoding = 'async'; + stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png'); + } + if (!stackTowerLifeFullImg) { + stackTowerLifeFullImg = new Image(); + stackTowerLifeFullImg.decoding = 'async'; + stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png'); + } + } + + /** Space Shooter (mnpz6rkp): โหลด life HUD จาก /img/ViolentCrime/ แล้วคืน image set ให้ drawStackTowerLifeIntegrityBarPlay */ + function ensureViolentCrimeLifeHudImagesPlay() { + if (!violentCrimeLifeBarImg) { + violentCrimeLifeBarImg = new Image(); + violentCrimeLifeBarImg.decoding = 'async'; + violentCrimeLifeBarImg.src = violentCrimeAssetUrl('life-bar.png'); + } + if (!violentCrimeLifeHitImg) { + violentCrimeLifeHitImg = new Image(); + violentCrimeLifeHitImg.decoding = 'async'; + violentCrimeLifeHitImg.src = violentCrimeAssetUrl('life-hit.png'); + } + if (!violentCrimeLifeFullImg) { + violentCrimeLifeFullImg = new Image(); + violentCrimeLifeFullImg.decoding = 'async'; + violentCrimeLifeFullImg.src = violentCrimeAssetUrl('life-full.png'); + } + return { bar: violentCrimeLifeBarImg, full: violentCrimeLifeFullImg, hit: violentCrimeLifeHitImg }; + } + + /** + * สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG) + */ + const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38; + + /** + * วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง + */ + function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem, opts) { + const imgs = (opts && opts.images) + ? opts.images + : (ensureStackTowerLifeHudImagesPlay(), { bar: stackTowerLifeBarImg, full: stackTowerLifeFullImg, hit: stackTowerLifeHitImg }); + const bar = imgs.bar; + const fullIm = (imgs.full && imgs.full.complete && imgs.full.naturalWidth > 0) + ? imgs.full + : ensureStackTowerHeartMinusImgPlay(); + const hitIm = imgs.hit && imgs.hit.complete && imgs.hit.naturalWidth > 0 + ? imgs.hit + : null; + const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1)); + const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0)); + + if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) { + ctx.save(); + ctx.fillStyle = '#bb9af7'; + ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + let heartStr = ''; + for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ '; + ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5); + ctx.restore(); + return Math.min(maxH, 28); + } + + const bw0 = bar.naturalWidth; + const bh0 = bar.naturalHeight; + const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC)); + const scale = Math.min(maxW / bw0, 1.45); + const bw = bw0 * scale; + const titleDh = titleSrcH * scale; + const gap = 3; + const bx = destX; + const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35); + const heartsTop = by0 + titleDh + gap; + const roomHearts = Math.max(10, destY + maxH - heartsTop - 2); + const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5))); + + ctx.save(); + try { + ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh); + } catch (e) { /* ignore */ } + + const cy = heartsTop + heartsH * 0.5; + const bracketW = Math.min(14, Math.max(9, bw * 0.07)); + ctx.fillStyle = 'rgba(94, 239, 255, 0.88)'; + ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace'; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.shadowColor = 'rgba(0, 234, 255, 0.45)'; + ctx.shadowBlur = 4; + ctx.fillText('[', bx + 1, cy); + ctx.textAlign = 'right'; + ctx.fillText(']', bx + bw - 1, cy); + ctx.shadowBlur = 0; + + const ix = bx + bracketW + 4; + const iwInner = Math.max(24, bw - (bracketW + 4) * 2); + const slotW = iwInner / slots; + const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34); + + for (let i = 0; i < slots; i++) { + const cx = ix + slotW * (i + 0.5); + const useFull = i < lives; + const im = useFull ? fullIm : hitIm; + if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) { + const nw = im.naturalWidth; + const nh = im.naturalHeight; + let ih = iconMax; + let iw = (ih * nw) / nh; + const capW = slotW * 0.82; + const capH = heartsH * 0.88; + if (iw > capW) { + iw = capW; + ih = (iw * nh) / nw; + } + if (ih > capH) { + ih = capH; + iw = (ih * nw) / nh; + } + if (iw < 6 && nh > 0) { + iw = 6; + ih = (iw * nh) / nw; + if (ih > capH) { + ih = capH; + iw = (ih * nw) / nh; + } + } + try { + ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih); + } catch (e2) { /* ignore */ } + } else { + ctx.fillStyle = useFull ? '#9ece6a' : '#565f89'; + ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(useFull ? '♥' : '♡', cx, cy); + } + } + ctx.restore(); + const usedH = heartsTop + heartsH - by0 + 2; + return Math.min(maxH, Math.max(titleDh, usedH)); + } + + function triggerStackTowerHeartMinusFxPlay() { + if (!isStackTowerMissionHudActivePlay()) return; + stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS }; + } + + function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) { + if (!stackTowerHeartMinusFx) return; + const nt = performance.now(); + if (nt >= stackTowerHeartMinusFx.until) { + stackTowerHeartMinusFx = null; + return; + } + const dur = STACK_TOWER_HEART_MINUS_FX_MS; + const t0 = stackTowerHeartMinusFx.until - dur; + const u = Math.max(0, Math.min(1, (nt - t0) / dur)); + const alpha = 1 - u * u; + const worldCx = m.topCenterX * tileSize; + const stackTopY = floorY - nLay * layerWorldH; + const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5; + const [sx, sy] = worldToScreen(worldCx, worldCy); + const im = ensureStackTowerHeartMinusImgPlay(); + const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom)); + let dw = maxW; + let dh = maxW * 0.52; + if (im && im.naturalWidth > 0 && im.naturalHeight > 0) { + dh = (maxW * im.naturalHeight) / im.naturalWidth; + } + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha)); + if (im) { + ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh); + } else { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('♥ −1', sx, sy); + } + ctx.restore(); + } + + /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */ + function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) { + if (drawW <= 0 || drawH <= 0) return; + const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS)); + const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + /* เติมเหลืองทับบล็อกให้เด่น */ + ctx.globalAlpha = Math.min(0.6, 0.3 + pulse * 0.32); + ctx.fillStyle = 'rgba(255, 224, 60, 0.55)'; + ctx.fillRect(sx, sy, drawW, drawH); + /* กรอบเหลืองหนา "รอบ" บล็อก (เลยขอบออกมา) + เรืองแสงออกนอก → เห็นชัด */ + const pad = Math.max(3, Math.min(12, drawW * 0.05)); + const sw = Math.max(3, Math.min(9, drawW * 0.055)); + ctx.shadowColor = 'rgba(255, 196, 24, 0.98)'; + ctx.shadowBlur = Math.max(10, Math.min(30, 14 + pulse * 16)); + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.globalAlpha = Math.min(1, 0.62 + pulse * 0.38); + ctx.strokeStyle = 'rgba(255, 238, 110, 1)'; + ctx.lineWidth = sw; + ctx.strokeRect(sx - pad + sw * 0.5, sy - pad + sw * 0.5, drawW + pad * 2 - sw, drawH + pad * 2 - sw); + /* เส้นในขาวอมเหลือง เพิ่มความคม */ + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.globalAlpha = Math.min(0.95, 0.55 + pulse * 0.4); + ctx.strokeStyle = 'rgba(255, 252, 214, 0.96)'; + ctx.lineWidth = Math.max(1.5, sw * 0.42); + ctx.strokeRect(sx - pad + sw, sy - pad + sw, drawW + pad * 2 - sw * 2, drawH + pad * 2 - sw * 2); + ctx.restore(); + } + + /** + * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว + */ + function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) { + const dx = sx1 - sx0; + const dy = sy1 - sy0; + const len = Math.hypot(dx, dy); + if (len < 1.5) return; + const sling = ensureStackTowerSlingImagePlay(); + if (sling) { + const nw = sling.naturalWidth; + const nh = sling.naturalHeight; + const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2)); + const ang = Math.atan2(dy, dx); + ctx.save(); + ctx.translate(sx0, sy0); + ctx.rotate(ang - Math.PI / 2); + ctx.globalAlpha = 0.96; + try { + ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len); + } catch (e) { /* ignore */ } + ctx.globalAlpha = 1; + ctx.restore(); + } else { + ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)'; + ctx.lineWidth = Math.max(2.2, zoom * 1.4); + ctx.beginPath(); + ctx.moveTo(sx0, sy0); + ctx.lineTo(sx1, sy1); + ctx.stroke(); + ctx.lineWidth = 1; + } + } + + const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower'; + + function setGauntletCrownHowtoStackTowerArtMaskPlay(on) { + const hov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!hov) return; + hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on); + } + + function hideStackTowerHowtoRulesDom() { + /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */ + } + + function teardownStackTowerMissionUiPlay() { + stackTowerMissionPhase = null; + stackTowerMissionDeferredPhase = null; + stackTowerSessionStartAt = 0; + stackTowerMissionEndedOnce = false; + stackTowerPost50AnimStartMs = null; + hideStackTowerHowtoRulesDom(); + setGauntletCrownHowtoStackTowerArtMaskPlay(false); + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + hideStackTowerResultFlashDomOnlyPlay(); + } + + function hideStackTowerResultFlashDomOnlyPlay() { + if (stackTowerResultFlashTimer) { + clearTimeout(stackTowerResultFlashTimer); + stackTowerResultFlashTimer = null; + } + const ov = document.getElementById('stack-tower-result-flash'); + const img = document.getElementById('stack-tower-result-flash-img'); + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + if (img) { + img.onerror = null; + img.removeAttribute('src'); + } + } + + /** กันภาพผลโชว์ "ซ้ำ" — บางเกมจบ 2 path (เช่น MG2 local finish + server gauntlet-ended) ยิง flash 2 รอบผลต่างกัน + → ตัวที่ 2 ถูกบล็อก (เก็บตัวแรกไว้). reset เมื่อเริ่มมินิเกมรอบใหม่ (resetMissionResultFlashGuardPlay) */ + let _missionResultFlashAt = 0; + function missionResultFlashGuardPlay() { + const now = Date.now(); + if (now - _missionResultFlashAt < 9500) return false; /* เพิ่งโชว์ไป → บล็อกตัวซ้ำ */ + _missionResultFlashAt = now; + return true; + } + function resetMissionResultFlashGuardPlay() { _missionResultFlashAt = 0; } + + /** มีใครได้คะแนน ≥1 ไหม (จาก mission.ranked) — ตัดสิน complete/gameover (MG1 quiz / MG4 quiz_carry) */ + function missionAnyScorePlay(mission) { + const r = (mission && Array.isArray(mission.ranked)) ? mission.ranked : []; + return r.some(function (x) { return x && (Number(x.baseScore) || Number(x.finalScore) || Number(x.score) || 0) > 0; }); + } + /** จำนวนผู้รอด (ไม่ eliminated) — ตัดสิน complete/gameover (MG2 เข้าเส้นชัย / MG5 / MG6) */ + function missionSurvivorsPlay(mission) { + if (mission && typeof mission.survivorCount === 'number') return mission.survivorCount; + const r = (mission && Array.isArray(mission.ranked)) ? mission.ranked : []; + return r.filter(function (x) { return x && !x.eliminated; }).length; + } + /** จำนวนผู้เข้าร่วมทั้งหมด (คน+บอท) */ + function missionParticipantsPlay(mission) { + if (mission && typeof mission.participantCount === 'number' && mission.participantCount > 0) return mission.participantCount; + return (mission && Array.isArray(mission.ranked)) ? mission.ranked.length : 0; + } + /** "รอดทั้งหมด" — ทุกคนรอด ไม่มีใคร eliminated (MG5/MG6) */ + function missionAllSurvivedPlay(mission) { + const total = missionParticipantsPlay(mission); + return total > 0 && missionSurvivorsPlay(mission) >= total; + } + /** แฟลชรูปผลรูปเดียว (5s) แล้วเปิด GCM — เกมที่เดิมไม่มีแฟลช (MG1/MG2). asset ตาม skin ผ่าน gcmMissionMockAssetUrl */ + function beginSimpleResultFlashThenGcmPlay(mission, file) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ซ้ำ (MG2: local finish + server gauntlet-ended ยิงทั้งคู่) */ + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!mission || !ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; } + const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); }; + imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); }; + jdEndSfxFromFile(file); + imgEl.src = gcmMissionMockAssetUrl(mission, file); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; finishToGcm(); }, STACK_TOWER_RESULT_FLASH_MS); + } + + /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */ + function beginStackTowerResultFlashThenGcm(mission) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + if (!mission || mission.uiSkin !== 'stack_tower') { + showGauntletCrownMissionOverlay(mission); + return; + } + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!ov || !imgEl) { + showGauntletCrownMissionOverlay(mission); + return; + } + const outc = String(mission.stackTowerOutcome || 'time_up'); + let file = 'result-timeup.png'; + if (outc === 'decrypt_complete') file = 'result-complete.png'; + else if (outc === 'misses_exhausted') file = 'result-gameover.png'; + const url = stackTowerAssetUrl(file); + const finishToGcm = function () { + hideStackTowerResultFlashDomOnlyPlay(); + showGauntletCrownMissionOverlay(mission); + }; + imgEl.onerror = function () { + imgEl.onerror = null; + finishToGcm(); + }; + jdEndSfxFromFile(file); + imgEl.src = url; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { + stackTowerResultFlashTimer = null; + finishToGcm(); + }, STACK_TOWER_RESULT_FLASH_MS); + } + + /** Jumper mnptfts2: มีคนรอด ≥1 = complete · ไม่รอดเลย = gameover (เกรดไม่เกี่ยว — ตามกฎใหม่) */ + function jumpSurviveMissionOutcomeImageFile(mission) { + /* รอดทั้งหมด = complete · มีคนตาย/ตายหมด = gameover (ตามกฎใหม่) */ + return missionAllSurvivedPlay(mission) ? 'result-complete.png' : 'result-gameover.png'; + } + + /** + * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome; + * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM + */ + function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + if (!mission || mission.uiSkin !== 'jumper') { + showGauntletCrownMissionOverlay(mission); + return; + } + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!ov || !imgEl) { + showGauntletCrownMissionOverlay(mission); + return; + } + const outcomeFile = jumpSurviveMissionOutcomeImageFile(mission); + const finishToGcm = function () { + hideStackTowerResultFlashDomOnlyPlay(); + showGauntletCrownMissionOverlay(mission); + }; + const showOneFlash = function (file, onDone) { + imgEl.onerror = function () { + imgEl.onerror = null; + onDone(); + }; + jdEndSfxFromFile(file); + imgEl.src = jumperAssetUrl(file); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { + stackTowerResultFlashTimer = null; + onDone(); + }, STACK_TOWER_RESULT_FLASH_MS); + }; + if (endKind === 'time_up') { + showOneFlash('result-timeup.png', function () { + hideStackTowerResultFlashDomOnlyPlay(); + showOneFlash(outcomeFile, finishToGcm); + }); + } else { + showOneFlash(outcomeFile, finishToGcm); + } + } + + /** + * Space Shooter mnpz6rkp: หมดเวลา = แฟลช result-timeup แล้ว result-gameover แล้ว GCM; + * ทุกคนตาย = แฟลช result-gameover อย่างเดียวแล้ว GCM + */ + function beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, endKind) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + if (!mission || mission.uiSkin !== 'violent_crime') { + showGauntletCrownMissionOverlay(mission); + return; + } + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!ov || !imgEl) { + showGauntletCrownMissionOverlay(mission); + return; + } + const finishToGcm = function () { + hideStackTowerResultFlashDomOnlyPlay(); + showGauntletCrownMissionOverlay(mission); + }; + const showOneFlash = function (file, onDone) { + imgEl.onerror = function () { + imgEl.onerror = null; + onDone(); + }; + jdEndSfxFromFile(file); + imgEl.src = violentCrimeAssetUrl(file); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { + stackTowerResultFlashTimer = null; + onDone(); + }, STACK_TOWER_RESULT_FLASH_MS); + }; + if (endKind === 'time_up') { + showOneFlash('result-timeup.png', function () { + hideStackTowerResultFlashDomOnlyPlay(); + /* หมดเวลา: รอดทั้งหมด = complete · มีคนตาย = gameover (ตามกฎใหม่) */ + showOneFlash(missionAllSurvivedPlay(mission) ? 'result-complete.png' : 'result-gameover.png', finishToGcm); + }); + } else { + showOneFlash('result-gameover.png', finishToGcm); /* all_dead */ + } + } + + /** + * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM + * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow) + * — ชนะ = end-victory-2.png ใน MegaVirus + */ + function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) { + if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */ + if (!mission || mission.uiSkin !== 'mega_virus') { + showGauntletCrownMissionOverlay(mission); + return; + } + hideStackTowerResultFlashDomOnlyPlay(); + const ov = document.getElementById('stack-tower-result-flash'); + const imgEl = document.getElementById('stack-tower-result-flash-img'); + if (!ov || !imgEl) { + showGauntletCrownMissionOverlay(mission); + return; + } + const finishToGcm = function () { + hideStackTowerResultFlashDomOnlyPlay(); + showGauntletCrownMissionOverlay(mission); + }; + const showOneFlash = function (url, onDone) { + imgEl.onerror = function () { imgEl.onerror = null; onDone(); }; + jdEndSfxFromFile(url); + imgEl.src = url; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS); + }; + if (endReason === 'victory') { + /* ฆ่าบอส HP=0 → end-victory-2.png (MegaVirus) */ + showOneFlash(megaVirusAssetUrl('end-victory-2.png'), finishToGcm); + } else if (endReason === 'time') { + /* หมดเวลา บอสยังไม่ตาย → result-timeup → result-gameover (MegaVirus ไม่มี timeup ใช้ TowerBlock) */ + showOneFlash(stackTowerAssetUrl('result-timeup.png'), function () { + hideStackTowerResultFlashDomOnlyPlay(); + showOneFlash(stackTowerAssetUrl('result-gameover.png'), finishToGcm); + }); + } else { + /* ตายหมด → result-gameover */ + showOneFlash(stackTowerAssetUrl('result-gameover.png'), finishToGcm); + } + } + + function stackTowerMissionTimeLimitSecPlay() { + const t = Number(playStackTowerMissionTimeSec); + if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t))); + return 90; + } + + function stackTowerProgressBlocksPlay() { + const n = Number(playStackTowerProgressBlocks); + if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n))); + return 50; + } + + function stackTowerDecryptPctPlay() { + if (!stackMini) return 0; + if (isStackTowerMissionUiMapPlay()) { + const p = Number(stackMini.progressPct); + const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0)); + if (x >= 99.999) return 100; + return Math.floor(x); + } + const teamScore = stackMini.score || 0; + return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3))); + } + + function stackTowerMissionElapsedSecPlay() { + if (stackTowerSessionStartAt <= 0) return 0; + return (performance.now() - stackTowerSessionStartAt) / 1000; + } + + function applyStackTowerMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = stackTowerAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = stackTowerAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + ensureStackTowerSlingImagePlay(); + } + + function showStackTowerMissionHowtoOverlay() { + if (!isStackTowerMissionUiMapPlay()) return; + hideConflictingOverlaysForGauntletCrown(); + applyStackTowerMissionPanelImages(); + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + stackTowerMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + setGauntletCrownHowtoStackTowerArtMaskPlay(true); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = isMePlayHost() || humans.length === 1; + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateStackTowerMissionHowtoHud(); + } + } + + /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */ + function updateStackTowerMissionHowtoHud() { + if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + applyGauntletCrownReadyButtonPlay(st, btn); + } + + /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */ + function updateJumpSurviveMissionHowtoHud() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + applyGauntletCrownReadyButtonPlay(st, btn); + } + + /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */ + function updateSpaceShooterMissionHowtoHud() { + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return; + const st = document.getElementById('gauntlet-crown-howto-status'); + const btn = document.getElementById('btn-gch-ready'); + if (!st || !btn) return; + applyGauntletCrownReadyButtonPlay(st, btn); + } + + function beginStackTowerMissionCountdownThenRun() { + if (!isStackTowerMissionUiMapPlay()) return; + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + stackTowerMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) { + howto.classList.add('is-hidden'); + howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS); + } + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + hideStackTowerHowtoRulesDom(); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (stackTowerMissionCountdownTimer) { + clearTimeout(stackTowerMissionCountdownTimer); + stackTowerMissionCountdownTimer = null; + } + stackTowerMissionPhase = 'live'; + stackTowerMissionEndedOnce = false; + stackTowerSessionStartAt = performance.now(); + lastStackTickMs = performance.now(); + resetStackMinigameState(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { stackTowerMissionCountdownTimer = id; } + ); + } + + function stackTowerMissionBuildPayload(endOpts) { + const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete'; + const rows = []; + const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0); + function pushEnt(id, nickname, characterId, score) { + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: Math.max(0, Number(score) || 0), + eliminated: false, + hadQuizWrong: false, + rankBonus: 0, + finalScore: Math.max(0, Number(score) || 0), + }); + } + if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) { + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0); + } + stackPreviewTurnOrder.forEach(function (entry) { + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + const o = others.get(entry.botId); + pushEnt(entry.botId, o ? o.nickname : entry.botId, o ? o.characterId : null, o ? (o.stackBotScore || 0) : 0); + } else if (entry.kind === 'human' && entry.id != null && String(entry.id) !== String(myId)) { + /* คนจริงคนอื่น — ใส่ในสรุปด้วย ใช้คะแนนเฉพาะตัว (เดิมมีแต่ me+bots) */ + const oh = others.get(entry.id) || others.get(String(entry.id)); + pushEnt(String(entry.id), oh ? (oh.nickname || String(entry.id).slice(0, 6)) : String(entry.id).slice(0, 6), oh ? oh.characterId : null, oh ? (oh.stackPreviewHumanPts || 0) : 0); + } + }); + } else { + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), teamPts); + } + others.forEach(function (o, id) { + if (!o) return; + if (playBotsEnabled() && isPreviewBotId(id)) return; + pushEnt(id, o.nickname, o.characterId, teamPts); + }); + } + const seen = new Set(); + const uniq = []; + rows.forEach(function (r) { + const sid = String(r.id); + if (seen.has(sid)) return; + seen.add(sid); + uniq.push(r); + }); + uniq.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = uniq.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: false, + hadQuizWrong: false, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (totalSum <= 0 && uniq.length) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'stack_tower', + survivorCount: uniq.length, + participantCount: uniq.length, + stackTowerOutcome: outcome, + }; + } + + function stackTowerMissionMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'stack_tower' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: false, + hadQuizWrong: false, + rankBonus: 0, + finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0), + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: Math.max(0, Number(o.stackBotScore) || 0), + eliminated: false, + hadQuizWrong: false, + rankBonus: 0, + finalScore: Math.max(0, Number(o.stackBotScore) || 0), + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = baseRows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: false, + hadQuizWrong: false, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (totalSum <= 0 && baseRows.length) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'stack_tower', + survivorCount: baseRows.length, + participantCount: baseRows.length, + stackTowerOutcome: mission.stackTowerOutcome, + }; + } + + function stackTowerMissionMaybeEndPlay() { + if (!isStackTowerMissionUiMapPlay()) return; + if (stackTowerMissionPhase !== 'live') return; + if (stackTowerMissionEndedOnce) return; + if (stackServerAuthPlay) return; /* server-auth: server ตัดสินจบ + ส่ง stack-over */ + const pct = stackTowerDecryptPctPlay(); + const elapsed = stackTowerMissionElapsedSecPlay(); + const timeLim = stackTowerMissionTimeLimitSecPlay(); + const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0; + const timeOut = elapsed >= timeLim; + if (pct < 100 && !timeOut && !missesOut) return; + let outcome = 'time_up'; + if (pct >= 100) outcome = 'decrypt_complete'; + else if (missesOut) outcome = 'misses_exhausted'; + /* เล่นจริง: host เป็นคนตัดสินจบ + broadcast → ทุกเครื่องจบพร้อมกัน (เดิมต่างคนต่างจับเวลา local → จบไม่พร้อม) */ + if (detectiveCaseFillBots && !isMePlayHost()) return; /* non-host รอ stack-over จาก host */ + if (detectiveCaseFillBots && socket && myId != null) { + try { socket.emit('stack-over', { outcome: outcome }); } catch (e) { /* ignore */ } + } + finishStackTowerMissionLocalPlay(outcome); + } + + /** จบรอบ MG3 จริง (host เรียกเอง / non-host เรียกตอนรับ stack-over) */ + function finishStackTowerMissionLocalPlay(outcome) { + if (stackTowerMissionEndedOnce || !isStackTowerMissionUiMapPlay()) return; + stackTowerMissionEndedOnce = true; + stackTowerMissionPhase = 'ended'; + applyStackTowerMissionPanelImages(); + beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome || 'time_up' })); + } + + function isJumpSurviveMissionUiMapPlay() { + return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID; + } + + function isJumpSurviveMissionPregameBlockingPlay() { + if (!isJumpSurviveMissionUiMapPlay()) return false; + return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended'; + } + + function jumperAssetUrl(file) { + return BASE + '/img/Jumper/' + file; + } + + function hideConflictingOverlaysForJumpSurviveMission() { + hideConflictingOverlaysForGauntletCrown(); + } + + function jumpSurviveTimeLimitSecForMission() { + const m = Number(mapData && mapData.jumpSurviveTimeSec); + if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m); + const j = Number(playJumpSurviveMissionTimeSec); + if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j); + return 60; + } + + function jumpSurviveRemainingSecMission() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; + const lim = jumpSurviveTimeLimitSecForMission(); + if (!(lim > 0)) return null; + /* นับเวลาจาก anchor server (serverNow) → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor */ + const elapsed = jumpSurviveLiveAnchorServerMs > 0 + ? (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000 + : (performance.now() - jumpSurviveSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function applyJumpSurviveJumperPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = jumperAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = jumperAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showJumpSurviveMissionHowtoOverlay() { + if (!isJumpSurviveMissionUiMapPlay()) return; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + jumpSurviveMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = humans.length === 1 || isMePlayHost(); + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateJumpSurviveMissionHowtoHud(); + } + } + + function beginJumpSurviveMissionCountdownThenRun() { + if (!isJumpSurviveMissionUiMapPlay()) return; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'live'; + jumpSurviveGameEnded = false; + jumpSurviveEliminated = false; + /* anchor เวลาเริ่ม live = liveBeginsAt ของ server (ถ้ามี) → scroll/timer ของทุกเครื่องอ้างอิงจุดเดียวกัน */ + jumpSurviveLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); + jumpSurvivePlatformScrollPx = 0; + applyJumpSurvivePreviewSpawnLayout(false); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { jumperMissionCountdownTimer = id; } + ); + } + + /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ + function jumpSurviveGradeFromSurvivorCount(survivors) { + const s = Math.max(0, Math.floor(Number(survivors) || 0)); + if (s >= 6) return 'A'; + if (s === 5) return 'B'; + if (s === 4) return 'C'; + if (s === 3) return 'D'; + if (s === 2) return 'E'; + return 'F'; + } + + function jumpSurviveRollRewardCardForGrade(grade) { + if (grade === 'F') return null; + const r = Math.random(); + if (grade === 'A' || grade === 'B') { + if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C' || grade === 'D') { + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'E') { + if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + + function jumpSurviveMissionTotalParticipants() { + let n = myId != null ? 1 : 0; + if (others && typeof others.size === 'number') n += others.size; + return n; + } + + function jumpSurviveMissionCountAlive() { + let n = 0; + if (myId != null && !jumpSurviveEliminated) n++; + if (others && typeof others.forEach === 'function') { + others.forEach(function (o) { + if (o && !o.jumpSurviveEliminated) n++; + }); + } + return n; + } + + /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */ + function jumpSurviveMissionMaybeEarlyFinish() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; + const alive = jumpSurviveMissionCountAlive(); + if (alive === 0) { + endJumpSurviveMissionRound('all_dead'); + } + } + + function jumpSurviveBuildMissionPayload() { + const rows = []; + const pushEnt = function (id, nickname, characterId, eliminated) { + const alive = !eliminated; + const baseScore = alive ? 100 : 0; + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: baseScore, + eliminated: !!eliminated, + rankBonus: 0, + finalScore: baseScore, + }); + }; + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < rows.length; si++) { + if (rows[si] && !rows[si].eliminated) survivorCount++; + } + const participantCount = rows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = missionEnsureHumanInTopRanked(rows, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const rewardCard = jumpSurviveRollRewardCardForGrade(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function jumpSurviveMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'jumper' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: o.jumpSurviveEliminated ? 0 : 100, + eliminated: !!o.jumpSurviveEliminated, + rankBonus: 0, + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < baseRows.length; si++) { + if (baseRows[si] && !baseRows[si].eliminated) survivorCount++; + } + const participantCount = baseRows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = baseRows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: jumpSurviveRollRewardCardForGrade(grade), + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function endJumpSurviveMissionRound(endKind) { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return; + jumpSurviveGameEnded = true; + jumpSurviveMissionPhase = 'ended'; + applyJumpSurviveJumperPanelImages(); + const mission = jumpSurviveBuildMissionPayload(); + const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up'; + beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind); + } + function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; } + + function isSpaceShooterMissionUiMapPlay() { + return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID; + } + + function isSpaceShooterMissionPregameBlockingPlay() { + if (!isSpaceShooterMissionUiMapPlay()) return false; + return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended'; + } + + function violentCrimeAssetUrl(file) { + return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, ''); + } + + function megaVirusAssetUrl(file) { + return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, ''); + } + + function applyGauntletCrownHeistMissionPanelImages() { + const howtoOv = document.getElementById('gauntlet-crown-howto-overlay'); + if (howtoOv) { + const tfHowto = isGauntletCrownHeistMapPlay(); + howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto); + howtoOv.classList.toggle('gch-mg2-mock', tfHowto); + if (tfHowto) { + ensureQuizTfScaleResizeListener(); + updateGchTfMockScale(howtoOv); + } else { + howtoOv.style.removeProperty('--gch-tf-scale'); + } + } + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = missionMockHudAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg && isGauntletCrownHeistMapPlay()) { + resBg.src = missionMockHudAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function applyMegaVirusMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = megaVirusAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = megaVirusAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function applySpaceShooterMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = violentCrimeAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = violentCrimeAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showSpaceShooterMissionHowtoOverlay() { + if (!isSpaceShooterMissionUiMapPlay()) return; + hideConflictingOverlaysForGauntletCrown(); + applySpaceShooterMissionPanelImages(); + if (spaceShooterMissionCountdownTimer) { + clearTimeout(spaceShooterMissionCountdownTimer); + spaceShooterMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + spaceShooterMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = humans.length === 1 || isMePlayHost(); + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateSpaceShooterMissionHowtoHud(); + } + spaceShooterResetMissionShipStateForAllPlay(); + } + + function beginSpaceShooterMissionCountdownThenRun() { + if (!isSpaceShooterMissionUiMapPlay()) return; + if (spaceShooterMissionCountdownTimer) { + clearTimeout(spaceShooterMissionCountdownTimer); + spaceShooterMissionCountdownTimer = null; + } + spaceShooterMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (spaceShooterMissionCountdownTimer) { + clearTimeout(spaceShooterMissionCountdownTimer); + spaceShooterMissionCountdownTimer = null; + } + spaceShooterMissionPhase = 'live'; + spaceShooterGameEnded = false; + spaceShooterBullets = []; + spaceShooterAsteroids = []; + spaceShooterAsteroidExplosions = []; + spaceShooterPopups = []; + spaceShooterLastTickMs = performance.now(); + spaceShooterSpawnAccMs = 0; + spaceShooterSpawnCount = 0; + /* anchor = liveBeginsAt ของ server (ทุกเครื่องได้ค่าเดียวกัน) → spawn/timer อ้างอิงจุดเดียวกัน */ + spaceShooterLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow(); + spaceShooterFireCd = 0; + spaceShooterSessionStartMs = performance.now(); + spaceShooterLastMoveEmit = 0; + if (myId != null) me.spaceShooterScore = 0; + others.forEach(function (o) { + if (o) o.spaceShooterScore = 0; + }); + spaceShooterResetMissionShipStateForAllPlay(); + if (mapData) applySpaceShooterSpawnLayoutPlay(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { spaceShooterMissionCountdownTimer = id; } + ); + } + + function spaceShooterBuildMissionPayload() { + const rows = []; + function pushEnt(id, nickname, characterId, score, eliminated) { + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: Math.max(0, Number(score) || 0), + eliminated: !!eliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(score) || 0), + }); + } + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = rows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + /* โบนัสอยู่รอดจนจบเกม = +20 (เฉพาะคนไม่ตาย) — รวมใน finalScore เหมือน rankBonus ของ MG2 */ + const surviveBonus = row.eliminated ? 0 : 20; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: surviveBonus, + finalScore: row.baseScore + surviveBonus, + }; + }); + const totalParts = ranked.map(function (r) { return r.finalScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length; + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (survivorCount <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'violent_crime', + survivorCount: survivorCount, + participantCount: rows.length, + }; + } + + function spaceShooterMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0), + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: Math.max(0, Number(o.spaceShooterScore) || 0), + eliminated: !!o.spaceShooterEliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(o.spaceShooterScore) || 0), + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = baseRows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length; + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (survivorCount <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'violent_crime', + survivorCount: survivorCount, + participantCount: baseRows.length, + }; + } + + function balloonBossMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'mega_virus' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0), + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: Math.max(0, Number(o.balloonBossScore) || 0), + eliminated: !!o.balloonBossEliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(o.balloonBossScore) || 0), + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = baseRows.slice(0, 6); + /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */ + const endRBonus = mission && mission.balloonBossEndReason; + const killBonusAll = endRBonus === 'victory' ? 100 : 0; + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + const bonus = killBonusAll + (row.eliminated ? 0 : 10); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: bonus, + finalScore: Math.max(0, bs + bonus), + }; + }); + const totalParts = ranked.map(function (r) { return r.finalScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const maxHp = balloonBossMaxHpPlay(); + let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0); + others.forEach(function (o) { + if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0); + }); + const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp)); + const score100 = Math.min(100, Math.floor(progress * 100)); + let grade = 'C'; + const endR = mission && mission.balloonBossEndReason; + if (endR === 'victory' || progress >= 1) grade = 'A'; + else if (endR === 'all_dead') grade = 'F'; + else if (score100 >= 50) grade = 'B'; + else if (totalSum <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'mega_virus', + survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length, + participantCount: baseRows.length, + balloonBossEndReason: mission.balloonBossEndReason, + }; + } + + function balloonBossBuildMissionPayloadPlay(reason) { + const rows = []; + if (myId != null) { + rows.push({ + id: myId, + nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'), + characterId: me.characterId ?? null, + baseScore: Math.max(0, me.balloonBossScore | 0), + eliminated: !!me.balloonBossEliminated, + }); + } + others.forEach((o, id) => { + rows.push({ + id: id, + nickname: (o && o.nickname) ? String(o.nickname).trim() : id, + characterId: o && o.characterId != null ? o.characterId : null, + baseScore: Math.max(0, o.balloonBossScore | 0), + eliminated: !!(o && o.balloonBossEliminated), + }); + }); + rows.sort((a, b) => b.baseScore - a.baseScore + || String(a.nickname).localeCompare(String(b.nickname), 'th') + || String(a.id).localeCompare(String(b.id))); + const top = rows.slice(0, 6); + /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */ + const killBonusAll = reason === 'victory' ? 100 : 0; + const ranked = top.map((row, idx) => { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + const bonus = killBonusAll + (row.eliminated ? 0 : 10); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: bonus, + finalScore: Math.max(0, bs + bonus), + }; + }); + const totalParts = ranked.map((r) => r.finalScore); + const totalSum = totalParts.reduce((s, n) => s + n, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const maxHp = balloonBossMaxHpPlay(); + let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0); + others.forEach((o) => { + if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0); + }); + const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp)); + const score100 = Math.min(100, Math.floor(progress * 100)); + let grade = 'C'; + if (reason === 'victory' || progress >= 1) grade = 'A'; + else if (reason === 'all_dead') grade = 'F'; + else if (score100 >= 50) grade = 'B'; + else if (totalSum <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'mega_virus', + survivorCount: rows.filter((r) => !r.eliminated).length, + participantCount: rows.length, + balloonBossEndReason: reason, + }; + } + + /* host เป็นผู้ตัดสินจบ MG6 + แนบคะแนน/สถานะสุดท้าย → ทุกเครื่องจบพร้อมกันและอันดับตรงกัน */ + function triggerSpaceShooterOverPlay(kind) { + if (spaceShooterGameEnded) return; + const k = (kind === 'all_dead') ? 'all_dead' : 'time_up'; + try { + if (socket && myId != null && isMePlayHost()) { + const scores = buildStableShipParticipantRefsPlay().map((e) => ({ + id: e.id, + score: Math.max(0, (e.ref && (e.ref.spaceShooterScore | 0)) || 0), + hits: Math.max(0, Number(e.ref && e.ref.spaceShooterHits) || 0), + eliminated: !!(e.ref && e.ref.spaceShooterEliminated), + })); + socket.emit('space-shooter-over', { kind: k, scores: scores }); + } + } catch (e) { /* ignore */ } + endSpaceShooterMissionRound(k); + } + + function applySpaceShooterOverScoresPlay(scores) { + if (!Array.isArray(scores)) return; + scores.forEach((s) => { + if (!s || s.id == null) return; + const sc = Math.max(0, Number(s.score) || 0); + const hits = Math.max(0, Number(s.hits) || 0); + const ent = (myId != null && String(s.id) === String(myId)) ? me : (others.get(String(s.id)) || others.get(s.id)); + if (!ent) return; + ent.spaceShooterScore = sc; + ent.spaceShooterHits = hits; + if (s.eliminated) ent.spaceShooterEliminated = true; + }); + } + + function endSpaceShooterMissionRound(endKind) { + if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return; + const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up'; + spaceShooterGameEnded = true; + spaceShooterMissionPhase = 'ended'; + applySpaceShooterMissionPanelImages(); + spaceShooterBullets = []; + spaceShooterAsteroids = []; + spaceShooterAsteroidExplosions = []; + spaceShooterSpawnAccMs = 0; + spaceShooterPopups = []; + const mission = spaceShooterBuildMissionPayload(); + mission.spaceShooterEndKind = kind; + beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind); + } + + function questionMissionAssetUrl(file) { + return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, ''); + } + + /** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */ + function questionMissionHudAssetUrl(file) { + if (isMissionMockHudPlay()) return missionMockHudAssetUrl(file); + return questionMissionAssetUrl(file); + } + + /** สกิน HUD แบบ mock Minigame 1–7 (fix 1920×1080 + score-bar / profile) */ + function getMissionMockHudSkinPlay() { + if (isQuizQuestionMissionUiMapPlay()) return 'question_mission'; + if (isStackTowerMissionUiMapPlay()) return 'stack_tower'; + if (isJumpSurviveMissionUiMapPlay()) return 'jumper'; + if (isSpaceShooterMissionUiMapPlay()) return 'violent_crime'; + if (isMegaVirusMissionShellMapPlay()) return 'mega_virus'; + if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return 'quiz_carry'; + if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown'; + return ''; + } + + function isMissionMockHudPlay() { + return !!getMissionMockHudSkinPlay(); + } + + function missionMockHudAssetUrl(file) { + const skin = getMissionMockHudSkinPlay(); + const f = String(file || '').replace(/^\//, ''); + if (skin === 'jumper') return jumperAssetUrl(f); + if (skin === 'violent_crime') return violentCrimeAssetUrl(f); + if (skin === 'mega_virus') return megaVirusAssetUrl(f); + if (skin === 'stack_tower') return stackTowerAssetUrl(f); + if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + f; + if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + f; + return questionMissionAssetUrl(f); + } + + function applyMissionMockHudCssVarsPlay(root) { + if (!root) return; + if (!isMissionMockHudPlay()) { + root.style.removeProperty('--mmh-score-bar'); + root.style.removeProperty('--mmh-score-label'); + root.style.removeProperty('--mmh-score-avatar'); + root.style.removeProperty('--mmh-mic-on'); + root.style.removeProperty('--mmh-btn-ready'); + root.style.removeProperty('--mmh-btn-start'); + delete root.dataset.missionMockSkin; + return; + } + const skin = getMissionMockHudSkinPlay(); + root.dataset.missionMockSkin = skin; + if (skin === 'gauntlet_crown') { + root.style.removeProperty('--mmh-score-bar'); + root.style.setProperty('--mmh-score-label', "url('" + missionMockHudAssetUrl('score.png') + "')"); + const crownHead = document.getElementById('play-cyber-crown-score-head'); + const crownImg = document.getElementById('play-cyber-crown-score-img'); + if (crownHead && crownImg) { + const scoreSrc = missionMockHudAssetUrl('score.png'); + crownHead.classList.remove('is-hidden'); + crownHead.setAttribute('aria-hidden', 'false'); + if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc); + crownImg.alt = 'SCORE :'; + crownImg.classList.add('mg2-score-label'); + } + } else { + root.style.removeProperty('--mmh-score-label'); + root.style.setProperty('--mmh-score-bar', "url('" + missionMockHudAssetUrl('score-bar.png') + "?v=3')"); + } + root.style.setProperty('--mmh-score-avatar', "url('" + missionMockHudAssetUrl('score-avartar.png') + "')"); + root.style.setProperty('--mmh-mic-on', "url('" + missionMockHudAssetUrl('btn-mic_on.png') + "')"); + root.style.setProperty('--mmh-btn-ready', "url('" + missionMockHudAssetUrl('btn-ready.png') + "')"); + root.style.setProperty('--mmh-btn-start', "url('" + missionMockHudAssetUrl('btn-start.png') + "')"); + const micImg = document.getElementById('play-cyber-mic-img'); + if (micImg) { + micImg.classList.remove('is-hidden'); + micImg.setAttribute('aria-hidden', 'false'); + const micSrc = missionMockHudAssetUrl('btn-mic_on.png'); + if (micImg.getAttribute('src') !== micSrc) micImg.setAttribute('src', micSrc); + } + const frameOverlay = document.getElementById('play-cyber-profile-frame-overlay'); + if (frameOverlay) { + const frameSrc = missionMockHudAssetUrl('Avartar.png'); + if (frameOverlay.getAttribute('src') !== frameSrc) frameOverlay.setAttribute('src', frameSrc); + } + } + + function isQuizQuestionMissionHudActivePlay() { + return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live'; + } + + /** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */ + function getQuizQuestionMissionMapCenterWorldPxPlay() { + if (!mapData || !isQuiz()) return null; + const w = mapData.width || 20; + const h = mapData.height || 15; + const ts = tileSize; + const mwPx = w * ts; + const mhPx = h * ts; + return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx }; + } + + function applyQuizQuestionMissionPanelImages() { + /* ติดคลาส scope ให้ overlay howto (popup HOW TO PLAY) ใช้สไตล์ mockup เฉพาะ mng8a80o */ + const howtoOv = document.getElementById('gauntlet-crown-howto-overlay'); + if (howtoOv) { + const tfHowto = isQuizQuestionMissionUiMapPlay(); + howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto); + if (tfHowto) { + ensureQuizTfScaleResizeListener(); + updateGchTfMockScale(howtoOv); + } else { + howtoOv.style.removeProperty('--gch-tf-scale'); + } + } + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + /* MG1 ใช้ popup-Howtominigame1.png (รูป SAFE/SCAM) — popup-Howto.png เดิมเป็นดีไซน์ MG4 ใส่ผิดที่ */ + howtoBg.src = questionMissionAssetUrl('popup-Howtominigame1.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = questionMissionAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showQuizQuestionMissionHowtoOverlay() { + if (!isQuizQuestionMissionUiMapPlay()) return; + hideConflictingOverlaysForGauntletCrown(); + applyQuizQuestionMissionPanelImages(); + if (quizQuestionMissionCountdownTimer) { + clearTimeout(quizQuestionMissionCountdownTimer); + quizQuestionMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + quizQuestionMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) st.classList.remove('gch-status--jumper'); + const btn = document.getElementById('btn-gch-ready'); + const humans = quizCarryPregameHumanIds(); + const totPlayers = Math.max(1, quizCarryPregameTotalPlayers()); + /** ผู้เล่นจริงในห้องคนเดียว และไม่มีบอท/peer อื่นในนับรวม — เท่านั้นที่ข้าม lobby */ + const soleParticipant = totPlayers === 1; + if (soleParticipant) { + if (st) { + st.textContent = ''; + st.classList.add('is-hidden'); + } + if (btn) { + const canGo = isMePlayHost() || humans.length === 1; + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } else { + if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request'); + gauntletCrownSyncGuestReadyIfNeeded(); + updateQuizQuestionMissionHowtoHud(); + } + } + + function beginQuizQuestionMissionCountdownThenRun() { + if (!isQuizQuestionMissionUiMapPlay()) return; + if (quizQuestionMissionCountdownTimer) { + clearTimeout(quizQuestionMissionCountdownTimer); + quizQuestionMissionCountdownTimer = null; + } + quizQuestionMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */ + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (quizQuestionMissionCountdownTimer) { + clearTimeout(quizQuestionMissionCountdownTimer); + quizQuestionMissionCountdownTimer = null; + } + quizQuestionMissionPhase = 'live'; + playEmbedUserZoomMul = 1; + const qov = document.getElementById('quiz-game-overlay'); + if (qov) { + if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden'); + else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden'); + else qov.classList.remove('is-hidden'); + } + const dp = quizQuestionMissionDeferredPhase; + quizQuestionMissionDeferredPhase = null; + if (dp) applyQuizPhaseFromServer(dp); + if (previewMode && isQuiz()) loadPreviewQuizAndStart(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + syncGauntletCrownCountdownMg2ModePlay(cd); + runAlignedCountdownPlay( + function (n) { setCountdown321QuestionAssetGraphic(numEl, n); }, + runFinish, + function (id) { quizQuestionMissionCountdownTimer = id; } + ); + } + + function quizQuestionMissionBuildPayload() { + const rows = []; + function pushEnt(id, nickname, characterId, score) { + const sid = String(id); + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: Math.max(0, Number(score) || 0), + eliminated: false, + hadQuizWrong: !!playQuizEverWrong[sid], + rankBonus: 0, + finalScore: Math.max(0, Number(score) || 0), + }); + } + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = rows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: false, + hadQuizWrong: !!row.hadQuizWrong, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + /* เกรด F (= โชว์ Lose stamp) เฉพาะ "ไม่มีใครได้คะแนนเลย" — ดูจาก playLiveQuizScores เต็ม (ครบทุกคน) + ไม่ใช่ totalSum ของ ranked (me+others) ที่อาจหายคนตอบถูกที่ไม่อยู่ใน others → ขัดกับ flash complete */ + const mg1FullScoreSum = Object.keys(playLiveQuizScores).reduce(function (s, k) { return s + Math.max(0, playLiveQuizScores[k] | 0); }, 0); + if (mg1FullScoreSum <= 0 && rows.length) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'question_mission', + survivorCount: rows.length, + participantCount: rows.length, + }; + } + + function quizQuestionMissionMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'question_mission' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: false, + hadQuizWrong: !!r.hadQuizWrong, + rankBonus: 0, + finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0), + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: Math.max(0, Number(playLiveQuizScores[id]) || 0), + eliminated: false, + hadQuizWrong: !!playQuizEverWrong[sid], + rankBonus: 0, + finalScore: Math.max(0, Number(playLiveQuizScores[id]) || 0), + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + const top = baseRows.slice(0, 6); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: false, + hadQuizWrong: !!row.hadQuizWrong, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; + if (totalSum <= 0 && baseRows.length) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'question_mission', + survivorCount: baseRows.length, + participantCount: baseRows.length, + }; + } + + function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } + function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } + + /* MG4 server-authoritative: เกมจริง (detective) เท่านั้น — server เป็นเจ้าของ คำถาม/เฟส/หยิบ-ส่ง/คะแนน/บอท + editor preview (เล่นเดี่ยว) ยังเป็น client-sim เดิม (gate เดียวกับ server: space.detectiveMinigameActive) */ + let quizCarryServerAuthPlay = false; /* ตั้ง true เมื่อรับ quiz-carry-phase (srv) ครั้งแรก — กัน edge ก่อน flag */ + function quizCarryServerAuthActive() { + return isQuizCarry() && (quizCarryServerAuthPlay || isDetectiveMinigamePlay()); + } + + /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */ + function quizCarryEmbedMissionFlowActive() { + if (!isQuizCarry()) return false; + if (previewMode && editorEmbedReturn) return true; + return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay()); + } + /** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */ + function getQuizCarryMapCameraWorldCenterPxPlay() { + if (!mapData || mapData.gameType !== 'quiz_carry') return null; + const ww = mapData.width || 20, hh = mapData.height || 15; + const ts = tileSize; + return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 }; + } + function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; } + + function quizCarryNeonColorForChoice(choiceIndex) { + const i = Math.max(0, choiceIndex | 0); + const h = (i * 47 + 100) % 360; + return 'hsl(' + h + ', 72%, 62%)'; + } + + function quizCarryMinimapOptionFillCss(ov) { + const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1); + if (th.borderMode === 'fixed' && th.fixedBorder) { + const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim()); + if (m) { + const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1; + const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32; + const t = Math.round(aa * 1000) / 1000; + return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')'; + } + } + const i = Math.max(0, ov - 1); + const h = (i * 47 + 100) % 360; + return 'hsla(' + h + ', 58%, 52%, 0.32)'; + } + + function normalizeQuizCarryQuestionFromAny(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)); + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0; + if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ'; + let chSlot = Number(q.countdownHighlightSlot); + if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null; + else chSlot = Math.floor(chSlot); + let choiceImageUrls = null; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx])); + if (urls.some((u) => u)) choiceImageUrls = urls; + } + return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls }; + } + if (!text) return null; + const t = !!q.answerTrue; + return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null }; + } + + function buildQuizCarryPoolFromMap(md) { + if (!md || !Array.isArray(md.quizQuestions)) return []; + const out = []; + for (let i = 0; i < md.quizQuestions.length; i++) { + const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]); + if (n) out.push(n); + } + return out; + } + + function getQuizCarryHubTileBounds(md) { + if (!md || md.gameType !== 'quiz_carry') return null; + const grid = md.quizCarryHubArea; + if (!grid || !grid.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let yy = 0; yy < grid.length; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < row.length; xx++) { + if (row[xx] === 1) { + if (xx < minX) minX = xx; + if (yy < minY) minY = yy; + if (xx > maxX) maxX = xx; + if (yy > maxY) maxY = yy; + } + } + } + if (minX === Infinity) return null; + return { minX, minY, maxX, maxY }; + } + + /** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */ + function getQuizCarryOptionClusterBounds(md, slot1toN) { + const g = md && md.quizCarryOptionArea; + if (!g || !g.length) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let n = 0, sumX = 0, sumY = 0; + for (let yy = 0; yy < g.length; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < row.length; xx++) { + if (Number(row[xx]) === slot1toN) { + if (xx < minX) minX = xx; + if (yy < minY) minY = yy; + if (xx > maxX) maxX = xx; + if (yy > maxY) maxY = yy; + sumX += xx + 0.5; + sumY += yy + 0.5; + n++; + } + } + } + if (!n || minX === Infinity) return null; + return { + minX, minY, maxX, maxY, + cx: sumX / n, + cy: sumY / n, + }; + } + + function canvasWordWrapLines(ctx, text, maxWidth) { + const raw = String(text || '').trim(); + if (!raw) return []; + const words = raw.split(/\s+/); + const lines = []; + let cur = words[0]; + for (let i = 1; i < words.length; i++) { + const w = words[i]; + const test = cur + ' ' + w; + if (ctx.measureText(test).width <= maxWidth) cur = test; + else { + lines.push(cur); + cur = w; + } + } + lines.push(cur); + return lines; + } + + /** ตัดบรรทัดระดับตัวอักษรให้แต่ละบรรทัดพอดีกว้าง (รองรับคำไทยยาวไม่มีช่องว่าง) — คืนทุกบรรทัด */ + function quizCarryWrapLinesFit(ctx, text, maxWidth) { + const raw = String(text || '').trim(); + if (!raw) return []; + const lines = []; + let cur = ''; + for (const chr of raw) { + if (chr === '\n') { lines.push(cur); cur = ''; continue; } + const test = cur + chr; + if (cur === '' || ctx.measureText(test).width <= maxWidth) cur = test; + else { lines.push(cur); cur = chr; } + } + if (cur) lines.push(cur); + return lines; + } + + function quizCarryOptionHeldByAnyone(optionIdx) { + if (optionIdx == null || optionIdx < 0) return false; + if (me.quizCarryHeld === optionIdx) return true; + for (const o of others.values()) { + if (o && o.quizCarryHeld === optionIdx) return true; + } + return false; + } + + /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */ + function spriteOverlapsQuizCarryHubArea(md, sp) { + if (!md || md.gameType !== 'quiz_carry' || !sp) return false; + const hub = md.quizCarryHubArea; + if (!hub || !hub.length) return false; + const mw = md.width || 20, mh = md.height || 15; + const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0; + for (let yy = y0; yy < y0 + hh; yy++) { + if (yy < 0 || yy >= mh) continue; + const row = hub[yy]; + if (!row) continue; + for (let xx = x0; xx < x0 + ww; xx++) { + if (xx < 0 || xx >= mw) continue; + if (row[xx] === 1) return true; + } + } + return false; + } + + /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */ + function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) { + const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; + if (n < 1) return null; + let p = preferredIdx | 0; + if (p < 0 || p >= n) p = 0; + if (!quizCarryOptionHeldByAnyone(p)) return p; + const avail = []; + for (let i = 0; i < n; i++) { + if (!quizCarryOptionHeldByAnyone(i)) avail.push(i); + } + if (!avail.length) return null; + return avail[Math.floor(Math.random() * avail.length)]; + } + + /** + * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl + * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย + * @param plaqueExtra optional { imageUrl } + */ + function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) { + const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex); + const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex); + const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)'; + const textCol = th.textColor || '#f8f9ff'; + const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx))); + const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : ''; + const elImg = plaqueExtra && plaqueExtra.imageElement; + const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null; + const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null; + const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null); + const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2)); + const simplePreviewPlaque = previewMode && editorEmbedReturn; + function pathBoard() { + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr); + else ctx.rect(signX, signY, w, h); + } + if (!simplePreviewPlaque) { + for (let g = 4; g >= 1; g--) { + ctx.strokeStyle = neon; + ctx.globalAlpha = 0.07 + g * 0.06; + ctx.lineWidth = baseLw + g * 3.2; + pathBoard(); + ctx.stroke(); + } + } + ctx.globalAlpha = 1; + ctx.fillStyle = fillCol; + pathBoard(); + ctx.fill(); + if (drawPlaqueImg) { + ctx.save(); + pathBoard(); + ctx.clip(); + const padImg = Math.max(2, padY * 0.65); + const ix = signX + padImg; + const iy = signY + padImg; + const iw = Math.max(0, w - padImg * 2); + const ih = Math.max(0, h - padImg * 2); + if (iw > 0 && ih > 0) { + const nw = drawPlaqueImg.naturalWidth; + const nh = drawPlaqueImg.naturalHeight; + const ir = nw / nh; + let dw; + let dh; + if (iw / ih > ir) { + dh = ih; + dw = dh * ir; + } else { + dw = iw; + dh = dw / ir; + } + const dx = ix + (iw - dw) / 2; + const dy = iy + (ih - dh) / 2; + ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh); + } + ctx.restore(); + } + ctx.strokeStyle = neon; + if (simplePreviewPlaque) { + ctx.lineWidth = Math.max(1, baseLw * 0.85); + ctx.shadowBlur = 0; + pathBoard(); + ctx.stroke(); + } else { + ctx.lineWidth = baseLw; + ctx.shadowColor = neon; + ctx.shadowBlur = 14; + pathBoard(); + ctx.stroke(); + ctx.shadowBlur = 8; + pathBoard(); + ctx.stroke(); + ctx.shadowBlur = 0; + } + const attachX = signX + w / 2; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = textCol; + const lh = lineH || 12; + if (!simplePreviewPlaque && lines && lines.length) { + ctx.shadowColor = 'rgba(0,0,0,0.55)'; + ctx.shadowBlur = 3; + } + if (lines && lines.length) { + const n = lines.length; + const textBlockH = n * lh; + const y0 = signY + Math.max(padY, (h - textBlockH) / 2); + const firstLineCenterY = y0 + lh / 2; + for (let li = 0; li < n; li++) { + ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh); + } + } + ctx.shadowBlur = 0; + if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) { + ctx.strokeStyle = 'rgba(255,255,255,0.95)'; + ctx.lineWidth = 1.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.shadowBlur = 0; + ctx.beginPath(); + ctx.moveTo(tether.x0, tether.y0); + ctx.lineTo(tether.x1, tether.y1); + ctx.stroke(); + } + } + + /** + * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based + */ + function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) { + if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null; + const q = quizCarryCurrent; + const choices = q.choices; + if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null; + const i = idx0Based; + const b = getQuizCarryOptionClusterBounds(mapData, i + 1); + if (!b) return null; + const ts = tileSize * zoom; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + const choiceText = String(choices[i] || '').trim(); + const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i); + const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); + const plaqueExtra = imgUrl ? { imageUrl: imgUrl } + : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); + if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null; + const tileSpanX = (b.maxX - b.minX + 1); + const tileSpanY = (b.maxY - b.minY + 1); + const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); + const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + ctx.save(); + let signX; + let signY; + let w; + let h; + if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { + const minW = minPlaqueW; + const minH = minPlaqueH; + signX = sx - minW / 2; + signY = sy - minH / 2; + w = minW; + h = minH; + ctx.restore(); + return { signX, signY, w, h }; + } + const line = choiceText; + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + let maxW = Math.max(56, tileSpanX * ts - 14); + const lineH = fontPx * 1.2; + let textLines = canvasWordWrapLines(ctx, line, maxW); + if (!textLines.length) textLines = [line]; + if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { + const fp2 = Math.max(9, fontPx * 0.85); + ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + const lh2 = fp2 * 1.2; + maxW = Math.max(48, maxW - 4); + textLines = canvasWordWrapLines(ctx, line, maxW); + if (textLines.length * lh2 > tileSpanY * ts - 8) { + textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); + const padX = 10; + const padY = 8; + w = Math.ceil(maxLineW + padX * 2); + h = Math.ceil(textLines.length * lh2 + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + signX = sx - w / 2; + signY = sy - h / 2; + ctx.restore(); + return { signX, signY, w, h }; + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + let maxLineW = 0; + for (let ti = 0; ti < textLines.length; ti++) { + const lw = ctx.measureText(textLines[ti]).width; + if (lw > maxLineW) maxLineW = lw; + } + const padX = 10; + const padY = 8; + w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); + h = Math.ceil(textLines.length * lineH + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + signX = sx - w / 2; + signY = sy - h / 2; + ctx.restore(); + return { signX, signY, w, h }; + } + + /** + * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor) + * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด + */ + function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) { + if (!isQuizCarry() || !mapData) return; + if (mapData.carryEmbedCountdownHighlight === false) return; + const optIdx = getQuizCarryLocalGrabbableOptionIndex(); + if (optIdx == null) return; + const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx); + if (!pr || pr.w < 4 || pr.h < 4) return; + const rx = pr.signX; + const ry = pr.signY; + const rw = pr.w; + const rh = pr.h; + const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14)); + const colRaw = mapData.carryEmbedCountdownHighlightColor; + const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a'; + const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180); + ctx.save(); + function pathRr() { + ctx.beginPath(); + if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr); + else ctx.rect(rx, ry, rw, rh); + } + pathRr(); + ctx.fillStyle = strokeBase; + ctx.globalAlpha = 0.12 * pulse; + ctx.fill(); + for (let g = 6; g >= 1; g--) { + ctx.strokeStyle = strokeBase; + ctx.globalAlpha = (0.07 + g * 0.055) * pulse; + ctx.lineWidth = 2.5 + g * 4; + pathRr(); + ctx.stroke(); + } + ctx.globalAlpha = 0.98; + ctx.strokeStyle = strokeBase; + ctx.lineWidth = 4; + ctx.shadowColor = strokeBase; + ctx.shadowBlur = 22 * pulse; + pathRr(); + ctx.stroke(); + ctx.shadowBlur = 12 * pulse; + pathRr(); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + ctx.restore(); + } + + function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) { + if (!isQuizCarry() || !mapData || !quizCarryCurrent) return; + if (!quizCarryOptionsPickableNow()) return; + const choices = quizCarryCurrent.choices; + if (!choices || !choices.length) return; + const maxSlots = choices.length; + const ts = tileSize * zoom; + const ps = quizCarryPlaqueMapScaleClampedPlay(); + ctx.save(); + for (let i = 0; i < Math.min(choices.length, maxSlots); i++) { + if (quizCarryOptionHeldByAnyone(i)) continue; + const b = getQuizCarryOptionClusterBounds(mapData, i + 1); + if (!b) continue; + const choiceText = String(choices[i] || '').trim(); + const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i); + const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i); + const plaqueExtra = imgUrl ? { imageUrl: imgUrl } + : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {}); + if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue; + const tileSpanX = (b.maxX - b.minX + 1); + const tileSpanY = (b.maxY - b.minY + 1); + const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps); + const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps); + if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) { + const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + const lineH = fontPx * 1.2; + const padY = 8; + const minW = minPlaqueW; + const minH = minPlaqueH; + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra); + continue; + } + const line = choiceText; + let fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps)); + let maxW = Math.max(96, tileSpanX * ts * 1.5 - 14); + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + /* คำตอบไทยมักเป็น "คำเดียวยาว" (ไม่มีช่องว่าง → wrap ไม่ได้) → ย่อฟอนต์ให้พอดีกว้าง + ปล่อยป้ายกว้างได้ กันโดนตัด "…" */ + while (fontPx > 8 && ctx.measureText(line).width > maxW) { + fontPx -= 1; + ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + } + const lineH = fontPx * 1.2; + let textLines = canvasWordWrapLines(ctx, line, maxW); + if (!textLines.length) textLines = [line]; + if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) { + const fp2 = Math.max(9, fontPx * 0.85); + ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif'; + const lh2 = fp2 * 1.2; + maxW = Math.max(48, maxW - 4); + textLines = canvasWordWrapLines(ctx, line, maxW); + if (textLines.length * lh2 > tileSpanY * ts - 8) { + textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2))); + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40); + const padX = 10; + const padY = 8; + let w = Math.ceil(maxLineW + padX * 2); + let h = Math.ceil(textLines.length * lh2 + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const signX = sx - w / 2; + const signY = sy - h / 2; + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra); + continue; + } + for (let ti = 0; ti < textLines.length; ti++) { + let s = textLines[ti]; + if (ctx.measureText(s).width <= maxW) continue; + while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1); + textLines[ti] = s + '…'; + } + let maxLineW = 0; + for (let ti = 0; ti < textLines.length; ti++) { + const lw = ctx.measureText(textLines[ti]).width; + if (lw > maxLineW) maxLineW = lw; + } + const padX = 10; + const padY = 8; + let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2); + let h = Math.ceil(textLines.length * lineH + padY * 2); + if (imgUrl || plaqueExtra.imageElement) { + w = Math.max(w, minPlaqueW); + h = Math.max(h, minPlaqueH); + } + const wx = b.cx * tileSize; + const wy = b.cy * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const signX = sx - w / 2; + const signY = sy - h / 2; + drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra); + } + ctx.restore(); + } + + function quizCarryHubCellPlay(md, tx, ty) { + const g = md && md.quizCarryHubArea; + return !!(g && g[ty] && g[ty][tx] === 1); + } + + function quizCarryInteractiveCellPlay(md, tx, ty) { + const g = md && md.interactive; + return !!(g && g[ty] && g[ty][tx] === 1); + } + + function quizCarryMapHasAnswerInteractive(md) { + if (!md || !md.interactive || !md.interactive.length) return false; + const h = md.height || 15, w = md.width || 20; + for (let y = 0; y < h; y++) { + const row = md.interactive[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x] === 1) return true; + } + } + return false; + } + + /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */ + function quizCarryOptionIndexAtPlay(md, tx, ty) { + const g = md && md.quizCarryOptionArea; + if (!g || !g[ty]) return null; + const v = g[ty][tx]; + if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null; + const idx = v - 1; + const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0; + if (n < 1 || idx >= n) return null; + return idx; + } + + /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */ + function quizCarryFootprintOverlapsHub(px, py) { + if (!mapData || !isQuizCarry()) return false; + const md = mapData; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryHubCellPlay(md, tx, ty)) return true; + } + return false; + } + + /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */ + function quizCarryFootprintAdjacentToHub(md, px, py) { + if (!md) return false; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryHubCellPlay(md, tx, ty)) continue; + for (let di = 0; di < dirs.length; di++) { + const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; + if (quizCarryHubCellPlay(md, nx, ny)) return true; + } + } + return false; + } + + function quizCarryCanSubmitAtHub(md, px, py) { + if (!md) return false; + return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py); + } + + function quizCarryFootprintOverlapsInteractive(md, px, py) { + if (!md) return false; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryInteractiveCellPlay(md, tx, ty)) return true; + } + return false; + } + + function quizCarryFootprintAdjacentToInteractive(md, px, py) { + if (!md) return false; + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const k of quizTilesFootprintPlay(px, py)) { + const p = k.split(','); + const tx = +p[0], ty = +p[1]; + if (quizCarryInteractiveCellPlay(md, tx, ty)) continue; + for (let di = 0; di < dirs.length; di++) { + const nx = tx + dirs[di][0], ny = ty + dirs[di][1]; + if (quizCarryInteractiveCellPlay(md, nx, ny)) return true; + } + } + return false; + } + + function quizCarryCanSubmitAtInteractiveZone(md, px, py) { + if (!md) return false; + return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py); + } + + /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */ + function quizCarryCanSubmitAnswerAt(md, px, py) { + if (!md) return false; + if (quizCarryMapHasAnswerInteractive(md)) { + return quizCarryCanSubmitAtInteractiveZone(md, px, py); + } + return quizCarryCanSubmitAtHub(md, px, py); + } + + /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */ + function formatQuizCarryQuestionHud(q) { + if (!q) return ''; + return String(q.text || q.question || q.prompt || q.title || '').trim(); + } + + function quizCarryRestoreEmbedPhaseLabel() { + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (!phaseEl || !mapData) return; + phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData) + ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้' + : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้'; + } + + function isQuizCarryEmbedCountdownBlockingMovement() { + if (!quizCarryEmbedMissionFlowActive()) return false; + const now = Date.now(); + return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now); + } + + function isQuizCarryEmbedLobbyBlockingMovement() { + return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive); + } + + /* ผู้ถูกแบนรอบนี้ (Card 3 Ban) เข้ามาดูเฉยๆ ไม่ต้อง Ready → ไม่นับใน gate เริ่มเกม + (ไม่งั้น host จะค้าง N/N+1 เพราะ banned ไม่เคย ready) */ + function isQuizCarryPregameBannedId(id) { + const o = others.get(id) || others.get(String(id)); + return !!(o && o.bannedSpectator); + } + + function quizCarryPregameHumanIds() { + const set = new Set(); + if (myId != null && !isPreviewBotId(myId) && !playBannedSpectator) set.add(String(myId)); + /* นับจาก readyMap ของ server (authoritative + ตัด banned แล้ว) + ตัวเราเท่านั้น + → host/client นับ "จำนวนผู้เล่น" ตรงกันเสมอ (เดิมวน others ซึ่ง membership ต่างกันชั่วคราว → ready ไม่ตรง) + การรอให้ client เข้าครบใช้ expectedActive (gauntletCrownPregameExpectedHumans) แทน */ + [quizCarryLobbyReadyMap, gauntletCrownLobbyReadyMap].forEach((rm) => { + if (rm && typeof rm === 'object') { + Object.keys(rm).forEach((id) => { + if (id && !isPreviewBotId(id) && !isQuizCarryPregameBannedId(id)) set.add(String(id)); + }); + } + }); + return [...set]; + } + + function quizCarryPregameBotCount() { + let n = 0; + others.forEach((_, id) => { + if (isPreviewBotId(id)) n++; + }); + return n; + } + + function quizCarryPregameReadyNumerator() { + let n = quizCarryPregameBotCount(); + quizCarryPregameHumanIds().forEach((id) => { + if (quizCarryLobbyReadyMap[id]) n++; + }); + return n; + } + + function quizCarryPregameTotalPlayers() { + return quizCarryPregameHumanIds().length + quizCarryPregameBotCount(); + } + + function isMePlayHost() { + if (myId == null) return false; + if (playHostId == null) return true; /* fallback (load-bearing): host พึ่งตอน null — อย่าเปลี่ยน (เคยทำ MG3 ready ค้าง 4/5 + MG2 spawn ทับ) */ + return String(playHostId) === String(myId); + } + + /* บอทเติมช่อง (fill-bot) ใน 7 มินิเกมต้องเดิน/เล่น/คะแนนตรงกันทุก client: + host เป็นผู้จำลอง bot ทั้งหมด (AI/ฟิสิกส์/คะแนน) แล้ว broadcast state — client อื่นปิด local sim แล้ว render ตาม + (เดิม: ทุกเครื่องสุ่ม Math.random() แยกกัน → บอทตัวเดียวกันอยู่คนละที่/คนละคะแนนบนแต่ละจอ) */ + function fbNum(v) { return (typeof v === 'number' && Number.isFinite(v)) ? Math.round(v * 100) / 100 : undefined; } + function broadcastFillBotState() { + if (!playBotsEnabled() || !isMePlayHost() || !mapData) return; + if (isQuiz() && detectiveCaseFillBots) return; /* quiz เกมจริง: server เป็นเจ้าของบอท (quiz-bot-move) → ห้าม host ส่งทับ = บอทวาป/ผีหลุด area */ + if (isGauntlet() && detectiveCaseFillBots) return; /* MG2 เกมจริง: server เป็นเจ้าของบอท (gauntlet-sync) → ห้าม host ส่งทับ */ + if (quizCarryServerAuthActive()) return; /* MG4 เกมจริง: server เป็นเจ้าของบอท (quiz-carry-bot-move) → ห้าม host ส่งทับ */ + if (jumpSurviveServerAuthActive()) return; /* MG5 เกมจริง: server เป็นเจ้าของบอท (jump-survive-bot-move) → ห้าม host ส่งทับ */ + if (spaceShooterServerAuthActive()) return; /* MG6 เกมจริง: server เป็นเจ้าของบอท (space-shooter-bot-move) → ห้าม host ส่งทับ */ + if (balloonBossServerAuthActive()) return; /* MG7 เกมจริง: server เป็นเจ้าของบอท (balloon-boss-bot-move) → ห้าม host ส่งทับ */ + const arr = []; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o) return; + const row = { id: id, x: fbNum(o.x), y: fbNum(o.y), direction: o.direction || 'down', walking: !!o.botIsWalking }; + if (o.characterId != null) row.cid = o.characterId; /* ให้ client สร้างบอทที่ขาดได้ → ชุดบอท host-authoritative */ + if (o.botTier != null) row.tier = o.botTier; /* tier (ฉลาด/กลาง/พลาดบ่อย) — host เป็นเจ้าของ ให้ label/พฤติกรรมตรงกันทุกเครื่อง (เดิมสุ่ม local → ป้ายไม่ตรง) */ + if (o.gauntletScore != null) row.gScore = o.gauntletScore; + if (o.gauntletEliminated != null) row.gElim = !!o.gauntletEliminated; + if (o.gauntletJumpTicks != null) row.gJump = o.gauntletJumpTicks; + if (o.gauntletJumpVis != null) row.gJumpVis = fbNum(o.gauntletJumpVis); + if (o.jumpSurviveEliminated != null) row.jsElim = !!o.jumpSurviveEliminated; + if (o.spaceShooterCx != null) row.ssCx = fbNum(o.spaceShooterCx); + if (o.spaceShooterCy != null) row.ssCy = fbNum(o.spaceShooterCy); + if (o.spaceShooterScore != null) row.ssScore = o.spaceShooterScore; + if (o.spaceShooterEliminated != null) row.ssElim = !!o.spaceShooterEliminated; + if (o.balloonBossCx != null) row.bbCx = fbNum(o.balloonBossCx); + if (o.balloonBossCy != null) row.bbCy = fbNum(o.balloonBossCy); + if (o.balloonBossScore != null) row.bbScore = o.balloonBossScore; + if (o.balloonBossBossDmg != null) row.bbDmg = o.balloonBossBossDmg; + if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons; + if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated; + if (o.quizCarryHeld !== undefined && !(isQuizCarry() && quizCarryServerAuthActive())) row.qcHeld = o.quizCarryHeld; + /* MG1 quiz: สถานะล็อก (ตอบผิด=ผี) + คะแนนบอท → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน + (เดิมไม่ส่งเลย → non-host เห็นบอทล็อก/respawn/คะแนนคนละแบบ) */ + if (isQuiz()) { + if (o.quizCannotTrue != null) row.qct = !!o.quizCannotTrue; + if (o.quizCannotFalse != null) row.qcf = !!o.quizCannotFalse; + } + /* คะแนนบอท MG1 (quiz) + MG4 (quiz_carry) → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน + (เดิม MG4 ไม่ส่ง → non-host sim บอทเอง ด้วย RNG ต่างกัน = คะแนนบอทไม่ตรง 2 จอ) */ + if (isQuiz() || isQuizCarry()) { + const qsc = playLiveQuizScores[id]; + if (qsc != null) row.qsc = Math.max(0, qsc | 0); + } + /* สีบอท (host เป็นเจ้าของ) — แนบไปด้วยเพื่อให้ทุก client แสดงสีตรงกัน + (เดิมแต่ละเครื่องคำนวณสีเองจาก theme/sessionStorage → สีบอทไม่ตรงกันเมื่อ sync ธีมมาช้า) */ + if (o.playTint && o.playTint.body) { + row.tint = { head: o.playTint.head, hair: o.playTint.hair, body: o.playTint.body }; + } + arr.push(row); + }); + /* รายชื่อ "คนจริง" ที่ host เห็น (= myId(host) + others ที่ไม่ใช่บอท) — ให้ non-host force ลบผีที่ host ไม่มี + (humans ไม่ถูก sync แบบบอท → ผี reconnect ค้างบน client; id-based ไม่ชนชื่อ default) */ + const humanRoster = []; + if (myId != null) humanRoster.push(String(myId)); + others.forEach((o, id) => { if (!isPreviewBotId(id) && o) humanRoster.push(String(id)); }); + if (arr.length) socket.emit('fill-bot-state', { bots: arr, humans: humanRoster }); + } + setInterval(broadcastFillBotState, 80); /* ~12.5Hz — self-gate ถ้าไม่ใช่ host หรือไม่มีบอท */ + + /* ===== Server-time sync + เวลาเริ่มเล่นจริงร่วมกัน (liveBeginsAt) ===== + แก้ปัญหา "เวลาในเกมไม่เท่ากัน เริ่ม/จบไม่พร้อมกัน": เดิมแต่ละเครื่องนับ 3-2-1 เองด้วย setTimeout + และจับเวลารอบด้วย performance.now() ของตัวเอง → GO และจุดจบไม่ตรงกัน + แก้: sync นาฬิกากับ server (serverNow) แล้วทุกคนนับไปหา "liveBeginsAt" (server epoch) จุดเดียวกัน + เมื่อ GO พร้อมกัน → นาฬิการอบ (performance.now ตั้งแต่ GO) ก็ sync กันเองอัตโนมัติ */ + let playClockOffset = 0; /* serverTime - clientTime (ms) */ + let playClockBestRtt = null; + let playClockSyncInterval = null; + let playClockEstablished = false; /* sync ครั้งแรกแล้วหรือยัง — ครั้งแรก hard-set, re-sync ถัดไป blend (กันกระโดด) */ + function serverNow() { return Date.now() + playClockOffset; } + function syncPlayClock(round) { + round = round || 0; + if (!socket || !socket.connected) return; + /* เริ่ม burst ใหม่ทุกครั้ง → รีเซ็ต best RTT เพื่อ "re-establish" offset ใหม่ทั้งหมด + ไม่งั้น best RTT ที่ค้างไว้จะทำให้ re-sync ครั้งหลังถูกข้าม แก้ clock drift ไม่ได้ + (เวลาในเกม MG6/MG7 จะเพี้ยนสะสมเพราะ serverNow ของแต่ละเครื่องไม่ตรงกัน) */ + if (round === 0) playClockBestRtt = null; + const t0 = Date.now(); + socket.emit('time-sync', (res) => { + const t1 = Date.now(); + if (res && Number.isFinite(res.t)) { + const rtt = t1 - t0; + if (playClockBestRtt == null || rtt <= playClockBestRtt) { + playClockBestRtt = rtt; + const newOffset = res.t + rtt / 2 - t1; /* ชดเชยครึ่งหนึ่งของ round-trip latency */ + if (!playClockEstablished) { + playClockOffset = newOffset; /* ครั้งแรก: ตั้งตรง ๆ ให้ baseline แม่น */ + playClockEstablished = true; + } else { + /* re-sync ถัดไป: เกลี่ยเข้าค่าใหม่แบบนุ่ม → serverNow ไม่กระโดดทุก 12 วิ (= ไม่ทำเฟส MG3/timer สะดุด). + ก้าวใหญ่ >250ms = สแน็ป (drift จริง/เปลี่ยน server) กัน lag ค้าง; ก้าวเล็ก = blend 25% */ + const dOff = newOffset - playClockOffset; + playClockOffset += (Math.abs(dOff) > 250) ? dOff : dOff * 0.25; + } + } + } + if (round < 5) setTimeout(function () { syncPlayClock(round + 1); }, 300); + }); + } + /* re-sync นาฬิกาเป็นระยะ → กัน clock drift ระหว่างเล่นยาว ๆ ทำให้ serverNow ทุกเครื่องตรงกันเสมอ + (ฐานเวลาเดียวกัน → timer/boss/aim/spawn ของทุก minigame sync กัน) */ + function startPeriodicClockSync() { + if (playClockSyncInterval) return; + playClockSyncInterval = setInterval(function () { + try { if (socket && socket.connected) syncPlayClock(0); } catch (_e) { /* ignore */ } + }, 12000); + } + + /* ─────────────────────────────────────────────────────────────────────── + ตัวบอกสถานะเน็ต (เพิ่มล้วน — ไม่แตะ game logic): ป้าย "เน็ตหน่วง"/"กำลังเชื่อมต่อใหม่" + แสดงมุมขวาบน. วัด RTT เองด้วย time-sync (server มี handler อยู่แล้ว ไม่ต้องแก้ server) + ───────────────────────────────────────────────────────────────────────── */ + const NET_RTT_WARN_MS = 320; /* เกินนี้ = เน็ตหน่วง (เหลือง) */ + const NET_RTT_BAD_MS = 700; /* เกินนี้ = แย่มาก (แดง) */ + let netStatusEl = null; + let netLastRtt = null; + let netReconnecting = false; + let netPingTimer = null; + let netOkHideTimer = null; + function ensureNetStatusEl() { + if (netStatusEl) return netStatusEl; + const css = document.createElement('style'); + css.textContent = '' + + '#play-net-status{position:fixed;top:10px;right:12px;z-index:9400;display:none;align-items:center;gap:7px;' + + 'padding:7px 13px;border-radius:11px;font-family:inherit;font-weight:700;font-size:14px;line-height:1;' + + 'color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;backdrop-filter:blur(2px);' + + 'transition:opacity .25s;letter-spacing:.2px;}' + + '#play-net-status.is-warn{background:linear-gradient(135deg,#c9831a,#e0a52b);}' + + '#play-net-status.is-bad{background:linear-gradient(135deg,#b3231d,#d84038);}' + + '#play-net-status.is-ok{background:linear-gradient(135deg,#1b9e4b,#13c06a);}' + + '#play-net-status .pns-dot{width:9px;height:9px;border-radius:50%;background:#fff;opacity:.92;}' + + '#play-net-status.is-bad .pns-spin{width:13px;height:13px;border:2px solid rgba(255,255,255,.4);' + + 'border-top-color:#fff;border-radius:50%;animation:pnsSpin .8s linear infinite;}' + + '@keyframes pnsSpin{to{transform:rotate(360deg);}}'; + document.head.appendChild(css); + const el = document.createElement('div'); + el.id = 'play-net-status'; + el.innerHTML = ''; + document.body.appendChild(el); + netStatusEl = el; + return el; + } + function setNetStatus(kind, text, withSpin) { + const el = ensureNetStatusEl(); + if (netOkHideTimer) { clearTimeout(netOkHideTimer); netOkHideTimer = null; } + if (kind === 'hidden') { el.style.display = 'none'; return; } + el.className = 'is-' + kind; + const icon = el.querySelector('.pns-icon'); + if (icon) icon.innerHTML = withSpin ? '' : ''; + const t = el.querySelector('.pns-text'); + if (t) t.textContent = text; + el.style.display = 'flex'; + el.style.opacity = '1'; + if (kind === 'ok') { + netOkHideTimer = setTimeout(function () { el.style.opacity = '0'; setTimeout(function () { if (netStatusEl) netStatusEl.style.display = 'none'; }, 280); }, 1600); + } + } + function refreshNetStatus() { + if (netReconnecting || (socket && socket.connected === false)) { + setNetStatus('bad', 'กำลังเชื่อมต่อใหม่…', true); + return; + } + if (netLastRtt != null && netLastRtt >= NET_RTT_BAD_MS) { + setNetStatus('bad', 'เน็ตหน่วงมาก (' + Math.round(netLastRtt) + ' ms)', false); + } else if (netLastRtt != null && netLastRtt >= NET_RTT_WARN_MS) { + setNetStatus('warn', 'เน็ตหน่วง (' + Math.round(netLastRtt) + ' ms)', false); + } else { + setNetStatus('hidden'); + } + } + function netPingOnce() { + if (!socket || !socket.connected) return; + const t0 = Date.now(); + let done = false; + try { + socket.emit('time-sync', function () { + if (done) return; done = true; + netLastRtt = Date.now() - t0; + refreshNetStatus(); + }); + } catch (_e) { /* ignore */ } + setTimeout(function () { if (!done) { done = true; netLastRtt = Math.max(netLastRtt || 0, NET_RTT_BAD_MS); refreshNetStatus(); } }, 2500); + } + function setupNetStatusIndicator() { + if (netPingTimer) return; + try { + socket.on('disconnect', function () { netReconnecting = true; refreshNetStatus(); }); + socket.on('connect', function () { + if (netReconnecting) { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); } + else { netReconnecting = false; } + netPingOnce(); + }); + if (socket.io && socket.io.on) { + socket.io.on('reconnect_attempt', function () { netReconnecting = true; refreshNetStatus(); }); + socket.io.on('reconnect', function () { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); }); + } + } catch (_e) { /* ignore */ } + netPingTimer = setInterval(netPingOnce, 4000); + netPingOnce(); + } + + /* #20 แสดงเลข version มุมขวาล่าง — อ่านจาก ?v= ของ - + +
v —
+ + + + +
กำลังโหลดตัวละคร…
+ + + + + +
+
+ + อนุญาตให้เข้าถึงไมโครโฟน เพื่อให้คุณสามารถสื่อสารและบันทึกเสียงได้อย่างคมชัด โปรดอนุญาตให้แอปเข้าถึงไมโครโฟนของอุปกรณ์ + +
+
+ + + +
+ + +
+

ช่องเขียว · กด F / แป้นไทย โต้ตอบ

+
+

PLAYERS : 0/10

+ +
+ +
+
+
+ ตัวละครของคุณ +
+ +
+
+
+ + +
+
+ Room ID : + + +
+
+ + +
+
+
+
+
+ + + + +
+
+ + + + + +
+ +
+
+ + +
+ + + + + + + + + + + +
v —
+ + diff --git a/www/html/Game/public/room-lobby.html.bak-storygate b/www/html/Game/public/room-lobby.html.bak-storygate new file mode 100644 index 0000000..066de10 --- /dev/null +++ b/www/html/Game/public/room-lobby.html.bak-storygate @@ -0,0 +1,1654 @@ + + + + + + โถงรอ — Game + + + + + + + + + + + + + + +
กำลังโหลดตัวละคร…
+ + + + + +
+
+ + อนุญาตให้เข้าถึงไมโครโฟน เพื่อให้คุณสามารถสื่อสารและบันทึกเสียงได้อย่างคมชัด โปรดอนุญาตให้แอปเข้าถึงไมโครโฟนของอุปกรณ์ + +
+
+ + + +
+ + +
+

ช่องเขียว · กด F / แป้นไทย โต้ตอบ

+
+

PLAYERS : 0/10

+ +
+ +
+
+
+ ตัวละครของคุณ +
+ +
+
+
+ + +
+
+ Room ID : + + +
+
+ + +
+
+
+
+
+ + + + +
+
+ + + + + +
+ +
+
+ + +
+ + + + + + + + + + + +
v —
+ + diff --git a/www/html/Game/server.js b/www/html/Game/server.js index ad47bae..72860ff 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -352,6 +352,25 @@ 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'); @@ -3601,6 +3620,30 @@ function emitDetectiveLobbyBPresence(sid, space) { }); } +/* TC156 story-gate: นับผู้เล่นในคดี "ที่อ่าน story (cutscene) จบแล้ว" เทียบกับคนที่อยู่ในห้องตอนนี้ + allRead = ทุก roster-nick ที่ยัง present ได้ส่ง story-read-done แล้ว (คนที่ left ไม่นับ — presence gate คุมแยก) */ +function detectiveStoryReadStatus(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { present: 0, total: 0, allRead: true }; + const read = space.storyReadNicks || new Set(); + const presentRosterNicks = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) presentRosterNicks.add(n); + }); + let readCount = 0; + presentRosterNicks.forEach((n) => { if (read.has(n)) readCount++; }); + const allRead = !!space.storyReadComplete || (presentRosterNicks.size > 0 && readCount >= presentRosterNicks.size); + return { present: readCount, total: presentRosterNicks.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 = เจาะจง, ช่องว่าง = สุ่ม */ @@ -5340,11 +5383,11 @@ function stepQuizCarryBots(sid, space, dt, now) { const answering = s.phase === 'answer'; const choiceCount = s.current ? (s.current.choices || []).length : 0; - /* ก้าวไป (x,y) ได้ไหม: เดินได้ (ไม่ใช่กำแพง objects) + ไม่ทับ hub + ไม่ทับโต๊ะ submit กลาง (interactive) - → บอทไม่เดินทะลุ "โต๊ะตรงกลาง" อีก (เดิมเช็คแค่ hub+objects ทำให้ทะลุโต๊ะ interactive ที่ไม่ได้อยู่ใน objects) */ - const canStepTo = (x, y) => quizTileWalkableServer(md, x, y) - && !quizCarryFootprintOverlapsHubServer(md, x, y) - && !quizCarryFootprintOverlapsInteractiveServer(md, x, y); + /* ก้าวไป (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; @@ -5390,10 +5433,16 @@ function stepQuizCarryBots(sid, space, dt, now) { }; s.bots.forEach((b, bid) => { - /* ทับ hub / โต๊ะ submit กลาง (interactive) / หรือ "ติดในคอนโซลกลาง" (ยืนได้แต่เดินออกไม่ได้) → ดันออกไปช่องว่างที่ถึงได้ */ + /* ทับ 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 t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = []; } + 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); @@ -7201,6 +7250,35 @@ const server = http.createServer((req, res) => { }); 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); @@ -11291,6 +11369,7 @@ io.on('connection', (socket) => { (เดิม: หลังจบเกม 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, @@ -11299,6 +11378,15 @@ io.on('connection', (socket) => { 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); } @@ -11323,6 +11411,7 @@ io.on('connection', (socket) => { 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; @@ -11335,6 +11424,7 @@ io.on('connection', (socket) => { 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); }); @@ -11440,6 +11530,12 @@ io.on('connection', (socket) => { 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 ปากคำ) ก่อนเข้าโหวต @@ -11640,6 +11736,13 @@ io.on('connection', (socket) => { 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 }); @@ -11648,10 +11751,46 @@ io.on('connection', (socket) => { } }); + /* 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 nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : ''; + if (!nick) break; + if (!space.storyReadNicks) space.storyReadNicks = new Set(); + const already = space.storyReadNicks.has(nick); + space.storyReadNicks.add(nick); + 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); + 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 = ''; @@ -11698,6 +11837,19 @@ io.on('connection', (socket) => { caseNickWl.add(normalizeLobbyNickname(p.nickname)); }); space.caseParticipantNicknames = caseNickWl; + /* TC156: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story (cutscene) ครบ — reset ที่ initial LobbyA→LobbyB เท่านั้น + (รอบ investigate ถัดไปไม่มี cutscene → storyReadComplete คงเป็น true) · safety timer กัน deadlock ถ้า client ไม่ emit */ + space.storyReadNicks = new Set(); + space.storyReadComplete = false; + 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; @@ -12078,6 +12230,8 @@ io.on('connection', (socket) => { 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) */ diff --git a/www/html/Game/server.js.bak-flowgate b/www/html/Game/server.js.bak-flowgate new file mode 100644 index 0000000..de5c894 --- /dev/null +++ b/www/html/Game/server.js.bak-flowgate @@ -0,0 +1,12778 @@ +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'); + +/* ===== Quiz Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ +const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json'); +/** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */ +let qbLeaderboardStore = {}; +function loadQbLeaderboard() { + try { + if (fs.existsSync(QB_LEADERBOARD_PATH)) { + const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8')); + qbLeaderboardStore = (j && typeof j === 'object') ? j : {}; + } + } catch (e) { qbLeaderboardStore = {}; } +} +function saveQbLeaderboard() { + try { + const dir = path.dirname(QB_LEADERBOARD_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8'); + } catch (e) { /* ignore */ } +} +function qbLeaderboardSubmit(roomId, name, score, characterId) { + roomId = String(roomId || '').trim(); + name = String(name || '').trim().slice(0, 24); + score = Math.max(0, Math.floor(Number(score) || 0)); + if (!roomId || !name) return; + if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = []; + const list = qbLeaderboardStore[roomId]; + const ex = list.find((e) => e && e.name === name); + if (ex) { + if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); } + } else { + list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() }); + } + list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts)); + if (list.length > 500) list.length = 500; + saveQbLeaderboard(); +} +function qbLeaderboardGet(roomId, name) { + roomId = String(roomId || '').trim(); + const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : []; + const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' })); + let me = null; + const nm = String(name || '').trim(); + if (nm) { + const idx = list.findIndex((e) => e && e.name === nm); + if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' }; + } + return { top, me, total: list.length }; +} +loadQbLeaderboard(); + +/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ +const os = require('os'); +let __prevCpuSample = null; +function __cpuSample() { + const cpus = os.cpus() || []; + let idle = 0, total = 0; + for (const c of cpus) { + for (const k in c.times) total += c.times[k]; + idle += c.times.idle; + } + return { idle, total }; +} +/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ +function __cpuPercent() { + const cur = __cpuSample(); + const prev = __prevCpuSample; + __prevCpuSample = cur; + if (!prev) return null; + const idleD = cur.idle - prev.idle; + const totalD = cur.total - prev.total; + if (totalD <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); +} +__cpuSample(); // เก็บ baseline แรก +function getDiskStats() { + try { + if (typeof fs.statfsSync !== 'function') return null; + const s = fs.statfsSync(__dirname); + const total = s.blocks * s.bsize; + const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด + const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ + const used = total - free; + if (!(total > 0)) return null; + return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; + } catch (e) { return null; } +} +/* จำนวนผู้เล่น/ห้อง/socket ออนไลน์ตอนนี้ — โชว์ในหน้า Admin เทียบกับเพดานความจุ */ +function getOnlineStats() { + let players = 0, rooms = 0; + try { + for (const [, sp] of spaces) { + if (sp && sp.peers) { players += sp.peers.size; rooms++; } + } + } catch (e) { /* ignore */ } + let sockets = 0; + try { sockets = (io && io.engine && io.engine.clientsCount) || 0; } catch (e) { /* ignore */ } + return { sockets, players, rooms }; +} + +function getSystemStats() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const cores = (os.cpus() || []).length || 1; + const cpuPct = __cpuPercent(); + const mu = process.memoryUsage(); + return { + ok: true, + cpu: { + percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) + cores, + load1: Math.round(load[0] * 100) / 100, + load5: Math.round(load[1] * 100) / 100, + load15: Math.round(load[2] * 100) / 100, + loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), + }, + mem: { + total: totalMem, + free: freeMem, + used: usedMem, + usedPercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDiskStats(), + node: { + rss: mu.rss, + heapUsed: mu.heapUsed, + rssPercent: Math.round((mu.rss / totalMem) * 100), + version: process.version, + uptime: Math.round(process.uptime()), + }, + uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, + /* ผู้ใช้ออนไลน์ + เพดานความจุ (ประเมินจาก 2 vCPU / socket fan-out): สบาย ≤300 · เริ่มหนัก ≥500 */ + online: getOnlineStats(), + capacity: { comfortable: 300, strain: 500 }, + hostname: os.hostname(), + platform: os.platform(), + ts: Date.now(), + }; +} + +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */ +const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5]; +const SPECIAL_QUIZ_CASES = [1, 2, 3]; +const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary']; +const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; + +function specialCardTxtFilename(id) { + return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`; +} + +function specialCardImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`; +} + +function specialCardTxtImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`; +} + +function specialQuizSetKey(level, caseId) { + return 'L' + level + '_C' + caseId; +} + +/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */ +function sanitizeSpecialQuizQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + const type = q.type === 'mcq' ? 'mcq' : 'tf'; + if (type === 'mcq') { + /* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */ + const choices = (Array.isArray(q.choices) ? q.choices : []) + .map((c) => String(c == null ? '' : c).trim()) + .slice(0, 8); + while (choices.length && choices[choices.length - 1] === '') choices.pop(); + if (choices.filter((c) => c).length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') { + ci = choices.findIndex((c) => c); + if (ci < 0) ci = 0; + } + return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci }; + } + return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue }; +} + +function sanitizeSpecialCard(c) { + const src = (c && typeof c === 'object') ? c : {}; + const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare'; + let cardId = Number(src.cardId); + if (!(cardId >= 1 && cardId <= 7)) cardId = null; + let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600); + let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600); + if (cardId) { + if (!imageUrl) imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } else if (imageUrl) { + const m = imageUrl.match(/\/card-(\d)\.png/i); + if (m) { + cardId = Number(m[1]); + imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } + } + return { + cardId: cardId || null, + th: String(src.th == null ? '' : src.th).trim().slice(0, 160), + en: String(src.en == null ? '' : src.en).trim().slice(0, 160), + rarity, + imageUrl, + txtImageUrl, + }; +} + +/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */ +function sanitizeSpecialQuizSets(obj) { + const out = {}; + SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => { + const key = specialQuizSetKey(L, C); + const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {}; + const questions = (Array.isArray(src.questions) ? src.questions : []) + .map(sanitizeSpecialQuizQuestion) + .filter(Boolean) + .slice(0, 50); + out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) }; + })); + return out; +} + +/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */ +function getSpecialQuizSet(settings, level, caseId) { + const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({}); + /* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่) + key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */ + const cid = Math.max(1, Math.min(15, Number(caseId) || 1)); + const L = Math.ceil(cid / 3); + const C = ((cid - 1) % 3) + 1; + return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; +} + +/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ +/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ +/** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */ +const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7']; +const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; +/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ +/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */ +const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; +/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน (roster คดี/LobbyB) + ⚠️ ห้ามตั้ง 0: เปลี่ยนหน้า lobbyB→play→lobbyB (socket หลุดชั่วคราว) จะถูกถอดจาก roster → rejoin ไม่ได้ ("ห้องนี้เริ่มคดีแล้ว") + หมายเหตุ: bot takeover "ในเกม MG1" (sess.bots) เป็นทันทีอยู่แล้ว แยกจาก grace นี้ */ +const DISCONNECT_GRACE_SEC = 10; + +/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */ +function normalizeTestSpecialCardByMap(v) { + const out = {}; + if (v && typeof v === 'object') { + SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => { + const c = Math.floor(Number(v[mid])); + if (c >= 1 && c <= 7) out[mid] = c; + }); + } + return out; +} +/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */ +const SPECIAL_QUIZ_ANSWER_MS = 20000; +/** หน่วงโชว์เฉลยก่อนข้อต่อไป */ +const SPECIAL_QUIZ_REVEAL_MS = 2600; + +/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */ +function specialQuizNormalizeQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + if (q.type === 'mcq') { + const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== ''); + if (choices.length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0; + return { text, choices, correctIndex: ci }; + } + return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 }; +} + +function specialQuizShuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function specialQuizTileWalkable(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const obj = md.objects && md.objects[y]; + if (obj && obj[x] === 1) return false; + /* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */ + const bp = md.blockPlayer && md.blockPlayer[y]; + if (bp && bp[x] === 1) return false; + return true; +} + +/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */ +function specialQuizTileInGameZone(md, x, y) { + if (quizCellOn(md.quizTrueArea || [], x, y)) return true; + if (quizCellOn(md.quizFalseArea || [], x, y)) return true; + if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true; + const oa = md.quizCarryOptionArea; + if (oa && oa[y] && oa[y][x]) return true; + return false; +} + +/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */ +function pickSpecialQuizSpawnTile(md, occupiedLaneCount) { + const w = md.width || 20; + const h = md.height || 15; + /* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer → + ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้ + → เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */ + if (md.gameType === 'quiz_carry') { + const nearMarkedTable = (x, y) => { + for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) { + if (!Array.isArray(g)) continue; + for (let dy = -2; dy <= 2; dy++) { + const gy = g[y + dy]; + if (!gy) continue; + for (let dx = -2; dx <= 2; dx++) { + if (gy[x + dx]) return true; + } + } + } + return false; + }; + const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length) + ? md.lobbyPlayerSpawns + : [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }]; + const qcCands = []; + seeds.forEach((s) => { + const bx = Math.floor(Number(s.x)); + const by = Math.floor(Number(s.y)); + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const dist = Math.abs(dx) + Math.abs(dy); + if (dist < 2 || dist > 6) continue; + const xx = bx + dx; + const yy = by + dy; + if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) { + qcCands.push({ x: xx, y: yy }); + } + } + } + }); + if (qcCands.length) { + const p = qcCands[Math.floor(Math.random() * qcCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns + (ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */ + if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) { + /* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */ + const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0) + ? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount)) + : md.gauntletPlayerSpawns.length; + const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes); + const rows = Array.from(new Set(usableSpawns + .map((s) => Math.floor(Number(s.y))) + .filter((n) => Number.isFinite(n) && n >= 0 && n < h))); + const laneCands = []; + const xMin = Math.max(4, Math.floor(w * 0.30)); + const xMax = Math.min(w - 2, Math.floor(w * 0.72)); + rows.forEach((ry) => { + for (let x = xMin; x <= xMax; x++) { + if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry }); + } + }); + if (laneCands.length) { + const p = laneCands[Math.floor(Math.random() * laneCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง + (ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */ + if (md.gameType === 'stack') { + const zc = []; + [md.stackReleaseArea, md.stackLandArea].forEach((g) => { + if (Array.isArray(g)) { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y }); + } + }); + if (zc.length) { + let cx2 = 0; + let cy2 = 0; + zc.forEach((c) => { cx2 += c.x; cy2 += c.y; }); + cx2 = Math.round(cx2 / zc.length); + cy2 = Math.round(cy2 / zc.length); + for (let rad = 0; rad <= 6; rad++) { + for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) { + for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 }; + } + } + } + /* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */ + return { x: cx2 + 0.5, y: cy2 + 0.5 }; + } + } + /* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack + (สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */ + if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) { + const painted = []; + const paintedWalkable = []; + for (let yy = 0; yy < h; yy++) { + const row = md.specialQuizSpawnArea[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (!row[xx]) continue; + painted.push({ x: xx, y: yy }); + if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy }); + } + } + const pickFrom = paintedWalkable.length ? paintedWalkable : painted; + if (pickFrom.length) { + const p = pickFrom[Math.floor(Math.random() * pickFrom.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* jump_survive (กระโดด mnptfts2): ผู้เล่นยืนบน "แพลตฟอร์ม" — พื้นที่ว่างนับ walkable ผิด (ลอยกลางอากาศเก็บไม่ได้) + ไอคอนคลิก/แตะเก็บได้ทุกมินิเกม (trySpecialQuizClickAt) → วางบนแพลตฟอร์มใกล้จุดเกิด (อยู่ในจอตั้งแต่ต้น คลิกได้) + = แก้ #44 อีเวนต์พิเศษ (ทนาย/เทพ) ไม่ขึ้นเพราะ BFS หา walkable ไม่เจอ → return null */ + if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) { + const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }; + const bx = Math.floor(Number(sp.x)); + const by = Math.floor(Number(sp.y)); + const plats = []; + for (let y = 0; y < h; y++) { + const row = md.jumpSurvivePlatformArea[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x]) plats.push({ x, y }); + } + } + if (plats.length) { + plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by))); + const near = plats.slice(0, Math.min(8, plats.length)); + const p = near[Math.floor(Math.random() * near.length)]; + const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */ + return { x: p.x + 0.5, y: iy + 0.5 }; + } + } + /* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้ + fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */ + let sx = -1; + let sy = -1; + const spawnGrid = md.spawnArea; + if (Array.isArray(spawnGrid)) { + for (let y = 0; y < h && sx < 0; y++) { + const row = spawnGrid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) { + const sp = md.spawn || { x: 1, y: 1 }; + const fx = Math.floor(Number(sp.x)); + const fy = Math.floor(Number(sp.y)); + if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; } + } + if (sx < 0) { + /* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก + เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */ + const ccx = Math.floor(w / 2); + const ccy = Math.floor(h / 2); + const maxRad = Math.max(w, h); + for (let rad = 0; rad <= maxRad && sx < 0; rad++) { + for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) { + for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; } + } + } + } + } + if (sx < 0) { + for (let y = 0; y < h && sx < 0; y++) { + for (let x = 0; x < w; x++) { + if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) return null; + const q = [[sx, sy]]; + const seen = new Set([sx + ',' + sy]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + const cands = []; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = nx + ',' + ny; + if (seen.has(k)) continue; + if (!specialQuizTileWalkable(md, nx, ny)) continue; + seen.add(k); + q.push([nx, ny]); + if (!specialQuizTileInGameZone(md, nx, ny)) { + const dist = Math.abs(nx - sx) + Math.abs(ny - sy); + if (dist >= 2) cands.push({ x: nx, y: ny }); + } + } + } + if (!cands.length) return null; + /* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ · + ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */ + const near = cands.filter((c) => { + const d = Math.abs(c.x - sx) + Math.abs(c.y - sy); + return d >= 2 && d <= 8; + }); + const pool = near.length ? near : cands; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x + 0.5, y: pick.y + 0.5 }; +} + +function clearSpecialQuizTimers(space) { + if (Array.isArray(space.specialQuizTimers)) { + space.specialQuizTimers.forEach((t) => clearTimeout(t)); + } + space.specialQuizTimers = []; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + space.specialQuizAwaitContinue = false; +} + +/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */ +function specialQuizIconPayload(space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done || !sq.appeared) return null; + return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 }; +} + +/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง (1 ใบต่อครั้งที่ติด — ดีไซน์ "1 ใน 3") + Test Mode (testSpecialCardByMap / forceSpecialQuiz) → ข้าม roll ขึ้นเสมอ */ +function maybeSpawnSpecialQuizForRun(space) { + clearSpecialQuizTimers(space); + space.specialQuiz = null; + space.specialCardAwardedThisRun = null; + space.timeDilationPendingSec = 0; + /* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้ + หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */ + if (!space || !space.detectiveMinigameActive) return; + const mapId = String(space.mapId || '').trim(); + if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return; + const level = Number(space.detectiveLobbyLevel) || 1; + const caseId = Number(space.detectiveLobbyCaseId) || 1; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, level, caseId); + const questions = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + let card = set && set.specialCard; + /* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */ + const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId]; + const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7); + if (testForcedCard) { + const def = specialCardDef(testCardId) || {}; + card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th }); + } + const hasCard = !!(card && (card.cardId || card.imageUrl)); + if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; } + const forced = !!space.forceSpecialQuiz || testForcedCard; + const roll = Math.random(); + if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; } + if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; } + /* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */ + const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); + const tile = pickSpecialQuizSpawnTile(md, occupiedLanes); + if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; } + const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)]; + console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y); + space.specialQuiz = { + iconType, + x: tile.x, + y: tile.y, + level, + caseId, + card, + triggered: false, + done: false, + session: null, + }; +} + +function emitSpecialQuizIcon(sid, space) { + const payload = specialQuizIconPayload(space); + if (payload) io.to(sid).emit('special-quiz-icon', payload); +} + +/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */ +const SPECIAL_QUIZ_ICON_DELAY_MS = 2000; +function scheduleSpecialQuizIconAppearAndExpiry(sid, space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done) return; + const tAppear = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return; + const cur = sp.specialQuiz; + const sec = readSpecialQuizIconExpireSec(); + cur.appeared = true; + cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0; + emitSpecialQuizIcon(sid, sp); + if (sec > 0) { + const tExpire = setTimeout(() => { + const sp2 = spaces.get(sid); + if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return; + sp2.specialQuiz = null; + io.to(sid).emit('special-quiz-icon-clear', {}); + console.log('[special-quiz] icon EXPIRED after', sec + 's'); + }, sec * 1000); + if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = []; + sp.specialQuizTimers.push(tExpire); + } + }, SPECIAL_QUIZ_ICON_DELAY_MS); + if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = []; + space.specialQuizTimers.push(tAppear); +} + +/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */ +function buildSpecialQuizRoster(space) { + const roster = []; + space.peers.forEach((p, id) => { + roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }); + }); + const botN = effectiveBotSlotCount(space); + for (let i = 0; i < botN; i++) { + roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true }); + } + return roster; +} + +function specialQuizRosterPayload(sess) { + return (sess && Array.isArray(sess.roster) ? sess.roster : []) + .map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot })); +} + +/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */ +function specialQuizExpectedIds(space, sess) { + const realAlive = new Set(space.peers.keys()); + const out = []; + (sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => { + if (m.isBot) out.push(m.id); + else if (realAlive.has(m.id)) out.push(m.id); + }); + return out; +} + +/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ +function maybeResolveSpecialQuizAllAnswered(sid, space) { + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return; + const expected = specialQuizExpectedIds(space, sess); + const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid])); + if (allAnswered) resolveSpecialQuizQuestion(sid, space, false); +} + +/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */ +function scheduleSpecialQuizBotAnswers(sid, space, sess, q) { + const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot); + if (!bots.length) return; + const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500); + bots.forEach((b) => { + const delay = 900 + Math.floor(Math.random() * windowMs); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session; + if (!spNow || sNow !== sess || sess.resolving) return; + if (Number.isInteger(sess.answers[b.id])) return; + sess.answers[b.id] = q.correctIndex; + io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true }); + maybeResolveSpecialQuizAllAnswered(sid, spNow); + }, delay); + space.specialQuizTimers.push(t); + }); +} + +function sendSpecialQuizQuestion(sid, space) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess) return; + const q = sess.questions[sess.qIndex]; + if (!q) { endSpecialQuizSession(sid, space, false); return; } + sess.answers = {}; + sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS; + io.to(sid).emit('special-quiz-question', { + index: sess.qIndex, + total: sess.questions.length, + text: q.text, + choices: q.choices, + endsAt: sess.phaseEndsAt, + iconType: sq.iconType, + roster: specialQuizRosterPayload(sess), + }); + scheduleSpecialQuizBotAnswers(sid, space, sess, q); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + resolveSpecialQuizQuestion(sid, spNow, true); + }, SPECIAL_QUIZ_ANSWER_MS + 80); + space.specialQuizTimers.push(t); +} + +/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */ +function resolveSpecialQuizQuestion(sid, space, fromTimeout) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess || sess.resolving) return; + sess.resolving = true; + clearSpecialQuizTimers(space); + const q = sess.questions[sess.qIndex]; + const expected = specialQuizExpectedIds(space, sess); + let allCorrect = expected.length > 0; + expected.forEach((pid) => { + const a = sess.answers[pid]; + if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false; + }); + io.to(sid).emit('special-quiz-result', { + index: sess.qIndex, + correctIndex: q.correctIndex, + answers: { ...sess.answers }, + roster: specialQuizRosterPayload(sess), + allCorrect, + timeout: !!fromTimeout, + }); + if (!allCorrect) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, false); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, true); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + sess.resolving = false; + sendSpecialQuizQuestion(sid, spNow); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); +} + +/** + * นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน: + * minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation) + * now = ใช้ทันทีตอนได้รับ (Fund coins) + * after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence) + * pre_trial = ก่อนโหวตพิจารณาคดี (Silence) + * trial_vote = ระหว่างโหวตพิจารณาคดี (Extension) + * trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin) + * lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban) + */ +const SPECIAL_CARD_DEFS = { + 1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' }, + 2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' }, + 3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' }, + 4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' }, + 5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' }, + 6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' }, + 7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, +}; + +function specialCardDef(cardId) { + return SPECIAL_CARD_DEFS[Number(cardId)] || null; +} + +/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */ +function specialCardClientPayload(card) { + if (!card) return null; + const def = specialCardDef(card.cardId) || {}; + return { + cardId: card.cardId || null, + th: card.th || def.th || '', + en: card.en || '', + rarity: card.rarity || 'common', + imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''), + txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''), + effectKey: def.key || '', + effectWhen: def.when || '', + addSec: def.addSec || 0, + desc: def.th || '', + }; +} + +/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */ +const TIME_DILATION_MAX_PENDING_SEC = 30; + +/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */ +function applySpecialCardImmediate(sid, space, card) { + const def = specialCardDef(card && card.cardId); + if (!def) return; + if (def.when === 'minigame' && def.key === 'time_dilation') { + /* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง + cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */ + space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10)); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + addSec: def.addSec || 10, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + if (def.when === 'now' && def.key === 'fund') { + /* +COINS — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม + noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */ + const coins = def.coins || 10; + const awards = [...space.peers.values()] + .filter((p) => p.playerKey) + .map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true })); + submitGameAwards(awards); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + coins: coins, + coinsServerAwarded: true, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) + — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ +} + +/* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */ +function dbgCards(space) { + return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : ''))); +} +function pendingSpecialCardList(space) { + if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = []; + return space.pendingSpecialCards; +} +/** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */ +function addPendingSpecialCard(space, card) { + if (!card || !card.cardId) return null; + const list = pendingSpecialCardList(space); + const cid = Number(card.cardId); + if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; } + const entry = { cardId: cid, card: card, consumed: false }; + list.push(entry); + console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space)); + return entry; +} +/** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */ +function findPendingSpecialCard(space, cardId) { + const cid = Number(cardId); + return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null; +} +function consumePendingSpecialCard(space, cardId) { + const e = findPendingSpecialCard(space, cardId); + if (e) e.consumed = true; +} + +function endSpecialQuizSession(sid, space, success) { + const sq = space.specialQuiz; + if (!sq) return; + clearSpecialQuizTimers(space); + sq.done = true; + sq.session = null; + let cardOut = null; + console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId)); + if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { + space.specialCardAwardedThisRun = sq.card; + cardOut = specialCardClientPayload(sq.card); + addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */ + applySpecialCardImmediate(sid, space, sq.card); + } + io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); + /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) + — มี safety timeout กันค้างถ้าไม่มีใครกด */ + space.specialQuizAwaitContinue = true; + space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */ + /* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */ + io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size }); + if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = setTimeout(() => { + space.specialQuizContinueTimer = null; + finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */ + }, 32000); +} + +/** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */ +function maybeFinishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set()); + const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */ + const ready = expected.filter((pid) => acks.has(pid)).length; + io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length }); + if (expected.length === 0 || expected.every((pid) => acks.has(pid))) { + finishSpecialQuizContinue(sid, space); + } +} + +/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ +function finishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + space.specialQuizAwaitContinue = false; + space.specialQuizContinueAcks = null; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + /* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */ + io.to(sid).emit('special-quiz-resume-all', {}); + resumeQuizAfterSpecialQuiz(sid, space); +} + +/* เกมหยุดชั่วคราวระหว่างคำถามพิเศษ (กำลังตอบ หรือรอกด «เล่นต่อ») + ใช้ร่วมกันทุก minigame ฝั่ง server — เดิมมีแต่ gauntlet เช็ค ทำให้ MG6/MG7 บอทยังเดิน/ยิงตอน pause */ +function isSpecialQuizPausedServer(space) { + if (!space) return false; + return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)); +} + +function startSpecialQuizSession(sid, space) { + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return false; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, sq.level, sq.caseId); + const all = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + if (!all.length) return false; + /* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */ + const picked = specialQuizShuffle(all).slice(0, 1); + sq.triggered = true; + sq.session = { + questions: picked, + qIndex: 0, + answers: {}, + phaseEndsAt: 0, + resolving: false, + roster: buildSpecialQuizRoster(space), + }; + clearSpecialQuizTimers(space); + pauseQuizForSpecialQuiz(sid, space); + io.to(sid).emit('special-quiz-icon-clear', {}); + sendSpecialQuizQuestion(sid, space); + return true; +} + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 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, + }; +} + +/** อ่านเวลา 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, + }; + } + } 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), + /* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */ + lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud') + ? !!d.lobbyDebugHud + : !!prev.lobbyDebugHud, + lobbyDebugHudNames: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHudNames') + ? (Array.isArray(d.lobbyDebugHudNames) ? d.lobbyDebugHudNames.slice(0, 30).map((s) => String(s).slice(0, 40)) : []) + : (Array.isArray(prev.lobbyDebugHudNames) ? prev.lobbyDebugHudNames : []), + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const specialQuizSets = d.specialQuizSets != null + ? sanitizeSpecialQuizSets(d.specialQuizSets) + : sanitizeSpecialQuizSets(prev.specialQuizSets); + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + specialQuizSets, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + specialQuizSets: out.specialQuizSets, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + let cw = Math.floor(Number(md.characterCellsW)); + let ch = Math.floor(Number(md.characterCellsH)); + const hasW = Number.isFinite(cw) && cw >= 1; + const hasH = Number.isFinite(ch) && ch >= 1; + if (hasW && hasH) { + return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; + } + const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); + return { cw: leg, ch: leg }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(md); + let colW = Math.floor(Number(md.characterCollisionW)); + let colH = Math.floor(Number(md.characterCollisionH)); + if (!Number.isFinite(colW) || colW < 1) colW = cw; + if (!Number.isFinite(colH) || colH < 1) colH = ch; + colW = Math.max(1, Math.min(cw, colW)); + colH = Math.max(1, Math.min(ch, colH)); + return { cw, ch, colW, colH }; +} + +const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; +const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; + +function quizBattleSpriteBoundsTileKeys(md, px, py) { + const out = []; + if (!md || md.gameType !== 'quiz_battle') return out; + const x = Number(px), y = Number(py); + if (!Number.isFinite(x) || !Number.isFinite(y)) return out; + const w = md.width || 20, h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const cx = x + cw * 0.5; + const feetY = y + ch; + const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; + const bottom = feetY; + if (left < 0 || right > w || top < 0 || bottom > h) return out; + const minTx = Math.floor(left); + const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); + const minTy = Math.floor(top); + const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`); + } + return out; +} + +function serverWallCollisionTileKeys(md, px, py) { + if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(md, px, py); + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + for (let ty = offY; ty < offY + colH; ty++) { + for (let tx = offX; tx < offX + colW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; +} + +function validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */ +function pauseQuizForSpecialQuiz(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.active || space.quizFrozenForSpecial) return; + const phase = sess.phase; + if (phase !== 'read' && phase !== 'answer') return; + const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now()); + space.quizFrozenForSpecial = true; + space.quizFreezePhase = phase; + space.quizFreezeRemainingMs = remaining; + clearSpaceQuizTimers(space); + io.to(sid).emit('quiz-paused', { phase }); +} + +/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */ +function resumeQuizAfterSpecialQuiz(sid, space) { + if (!space.quizFrozenForSpecial) return; + space.quizFrozenForSpecial = false; + const sess = space.quizSession; + const phase = space.quizFreezePhase; + const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0); + space.quizFreezePhase = null; + space.quizFreezeRemainingMs = 0; + if (!sess || !sess.active) return; + const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space); + if (!md) return; + /* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */ + let extraMs = 0; + if (space.timeDilationPendingSec > 0) { + extraMs = space.timeDilationPendingSec * 1000; + space.timeDilationPendingSec = 0; + } + sess.phaseEndsAt = Date.now() + remaining + extraMs; + if (phase === 'read') { + sess.phase = 'read'; + const q = sess.questions[sess.qIndex]; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + text: String((q && q.text) || '').trim(), + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining); + space.quizTimers.push(t); + } else { + sess.phase = 'answer'; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining); + space.quizTimers.push(t); + } +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function ensurePeerLobbyThemes(space) { + if (!space || !space.peers) return; + const seen = new Set(); + space.peers.forEach((p, id) => { + let ti = parseInt(p.lobbyColorThemeIndex, 10); + if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) { + ti = pickFreeLobbyThemeIndex(space, id); + p.lobbyColorThemeIndex = ti; + } + seen.add(ti); + if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) { + p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0); + } + }); +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null, + lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, + lobbySkinToneIndex: p.lobbySkinToneIndex || null, + bannedSpectator: !!p.bannedSpectator, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ + → client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client + เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */ +/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */ +function isBannedPeerForRun(space, id) { + if (!space) return false; + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true; + const pp = space.peers && space.peers.get(id); + return !!(pp && pp.bannedSpectator); +} + +/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */ +function readyMapWithoutBanned(space, srcMap) { + const out = {}; + if (srcMap && typeof srcMap === 'object') { + Object.keys(srcMap).forEach((id) => { + if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id]; + }); + } + return out; +} + +function quizCarryLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady), + peers: buildPeersSnapForSpace(space), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + }; +} + +/* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน + แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */ +function crownLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + peers: buildPeersSnapForSpace(space), + }; +} + +/* เช็คว่าพร้อมเริ่มมินิเกม crown-lobby ไหม: ยังไม่เริ่ม + เข้าครบ + ทุกคน (ไม่นับ banned) กด Ready */ +function crownLobbyAllReadyToStart(space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)); + if (humanIds.length === 0) return false; + const expectedActive = space.minigameExpectedActiveCount || 0; + if (expectedActive > 0 && humanIds.length < expectedActive) return false; + return humanIds.every((id) => rm[id]); +} + +/* เริ่มมินิเกม crown-lobby (server เป็นคนสั่ง) — idempotent ด้วย gr.lobbyStarted (รีเซ็ตเองทุกมินิเกมเพราะ gr สร้างใหม่) + ใช้ทั้ง host กด START และ auto-start เมื่อทุกคน ready (Track B: MG1 ไม่ต้องพึ่ง host) */ +function performCrownLobbyStart(sid, space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + gr.lobbyStarted = true; + const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; + space.missionLiveBeginsAt = liveBeginsAt; + if (isQuizQuestionMissionShellSpace(space)) { + /* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ตรงกับตอน client จบ 3-2-1) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'quiz') { + clearSpaceQuizTimers(sp); + startQuizGame(sid, sp, md2); + } + }, LIVE_COUNTDOWN_MS); + } + if (isStackTowerMissionShellSpace(space)) { + /* MG3 stack: เริ่ม server engine ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'stack') startStackTicker(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isJumpSurviveMissionShellSpace(space)) { + /* MG5 jump_survive: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'jump_survive') startJumpSurviveGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isSpaceShooterMissionShellSpace(space)) { + /* MG6 space_shooter: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'space_shooter') startSpaceShooterGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isBalloonBossMissionShellSpace(space)) { + /* MG7 balloon_boss: เริ่ม server engine (บอท+รอบ+HP บอส) ตอน client จบ countdown */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'balloon_boss') startBalloonBossGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt }); + return true; +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +/* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด + ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */ +function detectiveLobbyBMissingParticipants(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 }; + const present = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) present.add(n); + }); + const total = roster.size; + return { missing: Math.max(0, total - present.size), present: present.size, total }; +} + +/* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */ +function emitDetectiveLobbyBPresence(sid, space) { + if (!sid || !space) return; + if (!serverMapIsPostCaseLobbyB(space)) return; + const info = detectiveLobbyBMissingParticipants(space); + if (info.total <= 0) return; + io.to(sid).emit('detective-lobbyb-presence', { + present: info.present, + total: info.total, + allHere: info.missing === 0, + }); +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + /** โหมดเทสต์ (admin game-timing): เลือกเกมต่อการ์ด 3 ใบ — ช่องที่ตั้ง key = เจาะจง, ช่องว่าง = สุ่ม */ + const forcedKeys = normalizeForcedMinigameKeys(runtimeGameTiming && runtimeGameTiming.forcedMinigameKeys); + const randomPool = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + let ri = 0; + const pick3 = []; + for (let i = 0; i < 3; i++) { + const fk = forcedKeys[i]; + let entry = fk ? available.find((e) => e.key === fk) : null; + if (!entry) { entry = randomPool[ri % randomPool.length]; ri += 1; } + pick3.push(entry); + } + space.suspectCardMinigames = pick3.map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); + // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ + space.playerEvidence = {}; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; +} + +function ensureSpacePlayerEvidence(space) { + if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {}; + if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {}; +} + +/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */ +function normalizeEvidenceSlotList(cards) { + if (!Array.isArray(cards)) return []; + const out = []; + for (let i = 0; i < cards.length; i++) { + const c = sanitizeStoredEvidenceCard(cards[i]); + if (c) out.push(c); + } + return out; +} + +/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */ +function peerNickKey(space, playerId) { + if (!space || !playerId) return ''; + const p = space.peers && space.peers.get ? space.peers.get(playerId) : null; + if (!p || !p.nickname) return ''; + return normalizeLobbyNickname(p.nickname); +} + +/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */ +function clonePlayerEvidence(space, playerId) { + ensureSpacePlayerEvidence(space); + let row = space.playerEvidence[playerId]; + if (!row || !row.length) { + const nk = peerNickKey(space, playerId); + if (nk && space.evidenceByNick[nk]) { + row = space.evidenceByNick[nk]; + space.playerEvidence[playerId] = row; + } + } + if (!row) row = [[], [], []]; + return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i])); +} + +/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */ +function syncEvidenceByNick(space, playerId) { + ensureSpacePlayerEvidence(space); + const nk = peerNickKey(space, playerId); + if (!nk) return; + const row = space.playerEvidence[playerId]; + if (Array.isArray(row)) space.evidenceByNick[nk] = row; +} + +/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */ +function playerSuspectProgressFromEvidence(space, playerId) { + const row = clonePlayerEvidence(space, playerId); + return row.map((arr) => arr.length); +} + +/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */ +function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) { + if (suspectIndex < 0 || suspectIndex > 2) return null; + ensureSpacePlayerEvidence(space); + /* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */ + if (!space.playerEvidence[playerId]) { + const nk = peerNickKey(space, playerId); + space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk]) + ? space.evidenceByNick[nk] + : [[], [], []]; + } + const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]); + /* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */ + if (list.length >= 3) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + const card = pickEvidenceCardForSuspect(space, suspectIndex, grade); + if (!card) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + list.push(card); + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return card; +} + +function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) { + const out = {}; + (playerIds || []).forEach((pid) => { + if (!space.peers.has(pid)) return; + const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade); + if (card != null) out[pid] = card; + }); + return out; +} + +function resolveDetectiveSuspectAwardIndex(space) { + if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) { + return space.suspectActiveIndex; + } + if (typeof space.lastDetectiveInvestigatedSuspect === 'number' + && space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) { + return space.lastDetectiveInvestigatedSuspect; + } + if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) { + return space.suspectPickIndex; + } + return -1; +} + +/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */ +function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { + const suspectIdx = resolveDetectiveSuspectAwardIndex(space); + if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} }; + if (space.detectiveAwardDoneForRun) { + return { suspectIdx, awarded: {}, alreadyGranted: true }; + } + const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()]; + const grade = computeRunGradeForEvidence(space); + const awardedList = {}; + ids.forEach((pid) => { awardedList[pid] = []; }); + const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade); + ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); }); + /* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */ + let freeEvidenceCount = 0; + const pend = findPendingSpecialCard(space, 7); + if (pend) { + const def = specialCardDef(7); + freeEvidenceCount = (def && def.evidence) || 1; + for (let n = 0; n < freeEvidenceCount; n++) { + ids.forEach((pid) => { + if (!space.peers.has(pid)) return; + const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade); + if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; } + }); + } + pend.consumed = true; + } + space.detectiveAwardDoneForRun = true; + space.lastDetectiveInvestigatedSuspect = suspectIdx; + space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount }; + emitLobbyEvidenceSync(sid, space); + return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount }; +} + +function emitLobbyEvidenceSync(sid, space) { + if (!sid || !space) return; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('lobby-evidence-sync', { + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) { + const msg = message || 'จบมินิเกม — กลับ LobbyB'; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + const personal = { + ...basePayload, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }; + sock.emit('quiz-ended', { message: msg, returnToLobbyB: true }); + sock.emit('detective-minigame-ended', personal); + sock.emit('game-start', personal); + }); +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveAwardDoneForRun = false; + space.coinAwardDoneForRun = false; + space.lastCrownMissionGrade = null; + /* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */ + space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; }); + /* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */ + space.lastMinigameOver = null; + /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ + space.bannedThisRunPlayerId = space.bannedPlayerId || null; + console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId); + space.bannedPlayerId = null; + /* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน) + สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */ + space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + glog(sid, space, 'game-start', 'เริ่มมินิเกม: ' + ((md && md.name) || mapId) + ' (การ์ด ' + ((selectedIndex != null ? selectedIndex + 1 : '?')) + ')'); + /* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */ + maybeSpawnSpecialQuizForRun(space); + /* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */ + + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */ + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + /** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */ + const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID; + if (!isQuizMissionShell) { + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + /* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */ + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + /* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client + กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */ + if (md.gameType === 'quiz_carry') { + space.quizCarryLobbyReady = {}; + space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; }); + io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) { + ensurePostCaseLobbyMapLoaded(); + /* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */ + awardMinigameRankCoins(sid, space); + clearSpaceQuizTimers(space); + clearSpecialQuizTimers(space); + space.specialQuiz = null; + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + /* Last Light (MG2): เก็บเกรด mission (รวมบอท server-authoritative) ก่อนล้าง gauntletRun + → ใช้เป็น "แหล่งเดียว" ของเกรดหลักฐาน ให้ตรงกับสรุปภารกิจที่ผู้เล่นเห็น */ + if (space.gauntletRun && isGauntletCrownHeistMapBySpace(space)) { + try { space.lastCrownMissionGrade = buildGauntletCrownMissionPayload(space).grade; } + catch (_e) { space.lastCrownMissionGrade = null; } + } + stopGauntletTicker(space); + space.gauntletRun = null; + /* MG3 stack (server-auth): หยุด engine + ล้าง session (เกรดถูกเก็บไว้ใน lastStackMissionGrade ตอนจบแล้ว) */ + stopStackTicker(space); + if (space.stackSession) space.stackSession.active = false; + space.stackSession = null; + /* MG4 quiz_carry (server-auth): ล้าง session (คะแนนถูกอ่านไปแจกเหรียญใน awardMinigameRankCoins ด้านบนแล้ว) */ + if (space.quizCarrySession) space.quizCarrySession.active = false; + space.quizCarrySession = null; + 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; + let awardIds = []; + if (awardOptions && awardOptions.allPlayers) { + awardIds = [...space.peers.keys()]; + } else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) { + awardIds = [awardOptions.playerId]; + } else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) { + awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id)); + } + /* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */ + awardIds = awardIds.filter((id) => { + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false; + const pp = space.peers.get(id); + if (pp && pp.bannedSpectator) return false; + return true; + }); + /* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */ + if (!space.detectiveAwardDoneForRun && awardIds.length) { + /* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */ + grantDetectiveEvidenceForCurrentRun(sid, space, awardIds); + } + } + space.suspectActiveIndex = null; + space.bannedThisRunPlayerId = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const basePayload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + detectiveReturn: true, + }; + emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload); + /* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ" + ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */ + const banPend = findPendingSpecialCard(space, 3); + console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space)); + if (banPend && !space.pickVote) { + banPend.consumed = true; + /* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */ + space.pendingBanVoteCard = banPend.card; + } +} + +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: trialEligibleVoterTotal(space), + }); +} + +function computeAndEmitTrialResult(sid, space) { + /* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */ + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + /* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */ + { + /* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */ + const forceWrong = !!space.testForceWrongVote; + space.testForceWrongVote = false; + const c2 = [0, 0, 0]; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; }); + let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; } + const anyVotes = (c2[0] + c2[1] + c2[2]) > 0; + const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit); + const pend = findPendingSpecialCard(space, 5); + if (wrong && pend && !space.trialBailUsed) { + pend.consumed = true; + space.trialBailUsed = true; + space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */ + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); + openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted }); + return; + } + } + space.trialPhase = 'revealed'; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + if (p && p.nickname) return p.nickname; + if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) { + const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10); + return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท'; + } + return String(id).slice(0, 6); + }); + /* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */ + let disruptorId = null; + if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) { + for (const [pid, p] of space.peers) { + const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey; + const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname; + if (keyMatch || nickMatch) { disruptorId = pid; break; } + } + } + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + disruptorId, + hasDisruptor: !!disruptorId, + }); + + /* ===== Achievement auto-track ===== */ + try { + const achv = []; + /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */ + winners.forEach((id) => { + const p = space.peers.get(id); + if (p && p.playerKey) { + achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 }); + achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 }); + } + }); + /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */ + if (disruptorId && space.disruptorPlayerKey) { + const dKey = space.disruptorPlayerKey; + achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */ + /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */ + let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; } + const anyV = (counts[0] + counts[1] + counts[2]) > 0; + if (anyV && mv !== culprit) { + achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */ + } + } + submitAchievementProgress(achv); + } catch (e) { console.error('[achv] trial-result', e && e.message); } + + space.silencedPlayerId = null; +} + +/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ +/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */ +const TESTIMONY_BOT_PREFIX = '__lobby_bot_'; + +function testimonyBotIds(space) { + const n = effectiveBotSlotCount(space); + const out = []; + for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i); + return out; +} + +function buildTestimonyMembers(space) { + const arr = []; + space.peers.forEach((p, id) => { + arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false }); + }); + testimonyBotIds(space).forEach((bid, i) => { + arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true }); + }); + return arr; +} + +/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */ +function testimonyTotalParticipants(space) { + return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); +} + +/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */ +function botTestimonyPickCount(space) { + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + let maxOwned = 0; + space.peers.forEach((_p, pid) => { + const ev = clonePlayerEvidence(space, pid); + const len = Array.isArray(ev[round]) ? ev[round].length : 0; + if (len > maxOwned) maxOwned = len; + }); + /* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */ + if (maxOwned === 0) return 1; + return Math.max(1, Math.min(2, maxOwned)); +} + +/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */ +function autoSubmitBotTestimonyPicks(space) { + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + const want = botTestimonyPickCount(space); + testimonyBotIds(space).forEach((bid) => { + if (space.testimonySubmitted[bid] != null) return; + const picks = []; + for (let n = 0; n < want; n++) { + const c = pickEvidenceCardForSuspect(space, round, 'C'); + if (c) picks.push(c); + } + space.testimonySubmitted[bid] = picks; + }); +} + +function emitTestimonyOpen(sid, space) { + space.testimonySubmitted = {}; + space.testimonyReadyNext = {}; + space.testimonyRevealed = false; + /* บอทส่งหลักฐานทันทีเมื่อเปิด round */ + autoSubmitBotTestimonyPicks(space); + const submittedIds = Object.keys(space.testimonySubmitted || {}); + const base = { + round: space.testimonyRound, + suspectIndex: space.testimonyRound, + members: buildTestimonyMembers(space), + totalPlayers: testimonyTotalParticipants(space), + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + submitted: submittedIds, + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('testimony-open', { + ...base, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + /* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */ + emitTestimonyStatus(sid, space); +} + +function emitTestimonyStatus(sid, space) { + io.to(sid).emit('testimony-status', { + round: space.testimonyRound, + submitted: Object.keys(space.testimonySubmitted || {}), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +function doTestimonyReveal(sid, space) { + space.testimonyRevealed = true; + /* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */ + autoSubmitBotTestimonyPicks(space); + /* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + io.to(sid).emit('testimony-reveal', { + round: space.testimonyRound, + picks: space.testimonySubmitted || {}, + members: buildTestimonyMembers(space), + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */ +/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */ +/* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */ +function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; } +const TRIAL_VOTE_EXTENSION_MS = 10000; + +function clearTrialVoteTimer(space) { + if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; } +} + +/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */ +function openTrialVotingPhase(sid, space, opts) { + opts = opts || {}; + space.testimonyActive = false; + space.trialPhase = 'voting'; + space.trialVotes = {}; + /* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */ + const excludedSuspect = opts.revote + ? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect + : (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null)) + : null; + if (!opts.revote) space.trialRevoteExcluded = null; + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ + let extraMs = 0; + let extensionCard = false; + const pend = findPendingSpecialCard(space, 2); + if (pend) { + extraMs = TRIAL_VOTE_EXTENSION_MS; + extensionCard = true; + pend.consumed = true; + } + const durMs = trialVoteMs() + extraMs; + space.trialVoteEndsAt = Date.now() + durMs; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + voteEndsAt: space.trialVoteEndsAt, + voteDurationMs: durMs, + extensionCard, + extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0, + revote: !!opts.revote, + excludedSuspect: excludedSuspect, + silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId), + }); + }); + if (extensionCard) { + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(pend.card), + addSec: TRIAL_VOTE_EXTENSION_MS / 1000, + context: 'trial', + }); + } + emitTrialVoteUpdate(sid, space); + space.trialVoteTimer = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.trialPhase !== 'voting') return; + computeAndEmitTrialResult(sid, spNow); + }, durMs + 120); +} + +/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */ +/* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */ +function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; } + +function clearPickVoteTimer(space) { + if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; } +} + +function pickVoteCandidates(space) { + const arr = []; + space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false })); + testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true })); + return arr; +} + +function autoVotePickBots(space) { + const pv = space.pickVote; + if (!pv) return; + testimonyBotIds(space).forEach((bid) => { + if (pv.votes[bid] != null) return; + const choices = pv.candidates.filter((c) => c.id !== bid); + if (!choices.length) return; + pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id; + }); +} + +function emitPickVoteUpdate(sid, space) { + const pv = space.pickVote; + if (!pv) return; + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + io.to(sid).emit('player-pick-vote-update', { + voted: Object.keys(pv.votes).length, + total: pv.candidates.length, + counts, + }); +} + +function startPlayerPickVote(sid, space, purpose, onResolve) { + clearPickVoteTimer(space); + const PV_MS = pickVoteMs(); + const candidates = pickVoteCandidates(space); + space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_MS, resolved: false, onResolve }; + autoVotePickBots(space); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('player-pick-vote-open', { + purpose, + candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: space.pickVote.endsAt, + durationMs: PV_MS, + }); + }); + emitPickVoteUpdate(sid, space); + space.pickVoteTimer = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.pickVote || sp.pickVote.resolved) return; + resolvePlayerPickVote(sid, sp); + }, PV_MS + 120); + maybeResolvePickVote(sid, space); +} + +function maybeResolvePickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + const ids = pv.candidates.map((c) => c.id); + if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space); +} + +function resolvePlayerPickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + pv.resolved = true; + clearPickVoteTimer(space); + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + let max = 0; + Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); + const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); + /* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */ + const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : null; + const tc = pv.candidates.find((c) => c.id === target); + const targetName = tc ? tc.nickname : ''; + io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts }); + const cb = pv.onResolve; + space.pickVote = null; + if (typeof cb === 'function') cb(target, targetName); +} + +function trialSilencedIsParticipant(space) { + const s = space.silencedPlayerId; + if (!s) return false; + if (space.peers.has(s)) return true; + return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0); +} + +function trialEligibleVoterTotal(space) { + let t = testimonyTotalParticipants(space); + if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1); + return t; +} + +function autoVoteBotsTrial(space) { + if (!space.trialVotes) space.trialVotes = {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3); + const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1; + testimonyBotIds(space).forEach((bid) => { + if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */ + if (space.trialVotes[bid] != null) return; + /* 40% โหวตถูก, 60% โหวตสุ่ม */ + let pick; + if (Math.random() < 0.4) { + pick = culprit; + } else { + pick = Math.floor(Math.random() * 3); + } + /* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */ + if (pick === excluded) pick = culprit; + space.trialVotes[bid] = pick; + }); +} + +function advanceTestimony(sid, space) { + space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; + if (space.testimonyRound >= 3) { + space.trialBailUsed = false; + space.silencedPlayerId = null; + /* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */ + const pend = findPendingSpecialCard(space, 4); + console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space)); + if (pend) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' }); + startPlayerPickVote(sid, space, 'silence', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.silencedPlayerId = targetId || null; + openTrialVotingPhase(sid, sp, { revote: false }); + }); + return; + } + openTrialVotingPhase(sid, space, { revote: false }); + } else { + emitTestimonyOpen(sid, space); + } +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + /* คะแนนสุดท้าย authoritative (ผู้เล่นจริง) — ส่งไปกับ quiz-ended ให้ทุก client ตัดสินผล complete/gameover ตรงกัน + (เดิมไม่ส่ง → client อ่าน playLiveQuizScores local ที่ quiz-result ข้อสุดท้ายอาจมาไม่ทัน → ผล 2 จอไม่ตรง) */ + const quizFinalScores = {}; + const sessF = space.quizSession; + if (sessF && sessF.players) { + Object.keys(sessF.players).forEach((pid) => { + const st = sessF.players[pid]; + quizFinalScores[pid] = (st && typeof st.score === 'number') ? Math.max(0, st.score) : 0; + }); + } + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) + เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" + การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true, scores: quizFinalScores }); + /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ + (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', scores: quizFinalScores }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + /* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */ + const qCur = sess.questions[sess.qIndex]; + if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + /* บอท: server เป็นเจ้าของคะแนน (จากคำตอบที่ตัดสินไว้) + คัดออกถ้าผิด (กฎเดียวกับคน "ตอบผิดถูกคัดออก") */ + if (sess.bots) { + sess.bots.forEach((b, botId) => { + if (!b.eliminated) { + if (b.answerRight) { + b.score += QUIZ_TF_POINTS_PER_CORRECT; + } else { + b.eliminated = true; + /* คัดออก → วาร์ปเข้าโซนผีทันที (เหมือนคนจริง) */ + const gc = quizRandomCellInZone(mdLive.quizGhostArea, mdLive); + if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; } + b.wanderTX = null; b.retargetAt = 0; + } + } + scores[botId] = b.score; + results.push({ + id: botId, nickname: 'บอท', right: !!b.answerRight, choice: b.choice, + eliminated: !!b.eliminated, score: b.score, + }); + b.tx = null; b.ty = null; /* รีเซ็ตเป้า → เดินมั่วจนข้อถัดไป */ + }); + } + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + /** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */ + const playFullQuizRound = + !!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space); + if (allWrong && !playFullQuizRound) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + space.quizSession.bots = createQuizBots(space, md); + scheduleQuizReadPhase(sid, space, md); +} + +/* ===== MG1 quiz bots — server-authoritative (roster + เดิน + ตอบ + คะแนน) ===== + เดิม host เดิน+คิดคะแนนบอทเอง → host หลุด = บอทหยุด + คะแนนเพี้ยน. ย้ายมา server → บอทเดินเอง time-based + คะแนน server เป็นเจ้าของ + id `__pv_bot_` 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; + try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId || '-') + ' 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, + 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) => { + /* ทับ 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; + } + 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); +} + +/* ── 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, + }; +} + +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 = 1500; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป */ +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, + }); +} +function stackAdvanceTurnServer(s) { + if (!s.turnOrder || !s.turnOrder.length) return; + s.turnIndex = (s.turnIndex + 1) % s.turnOrder.length; + s.botThinkUntil = 0; + s.botAimPhase = null; +} +function stackSessionMissionGrade(space) { + const s = space.stackSession; + if (!s) return 'C'; + const scores = []; + s.turnOrder.forEach((e) => { + if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); scores.push(Math.max(0, (b && b.score) | 0)); } + else if (e.id != null) scores.push(Math.max(0, s.humanPts[e.id] | 0)); + }); + const n = scores.length || 1; + const avg = Math.floor(scores.reduce((a, b) => a + b, 0) / n); + let grade = avg >= 80 ? 'A' : avg >= 60 ? 'B' : 'C'; + return grade; +} +function stackSessionEnd(sid, space, outcome) { + const s = space.stackSession; + if (!s || s.ended) return; + s.ended = true; + stopStackTicker(space); + /* คะแนนผู้เล่นจริง → reportedMiniScore (ใช้ทางเดิมของ coin/evidence) */ + s.turnOrder.forEach((e) => { + if (e.kind === 'human' && e.id != null) { + const p = space.peers.get(e.id); + if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; } + } + }); + try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; } + const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' }; + io.to(sid).emit('stack-over', overPayload); + space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() }; +} +function runStackTick(sid, space) { + const s = space.stackSession; + if (!s || !s.active || s.ended) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; } + const now = Date.now(); + /* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */ + if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; } + if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; } + if ((now - s.startMs) >= stackTimeLimitSecServer() * 1000) { stackSessionEnd(sid, space, 'time_up'); return; } + if (!s.turnOrder.length) return; + /* รอแอนิเมชันบล็อกก่อนหน้า (busy) → ไม่แกว่ง/ไม่ดรอป */ + if (now < s.busyUntil) return; + const entry = stackCurrentTurnEntry(s); + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + const bot = s.bots.get(entry.botId); + const tier = bot ? bot.tier : 'avg'; + if (s.botThinkUntil === 0) { + s.botThinkUntil = now + STACK_BOT_THINK_MIN_MS + Math.floor(Math.random() * (STACK_BOT_THINK_MAX_MS - STACK_BOT_THINK_MIN_MS)); + } else { + /* เลื่อนเฟสตามเวลา (ให้ client เห็นแกว่ง) */ + s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2; + /* ส่ง swingAmp+topCenterX ด้วย → client วาดบล็อกที่ห้อยตรงกับโมเดล server เป๊ะ (drop landOffset = swingAmp*sin(phase)) ไม่วาปตอนปล่อย */ + if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX, 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; + const _dbgAim = s.botAimPhase; const _dbgDeadline = (now >= s.botAimDeadline) && !(s.phase >= s.botAimPhase); + s.botAimPhase = null; + const hit = stackComputeDropServer(s, md); + /* [DBG-MG3drop] บอทดรอปที่ s.phase → เทียบ aimPhase/จุดลง/รองรับ/overlap/miss (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'BOT ' + (entry.botId || '') + ' phase=' + s.phase.toFixed(3) + + ' aimPhase=' + (Number.isFinite(_dbgAim) ? _dbgAim.toFixed(3) : 'x') + (_dbgDeadline ? '(DEADLINE!)' : '(reached)') + + ' sinDrop=' + Math.sin(s.phase).toFixed(3) + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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 }); + } + /* [DBG-MG6move] จับสถานะบอททุก ~2.5วิ → STILL=server ไม่ขับ, DEAD=ตาย(client ต้องซ่อน), MOV=ปกติ */ + if (!s._dbgMT || now - s._dbgMT > 2500) { + s._dbgMT = now; + const rows = []; + s.bots.forEach((b, id) => { + const mv = (b._ldx == null) ? '?' : ((Math.abs(b.cx - b._ldx) + Math.abs(b.cy - b._ldy) > 2) ? 'MOV' : 'STILL'); + b._ldx = b.cx; b._ldy = b.cy; + const tag = (id.indexOf(JS_BOT_PREFIX) === 0 ? 'pv' + id.slice(JS_BOT_PREFIX.length) : 'TK:' + String(id).slice(0, 5)); + rows.push(tag + (b.takeover ? 'tk' : '') + '·' + (b.eliminated ? 'DEAD' : mv)); + }); + glog(sid, space, 'mg6-move', '[' + rows.join(' ') + ']'); + } +} + +/* ============================================================ + 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' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true }); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + y0 = Math.max(0, Math.min(h - 1, y0)); + y1 = Math.max(0, Math.min(h - 1, y1)); + if (y1 < y0) { + const t = y0; + y0 = y1; + y1 = t; + } + return { y0, y1 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + /* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */ + if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) { + if (gr.endsAt != null) gr.endsAt += getGauntletTickMs(); + emitGauntletSync(sid, space); + return; + } + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client + เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */ + if (gr.finishPhase) { + if (gr.obstacles.length) { gr.obstacles = []; } + if (gr.finishEndAt == null) gr.finishEndAt = Date.now() + GAUNTLET_FINISH_WALK_MS; + if (Date.now() >= gr.finishEndAt) { + endGauntletGame(sid, space, 'finish'); + } + /* ไม่ emitGauntletSync ระหว่าง grace — ปล่อย client คุมการเดินเข้าเส้นเอง (กัน sync ดึงตำแหน่งกลับ) */ + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* G2: pre-run hold — หลัง 3-2-1 จบ ค้างที่จุด start ให้เห็นเส้นเริ่มก่อน (ยังไม่เลื่อน/เกิด obstacle/ขยับบอท) + client ค้าง bg เองด้วย max(0, serverNow − liveStartMs) → bg+bots/obstacles sync ที่ start พร้อมกัน */ + if (gr.liveStartMs != null && Date.now() < gr.liveStartMs) { + space.peers.forEach((p) => { p.gauntletJumpPending = false; }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* บอท server-authoritative — แถวเลนของบอทที่ยังเล่นอยู่ (ไม่ต้องพึ่ง gauntlet-preview-rows จาก host) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (b.gauntletEliminated) return; + const lr = gauntletLaneGridRowFromPlayerTop(md, b.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + } + /* แถวบนที่ client แจ้ง (บอท preview — editor หรือ client เก่า) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + let _gauntletLaserThisTick = false; + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + _gauntletLaserThisTick = true; + } + /* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */ + if (!_gauntletLaserThisTick) { + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + /* บอท server-authoritative — ตัดสินใจกระโดด + ฟิสิกส์ รอบ tick เดียวกับผู้เล่น (obstacle เลื่อนแล้วข้างบน) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (crownHeist && b.gauntletEliminated) return; + gauntletBotDecideJump(b, gr.obstacles, md, w, h); + gauntletBotStepPhysics(b, gr.obstacles, md, w, h, crownHeist); + }); + } + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; +} + +function mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(md) { + const w = md.width || 20; + const h = md.height || 15; + const out = [null, null, null, null, null, null]; + const raw = md && md.lobbyPlayerSpawns; + if (!Array.isArray(raw)) return out; + for (let i = 0; i < 6 && i < raw.length; i++) { + const cell = raw[i]; + if (!cell || typeof cell !== 'object') continue; + const x = Math.floor(Number(cell.x)); + const y = Math.floor(Number(cell.y)); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (x < 0 || x >= w || y < 0 || y >= h) continue; + out[i] = { x, y }; + } + return out; +} + +/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) { + if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; + const g = md.shooterSpawnSlots; + if (!g) return; + const w = md.width || 20, h = md.height || 15; + for (let slot = 1; slot <= 6; slot++) { + const idx = slot - 1; + if (slots6[idx]) continue; + let found = false; + for (let yy = 0; yy < h && !found; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] === slot) { + slots6[idx] = { x: xx, y: yy }; + found = true; + break; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */ +/** เลือก host คนถัดไปเมื่อ host ปัจจุบันหลุด — คนที่อยู่ในห้องนานสุด (spawnJoinOrder ต่ำสุด) + เสถียรข้ามการ reconnect (spawnJoinOrder ผูกกับ playerKey) — แทน Map-first ที่สลับได้ */ +function pickNextHostPeer(space) { + if (!space || !space.peers || space.peers.size === 0) return null; + let best = null; + let bestOrd = Infinity; + space.peers.forEach((p, id) => { + if (!p) return; + const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999; + if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) { + bestOrd = ord; + best = p; + } + }); + return best; +} + +function resolveSpawnJoinOrder(space, playerKey) { + if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {}; + const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const used = new Set(); + space.peers.forEach((p) => { + if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder); + }); + /* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */ + if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) { + const prev = space.spawnOrderByKey[key]; + if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev; + } + let ord = 0; + while (used.has(ord)) ord++; + if (key) space.spawnOrderByKey[key] = ord; + return ord; +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน < 4) */ +function openSuspectPhaseForRoom(sid, space, disruptorAccepted) { + if (!space) return; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + const phaseBase = { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: !!disruptorAccepted, + cardMinigames: space.suspectCardMinigames || [], + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('suspect-phase-open', { + ...phaseBase, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + /* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */ + const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer); + if (peerIds.length < 4 && !forceOffer) { + /* ไม่เสนอบทตัวป่วน (คน < 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */ + space.troublesomeOfferSent = true; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + openSuspectPhaseForRoom(sid, space, false); + return; + } + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 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) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + /* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */ + const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const prevHostId = space.hostId; + if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) { + space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */ + } else if (!space.hostId) { + space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */ + if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey; + } + /* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers + จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost) + แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */ + if (joinPlayerKey) { + const staleIds = []; + space.peers.forEach((p, pid) => { + if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid); + }); + staleIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + /* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */ + if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) { + delete space.graceReconnect[joinPlayerKey]; + if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; } + io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' }); + } + /* MG1 takeover: ผู้เล่นกลับมากลาง quiz (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (sess.bots) คืนสิทธิ์เล่น + กัน "ตัวซ้ำ" (บอท takeover + ตัวที่ rejoin) */ + const sessJ = space.quizSession; + if (sessJ && sessJ.bots) { + const dropBotIds = []; + sessJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBotIds.push(bid); }); + dropBotIds.forEach((bid) => { + sessJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); /* ลบ avatar บอท takeover เก่า — ตัวจริงจะมาใหม่ผ่าน user-joined */ + }); + } + /* MG2 takeover เช่นกัน: ผู้เล่นกลับมากลาง gauntlet (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (gauntletRun.bots) */ + const grJ = space.gauntletRun; + if (grJ && grJ.bots) { + const dropG = []; + grJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropG.push(bid); }); + dropG.forEach((bid) => { + grJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG3 takeover เช่นกัน: กลับมากลาง stack (playerKey เดิม) → ถอนบอท + คืน seat ให้คนจริง (inherit คะแนนกลับ) */ + const s3J = space.stackSession; + if (s3J && s3J.bots) { + let restored = false; + const dropS = []; + s3J.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropS.push(bid); }); + dropS.forEach((bid) => { + const inherited = Math.max(0, ((s3J.bots.get(bid) || {}).score) | 0); + s3J.bots.delete(bid); + const entry = (s3J.turnOrder || []).find((e) => e.kind === 'bot' && String(e.botId) === String(bid)); + if (entry) { entry.kind = 'human'; entry.id = socket.id; entry.botId = null; s3J.humanPts[socket.id] = inherited; restored = true; } + io.to(spaceId).emit('user-left', { id: bid }); + }); + if (restored) emitStackStateServer(spaceId, space); + } + /* MG4 takeover เช่นกัน: กลับมากลาง quiz_carry (playerKey เดิม) → ถอนบอท + คืน slot ให้คนจริง (inherit ป้าย/คะแนนกลับ) */ + const sCJ = space.quizCarrySession; + if (sCJ && sCJ.bots) { + const dropC = []; + sCJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropC.push(bid); }); + dropC.forEach((bid) => { + const bb = sCJ.bots.get(bid) || {}; + sCJ.bots.delete(bid); + sCJ.players[socket.id] = { held: (bb.held == null ? null : bb.held), score: Math.max(0, Number(bb.score) || 0) }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG5 takeover เช่นกัน: กลับมากลาง jump_survive (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit สถานะตาย) */ + const sJJ = space.jumpSurviveSession; + if (sJJ && sJJ.bots) { + const dropJ = []; + sJJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropJ.push(bid); }); + dropJ.forEach((bid) => { + const bb = sJJ.bots.get(bid) || {}; + sJJ.bots.delete(bid); + sJJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG6 takeover: กลับมากลาง space_shooter (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit คะแนน/ตาย) */ + const sSJ = space.spaceShooterSession; + if (sSJ && sSJ.bots) { + const dropSS = []; + sSJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropSS.push(bid); }); + dropSS.forEach((bid) => { + const bb = sSJ.bots.get(bid) || {}; + sSJ.bots.delete(bid); + sSJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG7 takeover: กลับมากลาง balloon_boss (playerKey เดิม) → ถอนบอท + คืน slot คนจริง */ + const sBJ = space.balloonBossSession; + if (sBJ && sBJ.bots) { + const dropBB = []; + sBJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBB.push(bid); }); + dropBB.forEach((bid) => { sBJ.bots.delete(bid); io.to(spaceId).emit('user-left', { id: bid }); }); + } + } + /* reconnect dedup ชั้น 2: ghost ที่ playerKey block จับไม่ได้ (peer เก่าไม่มี playerKey) + ลบด้วย "ชื่อ custom เดียวกัน" — แต่ **เฉพาะ peer เก่าที่ไม่มี valid playerKey** เท่านั้น + ⚠️ ห้ามลบ peer ที่มี playerKey คนละค่า: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละคน ต้องเก็บทั้งคู่ + (เดิมลบทุกตัวที่ชื่อตรง → client ที่ชื่อซ้ำ host ถูกลบ = "ไม่ได้เล่นเลย") */ + const validPkStr = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : ''; + const newNickNorm = normalizeLobbyNickname(nickname); + const newNickIsCustom = newNickNorm && newNickNorm !== 'ผู้เล่น' + && newNickNorm.indexOf('ผู้เล่น') !== 0 && newNickNorm.indexOf('บอท') !== 0; + if (newNickIsCustom) { + const ghostIds = []; + space.peers.forEach((p, pid) => { + if (pid === socket.id || !p) return; + const ppk = validPkStr(p); + if (ppk && ppk !== joinPlayerKey) return; /* คนละ playerKey = คนละคน (ชื่อซ้ำได้) → ไม่ลบ */ + if (normalizeLobbyNickname(p.nickname) === newNickNorm) ghostIds.push(pid); + }); + ghostIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + } + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + ensurePeerLobbyThemes(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey); + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = balloonBossBalloonsForMap(mdJoin); + /* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */ + let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10); + if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) { + /* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */ + let takenByHuman = false; + space.peers.forEach((p) => { + const ti = parseInt(p.lobbyColorThemeIndex, 10); + if (ti === chosenThemeIdx) takenByHuman = true; + }); + if (takenByHuman) { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + /* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */ + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0; + }); + } + } else { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } + let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10); + if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) { + chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder); + } + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + lobbyColorThemeIndex: chosenThemeIdx, + lobbySkinToneIndex: chosenSkinIdx, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '', + reportedMiniScore: 0, reportedMiniSurvived: true, + bannedSpectator: !!bannedSpectator, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* เก็บกวาด peer ซ้ำทั้งก้อน (ghost) — เผื่อ dedup ต่อ-join ด้านบนพลาด/race จนคนเดิมค้าง 2 ตัว */ + pruneDuplicateSpacePeers(spaceId, space); + glog(spaceId, space, 'join', 'สี#' + (peer.lobbyColorThemeIndex || '?') + ' · คน ' + space.peers.size + ' + บอท ' + effectiveBotSlotCount(space) + (joinPlayerKey ? '' : ' · NO-KEY'), nickname || 'ผู้เล่น'); + /* invariant: คน + บอท ≤ 6 — กัน "เงาสะท้อนของ user": คนหลุดแล้ว grace แปลงเป็นบอท (botSlotCount+1, สีตามคนออก) + พอคนนั้นกลับเข้ามา (reconnect หลัง grace) จะมีทั้งคนจริง + บอทสีเดียวกัน = ตัวซ้ำ. ตัดบอทส่วนเกินออกให้รวมไม่เกิน 6 + (มินิเกม cap ที่ 6-humans อยู่แล้ว ~rebalancePreviewBots แต่ LobbyB ไม่ได้ cap → ตัดที่ server ต้นทาง) */ + { + const humansNow = space.peers.size; + const botsNow = effectiveBotSlotCount(space); + if (humansNow + botsNow > LOBBY_SLOT_TOTAL) { + space.botSlotCount = Math.max(0, LOBBY_SLOT_TOTAL - humansNow); + } + /* re-sync สีบอททุกครั้งที่มีคน (re)join + แจ้ง "ทุกจอ" (รวมคนที่อยู่ในมินิเกม/LobbyB อยู่แล้ว): + เคส "เงาสะท้อนของ user" = คนหลุด→บอทใช้สีคนนั้น (pin)→คนนั้นกลับมา. join handler เคลียร์ pin ที่ชนสีคนแล้ว + (7704) + syncLobbyBotThemeSlots เลือกสีใหม่ที่ไม่ชนคน แต่เดิม emit เฉพาะตอน total>6 → จออื่นไม่อัปเดต = บอทยังสีเดิม. + emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */ + emitLobbyTintSync(spaceId, space); + } + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id); + /* นับช่อง spawn ให้พอกับ "คน + บอท" — เดิมส่งแค่ peers.size: ห้อง 1 คน = 1 ช่อง = pos[0] (เลนบนสุด) เสมอ + แล้วไปทับบอทที่ initGauntletPeersAll วางไว้ที่ pos[0] → คน(host ใหม่)+บอท วิ่งเลนเดียวกันเลนบนสุด (bug ที่เจอซ้ำ) */ + const grJoin = space.gauntletRun; + const botCntJoin = (grJoin && grJoin.bots) ? grJoin.bots.size : 0; + const pos = gauntletSpawnPositions(mdJoin, space.peers.size + botCntJoin); + /* เลี่ยงทับทั้ง "peer อื่น" และ "บอท" ที่อยู่ในเลนแล้ว — ดันไปช่องว่างถัดไป (เดิมเช็คแต่ peer ไม่เช็คบอท) */ + const occupied = new Set(); + space.peers.forEach((q) => { if (q && q.id !== socket.id) occupied.add(Math.floor(q.x) + ',' + Math.floor(q.y)); }); + if (grJoin && grJoin.bots) grJoin.bots.forEach((b) => { if (b) occupied.add(Math.floor(Number(b.x)) + ',' + Math.floor(Number(b.y))); }); + let pidx = Number.isFinite(ord) ? Math.max(0, Math.min(ord, pos.length - 1)) : pos.length - 1; + let pguard = 0; + while (pos[pidx] && occupied.has(Math.floor(pos[pidx].x) + ',' + Math.floor(pos[pidx].y)) && pguard < pos.length) { pidx = (pidx + 1) % pos.length; pguard++; } + const pt = pos[pidx] != null ? pos[pidx] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + botSlotCount: effectiveBotSlotCount(space), + lobbyBotThemes: syncLobbyBotThemeSlots(space), + joinLocked: !!space.joinLocked, + missionLiveBeginsAt: space.missionLiveBeginsAt || null, + }; + if (serverMapIsPostCaseLobbyB(space)) { + joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null; + joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null; + } + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + /* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */ + const sqIcon = specialQuizIconPayload(space); + if (sqIcon) joinCb.specialQuizIcon = sqIcon; + if (typeof cb === 'function') cb(joinCb); + /* กันค้างตอน reconnect: event "จบมินิเกม" (balloon-boss-over/stack-over/space-shooter-over) + host ยิงครั้งเดียวผ่าน socket.to — ถ้า client หลุดตอนนั้นจะค้างถาวร (ไม่มี replay). + เก็บ over ล่าสุดไว้ → resend ให้ socket ที่เพิ่ง (re)join ภายใน 45s ถ้ายังอยู่ในแมปมินิเกม + (gameType !== 'lobby' = ยังไม่ทรานซิชันกลับ). delay เล็กน้อยให้ client applyMapAndStart เสร็จก่อน */ + const lmo = space.lastMinigameOver; + if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') { + const targetSid = socket.id; + setTimeout(() => { + try { + const sp = spaces.get(spaceId); + if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) { + io.to(targetSid).emit(lmo.event, lmo.payload); + } + } catch (_e) { /* ignore */ } + }, 400); + } + if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */ + emitLobbyTintSync(spaceId, space); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + /* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที + (คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */ + if (serverMapIsPostCaseLobbyB(space)) { + /* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */ + emitDetectiveLobbyBPresence(spaceId, space); + console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id); + if (space.pendingBanVoteCard && !space.pickVote) { + const banCard = space.pendingBanVoteCard; + space.pendingBanVoteCard = null; + /* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */ + setTimeout(() => { + const sp = spaces.get(spaceId); + if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) { + console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp))); + return; + } + console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')'); + io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' }); + startPlayerPickVote(spaceId, sp, 'ban', (targetId) => { + const sp2 = spaces.get(spaceId); + console.log('[ban-debug] ban vote RESOLVED target=', targetId); + if (sp2) sp2.bannedPlayerId = targetId || null; + }); + }, 2000); + } else if (space.pickVote && !space.pickVote.resolved) { + const pv = space.pickVote; + socket.emit('player-pick-vote-open', { + purpose: pv.purpose, + candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: pv.endsAt, + durationMs: pickVoteMs(), + }); + } + } + }); + + /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ + /** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */ + socket.on('special-quiz-force', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + space.forceSpecialQuiz = !!(data && data.on); + console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id); + }); + + /** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ + socket.on('special-quiz-testmode', (data) => { + socket.data.testMode = !!(data && data.on); + }); + + /** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */ + socket.on('test-force-wrong-vote', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return; + space.testForceWrongVote = true; + console.log('[test] force-wrong-vote armed by', socket.id); + }); + + /** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */ + socket.on('special-quiz-arm', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return; + sq.armScheduled = true; + scheduleSpecialQuizIconAppearAndExpiry(sid, space); + }); + + socket.on('special-quiz-collide', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return reply({ ok: false }); + const started = startSpecialQuizSession(sid, space); + reply({ ok: !!started }); + }); + + /** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */ + socket.on('special-quiz-answer', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return reply({ ok: false }); + const q = sess.questions[sess.qIndex]; + if (!q) return reply({ ok: false }); + /* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */ + let choice; + if (data && data.debugCorrect && socket.data.testMode) { + choice = q.correctIndex; + } else { + choice = Number(data && data.choiceIndex); + } + if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) { + return reply({ ok: false }); + } + if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' }); + sess.answers[socket.id] = choice; + reply({ ok: true }); + /* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */ + io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true }); + /* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ + maybeResolveSpecialQuizAllAnswered(sid, space); + }); + + /** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */ + socket.on('special-quiz-continue', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + /* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */ + if (!space.specialQuizAwaitContinue) return reply({ ok: true }); + /* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */ + if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set(); + space.specialQuizContinueAcks.add(socket.id); + reply({ ok: true }); + maybeFinishSpecialQuizContinue(sid, space); + }); + + /** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */ + socket.on('lobby-tint-update', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + const peer = space && space.peers.get(socket.id); + if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + let themeIndex = Math.floor(Number(data && data.themeIndex)); + let skinIndex = Math.floor(Number(data && data.skinToneIndex)); + if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) { + return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' }); + } + if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) { + skinIndex = peer.lobbySkinToneIndex || 1; + } + /* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join) + กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */ + let humanTaken = false; + space.peers.forEach((op, oid) => { + if (oid === socket.id) return; + if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true; + }); + if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); + if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */ + }); + } + peer.lobbyColorThemeIndex = themeIndex; + peer.lobbySkinToneIndex = skinIndex; + emitLobbyTintSync(sid, space); + reply({ + ok: true, + lobbyColorThemeIndex: themeIndex, + lobbySkinToneIndex: skinIndex, + lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], + }); + }); + + /* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */ + /* DEPRECATED: server เป็นเจ้าของบอท lobby เองแล้ว (stepLobbyBotsForSpace/lobbyBotSweepTick) + → ไม่ relay ตำแหน่งบอทจาก client อีก (กัน host เวอร์ชันเก่าฉีดตำแหน่งชนกับ server tick = บอทกระตุก/2 แหล่ง) */ + socket.on('lobby-bot-move', () => { /* no-op: server-authoritative */ }); + + /* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */ + socket.on('fill-bot-state', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ + if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; + /* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host" + แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */ + const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined); + socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans }); + }); + + /* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host) + payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */ + socket.on('balloon-boss-shot', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */ + const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); + if (!ok) return; + socket.to(sid).emit('balloon-boss-shot', { + sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy, + ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), + }); + }); + + /* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */ + socket.on('balloon-boss-over', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const reason = (data.reason === 'victory' || data.reason === 'all_dead' || data.reason === 'time') ? data.reason : 'all_dead'; + const scores = Array.isArray(data.scores) + ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated })) + : []; + const overPayload = { reason, scores }; + socket.to(sid).emit('balloon-boss-over', overPayload); + /* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */ + space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() }; + }); + + /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ + → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ + socket.on('balloon-boss-sync', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const bossDmg = Math.max(0, Number(data.bossDmg) || 0); + const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */ + const players = Array.isArray(data.players) + ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ + id: p.id, + d: Math.max(0, Number(p.d) || 0), + b: Math.max(0, Number(p.b) || 0), + e: !!p.e, + })) + : []; + socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor }); + }); + + /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + socket.on('stack-drop', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: client ไม่เป็นเจ้าของดรอป */ + const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; + const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; }; + socket.to(sid).emit('stack-drop', { + placedCx: num(data.placedCx), placedW: num(data.placedW), + miss: !!data.miss, perfect: !!data.perfect, + pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), + overlap: num(data.overlap), + supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), + delta: num(data.delta), + seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, + theme: themeOf(data.theme), /* สี block ที่วาง (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client freeze สีตัวเอง = เพี้ยน */ + actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, + }); + }); + /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ + socket.on('quiz-end-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ + if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'quiz') return; + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); + }); + + /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ + socket.on('stack-swing', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: server แกว่งเอง */ + const ph = Number(data.phase); + if (!Number.isFinite(ph)) return; + socket.to(sid).emit('stack-swing', { phase: ph }); + }); + + /* MG3 stack (server-auth) — รับ "เจตนากดวาง" จากผู้เล่นจริง → server คำนวณ ณ เฟสปัจจุบัน + emit ผลให้ทุกเครื่อง */ + socket.on('stack-drop-req', (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); + /* [DBG-MG3drop] เทียบ client-sent phase vs s.phase + จุดลง/รองรับ/overlap/miss/หัวใจ (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'HUMAN cph=' + (Number.isFinite(cph) ? cph.toFixed(3) : 'x') + + ' sPhase=' + s.phase.toFixed(3) + ' used=' + dropPhase.toFixed(3) + (dropPhase === cph ? '(client)' : '(server)') + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + ' perfect=' + hit.perfect + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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) { + return reply({ + ok: false, + waitingPlayers: true, + present: waitInfo.present, + total: waitInfo.total, + error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')', + }); + } + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; + const cardEntry = space.suspectCardMinigames[selectedIndex]; + if (!cardEntry || !cardEntry.mapId) { + return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); + } + /* จำกัดสืบผู้ต้องสงสัยละไม่เกิน 3 ครั้ง (#1) — นับจาก max หลักฐานของผู้ต้องสงสัยนี้ข้ามผู้เล่น + (แต่ละรอบทุกคนได้ +1) → กันเล่นครั้งที่ 4 + กันการ์ดพิเศษโผล่รอบที่ 4 (#2) */ + let timesPlayedThisSuspect = 0; + space.peers.forEach((_p, pid) => { + const prog = playerSuspectProgressFromEvidence(space, pid); + const n = Array.isArray(prog) ? (prog[selectedIndex] | 0) : 0; + if (n > timesPlayedThisSuspect) timesPlayedThisSuspect = n; + }); + if (timesPlayedThisSuspect >= 3) { + return reply({ ok: false, error: 'สืบผู้ต้องสงสัยคนนี้ครบ 3 ครั้งแล้ว — เลือกคนอื่น' }); + } + /* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */ + const pend = findPendingSpecialCard(space, 3); + if (pend && !space.pickVote) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' }); + startPlayerPickVote(sid, space, 'ban', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.bannedPlayerId = targetId || null; + const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex); + if (!res || !res.ok) { + io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true }); + } + }); + return reply({ ok: true, banVote: true }); + } + const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); + reply(started); + }); + + socket.on('detective-minigame-finished', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + } + /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */ + const finPeer = space.peers.get(socket.id); + if (finPeer && data && typeof data === 'object') { + /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง + หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */ + if (Number.isFinite(Number(data.score))) { + let rs = Math.max(0, Math.floor(Number(data.score))); + if (rs > REPORTED_MINI_SCORE_MAX) { + console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX); + rs = REPORTED_MINI_SCORE_MAX; + } + finPeer.reportedMiniScore = rs; + } + if (data.survived === false) finPeer.reportedMiniSurvived = false; + } + if (!space.detectiveMinigameActive) { + if (serverMapIsPostCaseLobbyB(space)) { + const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]); + return reply({ + ok: true, + alreadyOnLobbyB: true, + suspectIndex: grant.suspectIdx, + awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null, + awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [], + grade: grant.grade || null, + freeEvidenceCount: grant.freeEvidenceCount || 0, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + } + return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); + } + /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก" + จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด + ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */ + const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) { + if (finPeer) finPeer.detectiveFinished = true; + const allDone = [...space.peers.values()].every((p) => + p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished + ); + if (!allDone) { + /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */ + return reply({ ok: true, holdInScene: true }); + } + /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */ + } + // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน) + returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true }); + const g = space.lastEvidenceGrant || {}; + reply({ + ok: true, + suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1, + awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null, + awardedCards: (g.awardedList && g.awardedList[socket.id]) || [], + grade: g.grade || null, + freeEvidenceCount: g.freeEvidenceCount || 0, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + }); + + /** + * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที + * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง) + */ + socket.on('detective-debug-award-evidence', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + const idx = Math.floor(Number(data && data.suspectIndex)); + if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' }); + const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C'); + if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' }); + emitLobbyEvidenceSync(sid, space); + reply({ + ok: true, + suspectIndex: idx, + awardedCard: card, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + }); + + // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย) + socket.on('suspect-accuse-open', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' }); + const prog = playerSuspectProgressFromEvidence(space, socket.id); + if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' }); + if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3); + space.suspectPhaseActive = false; + // เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต + space.testimonyActive = true; + space.testimonyRound = 0; + emitTestimonyOpen(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นส่งหลักฐาน 1–2 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง + socket.on('testimony-submit', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' }); + if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' }); + const raw = (data && Array.isArray(data.picks)) ? data.picks : []; + const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []); + const required = Math.max(1, Math.min(2, owned.length)); + /* raw = ตำแหน่ง index ใน owned (0..n-1) → เก็บเป็น card object */ + const usedIdx = []; + const picks = []; + raw.forEach((n) => { + const v = Math.floor(Number(n)); + if (v >= 0 && v < owned.length && usedIdx.indexOf(v) === -1) { usedIdx.push(v); picks.push(owned[v]); } + }); + if (owned.length === 0) { + /* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */ + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = []; + emitTestimonyStatus(sid, space); + /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design */ + return reply({ ok: true, skipped: true }); + } + if (picks.length !== required) { + return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' }); + } + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = picks.slice(0, required); + emitTestimonyStatus(sid, space); + /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design + (UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */ + reply({ ok: true }); + }); + + // Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง) + socket.on('testimony-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' }); + doTestimonyReveal(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) — ไม่ auto-advance รอ host กด "เริ่มพิจารณาคดี" + socket.on('testimony-ready-next', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' }); + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + space.testimonyReadyNext[socket.id] = true; + const totalRN = testimonyTotalParticipants(space); + io.to(sid).emit('testimony-ready-update', { + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: totalRN, + hostId: space.hostId, + }); + /* ไม่ auto-advance — รอ host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote.png) เอง */ + reply({ ok: true }); + }); + + // Host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote) — ไปปากคำถัดไป/เข้าโหวต + socket.on('testimony-start-vote', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังไม่ถึงเวลา' }); + /* host อาจกดก่อนทุกคน READY — บังคับให้ทุกคน (รวมบอท) ready ก่อน advance เพื่อให้ flow consistent */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + advanceTestimony(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นโหวตชี้ตัวคนร้าย + socket.on('trial-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' }); + if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true }); + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); + /* รอบจับผิดตัว: ห้ามโหวตผู้ต้องสงสัยที่โหวตผิดไปแล้ว */ + if (Number.isInteger(space.trialRevoteExcluded) && idx === space.trialRevoteExcluded) { + return reply({ ok: false, error: 'ผู้ต้องสงสัยนี้ถูกโหวตไปแล้ว (ผิด) — เลือกคนอื่น', excluded: true }); + } + space.trialVotes = space.trialVotes || {}; + space.trialVotes[socket.id] = idx; + emitTrialVoteUpdate(sid, space); + const totalTV = trialEligibleVoterTotal(space); + if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + + /* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */ + socket.on('player-pick-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const pv = space.pickVote; + if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' }); + const tid = String((data && data.targetId) || ''); + if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' }); + pv.votes[socket.id] = tid; + reply({ ok: true, targetId: tid }); + emitPickVoteUpdate(sid, space); + maybeResolvePickVote(sid, space); + }); + + /** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */ + socket.on('host-console-apply', (data, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const humans = space.peers.size; + let total = parseInt(data && data.totalSlots, 10); + if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space); + total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total)); + let bots = parseInt(data && data.botSlotCount, 10); + if (!Number.isFinite(bots)) bots = total - humans; + bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots)); + total = humans + bots; + space.botSlotCount = bots; + space.maxPlayers = Math.max(humans, total - bots); + const lobbyBotThemes = syncLobbyBotThemeSlots(space); + io.to(sid).emit('host-console-settings', { + maxPlayers: space.maxPlayers, + botSlotCount: space.botSlotCount, + totalSlots: total, + lobbyBotThemes, + }); + emitLobbyTintSync(sid, space); + cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes }); + }); + + socket.on('host-console-kick-peer', ({ targetId }, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const tid = targetId != null ? String(targetId) : ''; + if (!tid || tid === socket.id || tid === space.hostId) { + return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' }); + } + if (!space.peers.has(tid)) { + return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' }); + } + const targetSock = io.sockets.sockets.get(tid); + if (targetSock) { + targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' }); + targetSock.leave(sid); + delete targetSock.data.spaceId; + } + space.peers.delete(tid); + io.to(sid).emit('user-left', { id: tid }); + cb && cb({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + /* ล็อก: 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; + } + } + }); + + 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(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + space.detectiveLobbyLevel = Number(lobbyLevel) || 1; + space.detectiveLobbyCaseId = Number(caseId) || 1; + space.caseId = Number(caseId) || 1; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space) + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + /* เก็บข้อมูลก่อนลบ peer — ใช้ทำ grace 10 วิ (รอ reconnect) + bot takeover ระหว่างมินิเกม */ + const leftPeerInfo = space.peers.get(socket.id) || {}; + const leftPlayerKey = (typeof leftPeerInfo.playerKey === 'string') ? leftPeerInfo.playerKey : ''; + const leftNick = (leftPeerInfo.nickname || '').trim() || 'ผู้เล่น'; + /* คนที่หลุดตาย/ตกรอบไปแล้วหรือยัง (ตอนหลุด) — ใช้ตัดสินว่า bot ที่มาแทนควร spawn ใหม่ไหม (คนตายแล้วไม่ควรฟื้น) */ + let leftWasEliminated = false; + try { + const md8 = (space.mapId && maps.get(space.mapId)) || space.mapData; + const gt8 = md8 && md8.gameType; + leftWasEliminated = minigamePlayerScoreSurvived(space, leftPeerInfo, gt8).survived === false; + } catch (e) { /* ignore */ } + /* สี (ธีม/สกิน) ของคนที่ออก — ใช้ให้ bot ที่มาแทนใช้สีเดียวกัน (b) */ + const leftThemeIndex = parseInt(leftPeerInfo.lobbyColorThemeIndex, 10); + const leftSkinIndex = parseInt(leftPeerInfo.lobbySkinToneIndex, 10); + /* MG1 (quiz live): หลุด → แปลง entity เดิม (socket.id) เป็นบอท server "ในเกมทันที" + เก็บ avatar เดิมไว้ = สี+ตำแหน่งเดิมอัตโนมัติ + inherit คะแนน/สถานะตกรอบ (ผีถ้าตอบผิด) */ + let mg1Takeover = false; + try { + const md9 = (space.mapId && maps.get(space.mapId)) || space.mapData; + const sessT = space.quizSession; + if (sessT && sessT.active && sessT.bots && md9 && md9.gameType === 'quiz') { + const st = (sessT.players && sessT.players[socket.id]) || {}; + const px = Number(leftPeerInfo.x), py = Number(leftPeerInfo.y); + sessT.bots.set(socket.id, { + tier: quizBotTierForSlot(sessT.bots.size), + x: Number.isFinite(px) ? px : 1, y: Number.isFinite(py) ? py : 1, + dir: leftPeerInfo.direction || 'down', isWalking: false, + score: Math.max(0, Number(st.score) || 0), + eliminated: !!(st.cannotTrue && st.cannotFalse), + choice: null, answerRight: false, tx: null, ty: null, wanderTX: null, wanderTY: null, retargetAt: 0, + takeover: true, takeoverPlayerKey: leftPlayerKey || '', + }); + mg1Takeover = true; + } + } catch (e) { /* ignore */ } + /* MG2 (gauntlet live): หลุดกลาง run → แปลง peer เป็นบอท server "ในที่เดิม" (ตำแหน่ง/คะแนน/สถานะเดิม) + เก็บ avatar เดิมไว้ + เล่นต่อเป็นบอท (host หลุดก็ไม่หยุด race) */ + let mg2Takeover = false; + try { + const md9b = (space.mapId && maps.get(space.mapId)) || space.mapData; + const grT = space.gauntletRun; + /* takeover ตราบที่ run ยังมีอยู่ — รวมช่วง 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 */ } + 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-mg4warp b/www/html/Game/server.js.bak-mg4warp new file mode 100644 index 0000000..ad47bae --- /dev/null +++ b/www/html/Game/server.js.bak-mg4warp @@ -0,0 +1,12750 @@ +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'); + +/* ===== Quiz Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ +const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json'); +/** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */ +let qbLeaderboardStore = {}; +function loadQbLeaderboard() { + try { + if (fs.existsSync(QB_LEADERBOARD_PATH)) { + const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8')); + qbLeaderboardStore = (j && typeof j === 'object') ? j : {}; + } + } catch (e) { qbLeaderboardStore = {}; } +} +function saveQbLeaderboard() { + try { + const dir = path.dirname(QB_LEADERBOARD_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8'); + } catch (e) { /* ignore */ } +} +function qbLeaderboardSubmit(roomId, name, score, characterId) { + roomId = String(roomId || '').trim(); + name = String(name || '').trim().slice(0, 24); + score = Math.max(0, Math.floor(Number(score) || 0)); + if (!roomId || !name) return; + if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = []; + const list = qbLeaderboardStore[roomId]; + const ex = list.find((e) => e && e.name === name); + if (ex) { + if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); } + } else { + list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() }); + } + list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts)); + if (list.length > 500) list.length = 500; + saveQbLeaderboard(); +} +function qbLeaderboardGet(roomId, name) { + roomId = String(roomId || '').trim(); + const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : []; + const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' })); + let me = null; + const nm = String(name || '').trim(); + if (nm) { + const idx = list.findIndex((e) => e && e.name === nm); + if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' }; + } + return { top, me, total: list.length }; +} +loadQbLeaderboard(); + +/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ +const os = require('os'); +let __prevCpuSample = null; +function __cpuSample() { + const cpus = os.cpus() || []; + let idle = 0, total = 0; + for (const c of cpus) { + for (const k in c.times) total += c.times[k]; + idle += c.times.idle; + } + return { idle, total }; +} +/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ +function __cpuPercent() { + const cur = __cpuSample(); + const prev = __prevCpuSample; + __prevCpuSample = cur; + if (!prev) return null; + const idleD = cur.idle - prev.idle; + const totalD = cur.total - prev.total; + if (totalD <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); +} +__cpuSample(); // เก็บ baseline แรก +function getDiskStats() { + try { + if (typeof fs.statfsSync !== 'function') return null; + const s = fs.statfsSync(__dirname); + const total = s.blocks * s.bsize; + const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด + const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ + const used = total - free; + if (!(total > 0)) return null; + return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; + } catch (e) { return null; } +} +/* จำนวนผู้เล่น/ห้อง/socket ออนไลน์ตอนนี้ — โชว์ในหน้า Admin เทียบกับเพดานความจุ */ +function getOnlineStats() { + let players = 0, rooms = 0; + try { + for (const [, sp] of spaces) { + if (sp && sp.peers) { players += sp.peers.size; rooms++; } + } + } catch (e) { /* ignore */ } + let sockets = 0; + try { sockets = (io && io.engine && io.engine.clientsCount) || 0; } catch (e) { /* ignore */ } + return { sockets, players, rooms }; +} + +function getSystemStats() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const cores = (os.cpus() || []).length || 1; + const cpuPct = __cpuPercent(); + const mu = process.memoryUsage(); + return { + ok: true, + cpu: { + percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) + cores, + load1: Math.round(load[0] * 100) / 100, + load5: Math.round(load[1] * 100) / 100, + load15: Math.round(load[2] * 100) / 100, + loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), + }, + mem: { + total: totalMem, + free: freeMem, + used: usedMem, + usedPercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDiskStats(), + node: { + rss: mu.rss, + heapUsed: mu.heapUsed, + rssPercent: Math.round((mu.rss / totalMem) * 100), + version: process.version, + uptime: Math.round(process.uptime()), + }, + uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, + /* ผู้ใช้ออนไลน์ + เพดานความจุ (ประเมินจาก 2 vCPU / socket fan-out): สบาย ≤300 · เริ่มหนัก ≥500 */ + online: getOnlineStats(), + capacity: { comfortable: 300, strain: 500 }, + hostname: os.hostname(), + platform: os.platform(), + ts: Date.now(), + }; +} + +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */ +const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5]; +const SPECIAL_QUIZ_CASES = [1, 2, 3]; +const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary']; +const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; + +function specialCardTxtFilename(id) { + return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`; +} + +function specialCardImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`; +} + +function specialCardTxtImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`; +} + +function specialQuizSetKey(level, caseId) { + return 'L' + level + '_C' + caseId; +} + +/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */ +function sanitizeSpecialQuizQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + const type = q.type === 'mcq' ? 'mcq' : 'tf'; + if (type === 'mcq') { + /* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */ + const choices = (Array.isArray(q.choices) ? q.choices : []) + .map((c) => String(c == null ? '' : c).trim()) + .slice(0, 8); + while (choices.length && choices[choices.length - 1] === '') choices.pop(); + if (choices.filter((c) => c).length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') { + ci = choices.findIndex((c) => c); + if (ci < 0) ci = 0; + } + return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci }; + } + return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue }; +} + +function sanitizeSpecialCard(c) { + const src = (c && typeof c === 'object') ? c : {}; + const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare'; + let cardId = Number(src.cardId); + if (!(cardId >= 1 && cardId <= 7)) cardId = null; + let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600); + let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600); + if (cardId) { + if (!imageUrl) imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } else if (imageUrl) { + const m = imageUrl.match(/\/card-(\d)\.png/i); + if (m) { + cardId = Number(m[1]); + imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } + } + return { + cardId: cardId || null, + th: String(src.th == null ? '' : src.th).trim().slice(0, 160), + en: String(src.en == null ? '' : src.en).trim().slice(0, 160), + rarity, + imageUrl, + txtImageUrl, + }; +} + +/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */ +function sanitizeSpecialQuizSets(obj) { + const out = {}; + SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => { + const key = specialQuizSetKey(L, C); + const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {}; + const questions = (Array.isArray(src.questions) ? src.questions : []) + .map(sanitizeSpecialQuizQuestion) + .filter(Boolean) + .slice(0, 50); + out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) }; + })); + return out; +} + +/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */ +function getSpecialQuizSet(settings, level, caseId) { + const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({}); + /* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่) + key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */ + const cid = Math.max(1, Math.min(15, Number(caseId) || 1)); + const L = Math.ceil(cid / 3); + const C = ((cid - 1) % 3) + 1; + return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; +} + +/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ +/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ +/** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */ +const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7']; +const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; +/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ +/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */ +const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; +/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน (roster คดี/LobbyB) + ⚠️ ห้ามตั้ง 0: เปลี่ยนหน้า lobbyB→play→lobbyB (socket หลุดชั่วคราว) จะถูกถอดจาก roster → rejoin ไม่ได้ ("ห้องนี้เริ่มคดีแล้ว") + หมายเหตุ: bot takeover "ในเกม MG1" (sess.bots) เป็นทันทีอยู่แล้ว แยกจาก grace นี้ */ +const DISCONNECT_GRACE_SEC = 10; + +/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */ +function normalizeTestSpecialCardByMap(v) { + const out = {}; + if (v && typeof v === 'object') { + SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => { + const c = Math.floor(Number(v[mid])); + if (c >= 1 && c <= 7) out[mid] = c; + }); + } + return out; +} +/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */ +const SPECIAL_QUIZ_ANSWER_MS = 20000; +/** หน่วงโชว์เฉลยก่อนข้อต่อไป */ +const SPECIAL_QUIZ_REVEAL_MS = 2600; + +/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */ +function specialQuizNormalizeQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + if (q.type === 'mcq') { + const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== ''); + if (choices.length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0; + return { text, choices, correctIndex: ci }; + } + return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 }; +} + +function specialQuizShuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function specialQuizTileWalkable(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const obj = md.objects && md.objects[y]; + if (obj && obj[x] === 1) return false; + /* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */ + const bp = md.blockPlayer && md.blockPlayer[y]; + if (bp && bp[x] === 1) return false; + return true; +} + +/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */ +function specialQuizTileInGameZone(md, x, y) { + if (quizCellOn(md.quizTrueArea || [], x, y)) return true; + if (quizCellOn(md.quizFalseArea || [], x, y)) return true; + if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true; + const oa = md.quizCarryOptionArea; + if (oa && oa[y] && oa[y][x]) return true; + return false; +} + +/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */ +function pickSpecialQuizSpawnTile(md, occupiedLaneCount) { + const w = md.width || 20; + const h = md.height || 15; + /* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer → + ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้ + → เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */ + if (md.gameType === 'quiz_carry') { + const nearMarkedTable = (x, y) => { + for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) { + if (!Array.isArray(g)) continue; + for (let dy = -2; dy <= 2; dy++) { + const gy = g[y + dy]; + if (!gy) continue; + for (let dx = -2; dx <= 2; dx++) { + if (gy[x + dx]) return true; + } + } + } + return false; + }; + const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length) + ? md.lobbyPlayerSpawns + : [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }]; + const qcCands = []; + seeds.forEach((s) => { + const bx = Math.floor(Number(s.x)); + const by = Math.floor(Number(s.y)); + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const dist = Math.abs(dx) + Math.abs(dy); + if (dist < 2 || dist > 6) continue; + const xx = bx + dx; + const yy = by + dy; + if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) { + qcCands.push({ x: xx, y: yy }); + } + } + } + }); + if (qcCands.length) { + const p = qcCands[Math.floor(Math.random() * qcCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns + (ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */ + if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) { + /* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */ + const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0) + ? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount)) + : md.gauntletPlayerSpawns.length; + const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes); + const rows = Array.from(new Set(usableSpawns + .map((s) => Math.floor(Number(s.y))) + .filter((n) => Number.isFinite(n) && n >= 0 && n < h))); + const laneCands = []; + const xMin = Math.max(4, Math.floor(w * 0.30)); + const xMax = Math.min(w - 2, Math.floor(w * 0.72)); + rows.forEach((ry) => { + for (let x = xMin; x <= xMax; x++) { + if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry }); + } + }); + if (laneCands.length) { + const p = laneCands[Math.floor(Math.random() * laneCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง + (ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */ + if (md.gameType === 'stack') { + const zc = []; + [md.stackReleaseArea, md.stackLandArea].forEach((g) => { + if (Array.isArray(g)) { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y }); + } + }); + if (zc.length) { + let cx2 = 0; + let cy2 = 0; + zc.forEach((c) => { cx2 += c.x; cy2 += c.y; }); + cx2 = Math.round(cx2 / zc.length); + cy2 = Math.round(cy2 / zc.length); + for (let rad = 0; rad <= 6; rad++) { + for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) { + for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 }; + } + } + } + /* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */ + return { x: cx2 + 0.5, y: cy2 + 0.5 }; + } + } + /* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack + (สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */ + if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) { + const painted = []; + const paintedWalkable = []; + for (let yy = 0; yy < h; yy++) { + const row = md.specialQuizSpawnArea[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (!row[xx]) continue; + painted.push({ x: xx, y: yy }); + if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy }); + } + } + const pickFrom = paintedWalkable.length ? paintedWalkable : painted; + if (pickFrom.length) { + const p = pickFrom[Math.floor(Math.random() * pickFrom.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* jump_survive (กระโดด mnptfts2): ผู้เล่นยืนบน "แพลตฟอร์ม" — พื้นที่ว่างนับ walkable ผิด (ลอยกลางอากาศเก็บไม่ได้) + ไอคอนคลิก/แตะเก็บได้ทุกมินิเกม (trySpecialQuizClickAt) → วางบนแพลตฟอร์มใกล้จุดเกิด (อยู่ในจอตั้งแต่ต้น คลิกได้) + = แก้ #44 อีเวนต์พิเศษ (ทนาย/เทพ) ไม่ขึ้นเพราะ BFS หา walkable ไม่เจอ → return null */ + if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) { + const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }; + const bx = Math.floor(Number(sp.x)); + const by = Math.floor(Number(sp.y)); + const plats = []; + for (let y = 0; y < h; y++) { + const row = md.jumpSurvivePlatformArea[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x]) plats.push({ x, y }); + } + } + if (plats.length) { + plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by))); + const near = plats.slice(0, Math.min(8, plats.length)); + const p = near[Math.floor(Math.random() * near.length)]; + const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */ + return { x: p.x + 0.5, y: iy + 0.5 }; + } + } + /* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้ + fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */ + let sx = -1; + let sy = -1; + const spawnGrid = md.spawnArea; + if (Array.isArray(spawnGrid)) { + for (let y = 0; y < h && sx < 0; y++) { + const row = spawnGrid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) { + const sp = md.spawn || { x: 1, y: 1 }; + const fx = Math.floor(Number(sp.x)); + const fy = Math.floor(Number(sp.y)); + if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; } + } + if (sx < 0) { + /* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก + เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */ + const ccx = Math.floor(w / 2); + const ccy = Math.floor(h / 2); + const maxRad = Math.max(w, h); + for (let rad = 0; rad <= maxRad && sx < 0; rad++) { + for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) { + for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; } + } + } + } + } + if (sx < 0) { + for (let y = 0; y < h && sx < 0; y++) { + for (let x = 0; x < w; x++) { + if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) return null; + const q = [[sx, sy]]; + const seen = new Set([sx + ',' + sy]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + const cands = []; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = nx + ',' + ny; + if (seen.has(k)) continue; + if (!specialQuizTileWalkable(md, nx, ny)) continue; + seen.add(k); + q.push([nx, ny]); + if (!specialQuizTileInGameZone(md, nx, ny)) { + const dist = Math.abs(nx - sx) + Math.abs(ny - sy); + if (dist >= 2) cands.push({ x: nx, y: ny }); + } + } + } + if (!cands.length) return null; + /* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ · + ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */ + const near = cands.filter((c) => { + const d = Math.abs(c.x - sx) + Math.abs(c.y - sy); + return d >= 2 && d <= 8; + }); + const pool = near.length ? near : cands; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x + 0.5, y: pick.y + 0.5 }; +} + +function clearSpecialQuizTimers(space) { + if (Array.isArray(space.specialQuizTimers)) { + space.specialQuizTimers.forEach((t) => clearTimeout(t)); + } + space.specialQuizTimers = []; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + space.specialQuizAwaitContinue = false; +} + +/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */ +function specialQuizIconPayload(space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done || !sq.appeared) return null; + return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 }; +} + +/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง (1 ใบต่อครั้งที่ติด — ดีไซน์ "1 ใน 3") + Test Mode (testSpecialCardByMap / forceSpecialQuiz) → ข้าม roll ขึ้นเสมอ */ +function maybeSpawnSpecialQuizForRun(space) { + clearSpecialQuizTimers(space); + space.specialQuiz = null; + space.specialCardAwardedThisRun = null; + space.timeDilationPendingSec = 0; + /* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้ + หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */ + if (!space || !space.detectiveMinigameActive) return; + const mapId = String(space.mapId || '').trim(); + if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return; + const level = Number(space.detectiveLobbyLevel) || 1; + const caseId = Number(space.detectiveLobbyCaseId) || 1; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, level, caseId); + const questions = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + let card = set && set.specialCard; + /* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */ + const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId]; + const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7); + if (testForcedCard) { + const def = specialCardDef(testCardId) || {}; + card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th }); + } + const hasCard = !!(card && (card.cardId || card.imageUrl)); + if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; } + const forced = !!space.forceSpecialQuiz || testForcedCard; + const roll = Math.random(); + if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; } + if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; } + /* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */ + const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); + const tile = pickSpecialQuizSpawnTile(md, occupiedLanes); + if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; } + const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)]; + console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y); + space.specialQuiz = { + iconType, + x: tile.x, + y: tile.y, + level, + caseId, + card, + triggered: false, + done: false, + session: null, + }; +} + +function emitSpecialQuizIcon(sid, space) { + const payload = specialQuizIconPayload(space); + if (payload) io.to(sid).emit('special-quiz-icon', payload); +} + +/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */ +const SPECIAL_QUIZ_ICON_DELAY_MS = 2000; +function scheduleSpecialQuizIconAppearAndExpiry(sid, space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done) return; + const tAppear = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return; + const cur = sp.specialQuiz; + const sec = readSpecialQuizIconExpireSec(); + cur.appeared = true; + cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0; + emitSpecialQuizIcon(sid, sp); + if (sec > 0) { + const tExpire = setTimeout(() => { + const sp2 = spaces.get(sid); + if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return; + sp2.specialQuiz = null; + io.to(sid).emit('special-quiz-icon-clear', {}); + console.log('[special-quiz] icon EXPIRED after', sec + 's'); + }, sec * 1000); + if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = []; + sp.specialQuizTimers.push(tExpire); + } + }, SPECIAL_QUIZ_ICON_DELAY_MS); + if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = []; + space.specialQuizTimers.push(tAppear); +} + +/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */ +function buildSpecialQuizRoster(space) { + const roster = []; + space.peers.forEach((p, id) => { + roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }); + }); + const botN = effectiveBotSlotCount(space); + for (let i = 0; i < botN; i++) { + roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true }); + } + return roster; +} + +function specialQuizRosterPayload(sess) { + return (sess && Array.isArray(sess.roster) ? sess.roster : []) + .map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot })); +} + +/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */ +function specialQuizExpectedIds(space, sess) { + const realAlive = new Set(space.peers.keys()); + const out = []; + (sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => { + if (m.isBot) out.push(m.id); + else if (realAlive.has(m.id)) out.push(m.id); + }); + return out; +} + +/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ +function maybeResolveSpecialQuizAllAnswered(sid, space) { + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return; + const expected = specialQuizExpectedIds(space, sess); + const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid])); + if (allAnswered) resolveSpecialQuizQuestion(sid, space, false); +} + +/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */ +function scheduleSpecialQuizBotAnswers(sid, space, sess, q) { + const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot); + if (!bots.length) return; + const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500); + bots.forEach((b) => { + const delay = 900 + Math.floor(Math.random() * windowMs); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session; + if (!spNow || sNow !== sess || sess.resolving) return; + if (Number.isInteger(sess.answers[b.id])) return; + sess.answers[b.id] = q.correctIndex; + io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true }); + maybeResolveSpecialQuizAllAnswered(sid, spNow); + }, delay); + space.specialQuizTimers.push(t); + }); +} + +function sendSpecialQuizQuestion(sid, space) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess) return; + const q = sess.questions[sess.qIndex]; + if (!q) { endSpecialQuizSession(sid, space, false); return; } + sess.answers = {}; + sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS; + io.to(sid).emit('special-quiz-question', { + index: sess.qIndex, + total: sess.questions.length, + text: q.text, + choices: q.choices, + endsAt: sess.phaseEndsAt, + iconType: sq.iconType, + roster: specialQuizRosterPayload(sess), + }); + scheduleSpecialQuizBotAnswers(sid, space, sess, q); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + resolveSpecialQuizQuestion(sid, spNow, true); + }, SPECIAL_QUIZ_ANSWER_MS + 80); + space.specialQuizTimers.push(t); +} + +/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */ +function resolveSpecialQuizQuestion(sid, space, fromTimeout) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess || sess.resolving) return; + sess.resolving = true; + clearSpecialQuizTimers(space); + const q = sess.questions[sess.qIndex]; + const expected = specialQuizExpectedIds(space, sess); + let allCorrect = expected.length > 0; + expected.forEach((pid) => { + const a = sess.answers[pid]; + if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false; + }); + io.to(sid).emit('special-quiz-result', { + index: sess.qIndex, + correctIndex: q.correctIndex, + answers: { ...sess.answers }, + roster: specialQuizRosterPayload(sess), + allCorrect, + timeout: !!fromTimeout, + }); + if (!allCorrect) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, false); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, true); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + sess.resolving = false; + sendSpecialQuizQuestion(sid, spNow); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); +} + +/** + * นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน: + * minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation) + * now = ใช้ทันทีตอนได้รับ (Fund coins) + * after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence) + * pre_trial = ก่อนโหวตพิจารณาคดี (Silence) + * trial_vote = ระหว่างโหวตพิจารณาคดี (Extension) + * trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin) + * lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban) + */ +const SPECIAL_CARD_DEFS = { + 1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' }, + 2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' }, + 3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' }, + 4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' }, + 5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' }, + 6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' }, + 7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, +}; + +function specialCardDef(cardId) { + return SPECIAL_CARD_DEFS[Number(cardId)] || null; +} + +/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */ +function specialCardClientPayload(card) { + if (!card) return null; + const def = specialCardDef(card.cardId) || {}; + return { + cardId: card.cardId || null, + th: card.th || def.th || '', + en: card.en || '', + rarity: card.rarity || 'common', + imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''), + txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''), + effectKey: def.key || '', + effectWhen: def.when || '', + addSec: def.addSec || 0, + desc: def.th || '', + }; +} + +/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */ +const TIME_DILATION_MAX_PENDING_SEC = 30; + +/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */ +function applySpecialCardImmediate(sid, space, card) { + const def = specialCardDef(card && card.cardId); + if (!def) return; + if (def.when === 'minigame' && def.key === 'time_dilation') { + /* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง + cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */ + space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10)); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + addSec: def.addSec || 10, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + if (def.when === 'now' && def.key === 'fund') { + /* +COINS — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม + noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */ + const coins = def.coins || 10; + const awards = [...space.peers.values()] + .filter((p) => p.playerKey) + .map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true })); + submitGameAwards(awards); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + coins: coins, + coinsServerAwarded: true, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) + — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ +} + +/* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */ +function dbgCards(space) { + return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : ''))); +} +function pendingSpecialCardList(space) { + if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = []; + return space.pendingSpecialCards; +} +/** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */ +function addPendingSpecialCard(space, card) { + if (!card || !card.cardId) return null; + const list = pendingSpecialCardList(space); + const cid = Number(card.cardId); + if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; } + const entry = { cardId: cid, card: card, consumed: false }; + list.push(entry); + console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space)); + return entry; +} +/** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */ +function findPendingSpecialCard(space, cardId) { + const cid = Number(cardId); + return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null; +} +function consumePendingSpecialCard(space, cardId) { + const e = findPendingSpecialCard(space, cardId); + if (e) e.consumed = true; +} + +function endSpecialQuizSession(sid, space, success) { + const sq = space.specialQuiz; + if (!sq) return; + clearSpecialQuizTimers(space); + sq.done = true; + sq.session = null; + let cardOut = null; + console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId)); + if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { + space.specialCardAwardedThisRun = sq.card; + cardOut = specialCardClientPayload(sq.card); + addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */ + applySpecialCardImmediate(sid, space, sq.card); + } + io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); + /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) + — มี safety timeout กันค้างถ้าไม่มีใครกด */ + space.specialQuizAwaitContinue = true; + space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */ + /* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */ + io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size }); + if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = setTimeout(() => { + space.specialQuizContinueTimer = null; + finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */ + }, 32000); +} + +/** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */ +function maybeFinishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set()); + const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */ + const ready = expected.filter((pid) => acks.has(pid)).length; + io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length }); + if (expected.length === 0 || expected.every((pid) => acks.has(pid))) { + finishSpecialQuizContinue(sid, space); + } +} + +/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ +function finishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + space.specialQuizAwaitContinue = false; + space.specialQuizContinueAcks = null; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + /* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */ + io.to(sid).emit('special-quiz-resume-all', {}); + resumeQuizAfterSpecialQuiz(sid, space); +} + +/* เกมหยุดชั่วคราวระหว่างคำถามพิเศษ (กำลังตอบ หรือรอกด «เล่นต่อ») + ใช้ร่วมกันทุก minigame ฝั่ง server — เดิมมีแต่ gauntlet เช็ค ทำให้ MG6/MG7 บอทยังเดิน/ยิงตอน pause */ +function isSpecialQuizPausedServer(space) { + if (!space) return false; + return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)); +} + +function startSpecialQuizSession(sid, space) { + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return false; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, sq.level, sq.caseId); + const all = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + if (!all.length) return false; + /* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */ + const picked = specialQuizShuffle(all).slice(0, 1); + sq.triggered = true; + sq.session = { + questions: picked, + qIndex: 0, + answers: {}, + phaseEndsAt: 0, + resolving: false, + roster: buildSpecialQuizRoster(space), + }; + clearSpecialQuizTimers(space); + pauseQuizForSpecialQuiz(sid, space); + io.to(sid).emit('special-quiz-icon-clear', {}); + sendSpecialQuizQuestion(sid, space); + return true; +} + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 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, + }; +} + +/** อ่านเวลา 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, + }; + } + } 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), + /* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */ + lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud') + ? !!d.lobbyDebugHud + : !!prev.lobbyDebugHud, + lobbyDebugHudNames: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHudNames') + ? (Array.isArray(d.lobbyDebugHudNames) ? d.lobbyDebugHudNames.slice(0, 30).map((s) => String(s).slice(0, 40)) : []) + : (Array.isArray(prev.lobbyDebugHudNames) ? prev.lobbyDebugHudNames : []), + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const specialQuizSets = d.specialQuizSets != null + ? sanitizeSpecialQuizSets(d.specialQuizSets) + : sanitizeSpecialQuizSets(prev.specialQuizSets); + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + specialQuizSets, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + specialQuizSets: out.specialQuizSets, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + let cw = Math.floor(Number(md.characterCellsW)); + let ch = Math.floor(Number(md.characterCellsH)); + const hasW = Number.isFinite(cw) && cw >= 1; + const hasH = Number.isFinite(ch) && ch >= 1; + if (hasW && hasH) { + return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; + } + const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); + return { cw: leg, ch: leg }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(md); + let colW = Math.floor(Number(md.characterCollisionW)); + let colH = Math.floor(Number(md.characterCollisionH)); + if (!Number.isFinite(colW) || colW < 1) colW = cw; + if (!Number.isFinite(colH) || colH < 1) colH = ch; + colW = Math.max(1, Math.min(cw, colW)); + colH = Math.max(1, Math.min(ch, colH)); + return { cw, ch, colW, colH }; +} + +const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; +const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; + +function quizBattleSpriteBoundsTileKeys(md, px, py) { + const out = []; + if (!md || md.gameType !== 'quiz_battle') return out; + const x = Number(px), y = Number(py); + if (!Number.isFinite(x) || !Number.isFinite(y)) return out; + const w = md.width || 20, h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const cx = x + cw * 0.5; + const feetY = y + ch; + const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; + const bottom = feetY; + if (left < 0 || right > w || top < 0 || bottom > h) return out; + const minTx = Math.floor(left); + const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); + const minTy = Math.floor(top); + const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`); + } + return out; +} + +function serverWallCollisionTileKeys(md, px, py) { + if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(md, px, py); + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + for (let ty = offY; ty < offY + colH; ty++) { + for (let tx = offX; tx < offX + colW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; +} + +function validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */ +function pauseQuizForSpecialQuiz(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.active || space.quizFrozenForSpecial) return; + const phase = sess.phase; + if (phase !== 'read' && phase !== 'answer') return; + const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now()); + space.quizFrozenForSpecial = true; + space.quizFreezePhase = phase; + space.quizFreezeRemainingMs = remaining; + clearSpaceQuizTimers(space); + io.to(sid).emit('quiz-paused', { phase }); +} + +/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */ +function resumeQuizAfterSpecialQuiz(sid, space) { + if (!space.quizFrozenForSpecial) return; + space.quizFrozenForSpecial = false; + const sess = space.quizSession; + const phase = space.quizFreezePhase; + const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0); + space.quizFreezePhase = null; + space.quizFreezeRemainingMs = 0; + if (!sess || !sess.active) return; + const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space); + if (!md) return; + /* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */ + let extraMs = 0; + if (space.timeDilationPendingSec > 0) { + extraMs = space.timeDilationPendingSec * 1000; + space.timeDilationPendingSec = 0; + } + sess.phaseEndsAt = Date.now() + remaining + extraMs; + if (phase === 'read') { + sess.phase = 'read'; + const q = sess.questions[sess.qIndex]; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + text: String((q && q.text) || '').trim(), + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining); + space.quizTimers.push(t); + } else { + sess.phase = 'answer'; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining); + space.quizTimers.push(t); + } +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function ensurePeerLobbyThemes(space) { + if (!space || !space.peers) return; + const seen = new Set(); + space.peers.forEach((p, id) => { + let ti = parseInt(p.lobbyColorThemeIndex, 10); + if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) { + ti = pickFreeLobbyThemeIndex(space, id); + p.lobbyColorThemeIndex = ti; + } + seen.add(ti); + if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) { + p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0); + } + }); +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null, + lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, + lobbySkinToneIndex: p.lobbySkinToneIndex || null, + bannedSpectator: !!p.bannedSpectator, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ + → client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client + เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */ +/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */ +function isBannedPeerForRun(space, id) { + if (!space) return false; + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true; + const pp = space.peers && space.peers.get(id); + return !!(pp && pp.bannedSpectator); +} + +/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */ +function readyMapWithoutBanned(space, srcMap) { + const out = {}; + if (srcMap && typeof srcMap === 'object') { + Object.keys(srcMap).forEach((id) => { + if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id]; + }); + } + return out; +} + +function quizCarryLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady), + peers: buildPeersSnapForSpace(space), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + }; +} + +/* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน + แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */ +function crownLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + peers: buildPeersSnapForSpace(space), + }; +} + +/* เช็คว่าพร้อมเริ่มมินิเกม crown-lobby ไหม: ยังไม่เริ่ม + เข้าครบ + ทุกคน (ไม่นับ banned) กด Ready */ +function crownLobbyAllReadyToStart(space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)); + if (humanIds.length === 0) return false; + const expectedActive = space.minigameExpectedActiveCount || 0; + if (expectedActive > 0 && humanIds.length < expectedActive) return false; + return humanIds.every((id) => rm[id]); +} + +/* เริ่มมินิเกม crown-lobby (server เป็นคนสั่ง) — idempotent ด้วย gr.lobbyStarted (รีเซ็ตเองทุกมินิเกมเพราะ gr สร้างใหม่) + ใช้ทั้ง host กด START และ auto-start เมื่อทุกคน ready (Track B: MG1 ไม่ต้องพึ่ง host) */ +function performCrownLobbyStart(sid, space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + gr.lobbyStarted = true; + const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; + space.missionLiveBeginsAt = liveBeginsAt; + if (isQuizQuestionMissionShellSpace(space)) { + /* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ตรงกับตอน client จบ 3-2-1) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'quiz') { + clearSpaceQuizTimers(sp); + startQuizGame(sid, sp, md2); + } + }, LIVE_COUNTDOWN_MS); + } + if (isStackTowerMissionShellSpace(space)) { + /* MG3 stack: เริ่ม server engine ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'stack') startStackTicker(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isJumpSurviveMissionShellSpace(space)) { + /* MG5 jump_survive: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'jump_survive') startJumpSurviveGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isSpaceShooterMissionShellSpace(space)) { + /* MG6 space_shooter: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'space_shooter') startSpaceShooterGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isBalloonBossMissionShellSpace(space)) { + /* MG7 balloon_boss: เริ่ม server engine (บอท+รอบ+HP บอส) ตอน client จบ countdown */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'balloon_boss') startBalloonBossGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt }); + return true; +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +/* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด + ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */ +function detectiveLobbyBMissingParticipants(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 }; + const present = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) present.add(n); + }); + const total = roster.size; + return { missing: Math.max(0, total - present.size), present: present.size, total }; +} + +/* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */ +function emitDetectiveLobbyBPresence(sid, space) { + if (!sid || !space) return; + if (!serverMapIsPostCaseLobbyB(space)) return; + const info = detectiveLobbyBMissingParticipants(space); + if (info.total <= 0) return; + io.to(sid).emit('detective-lobbyb-presence', { + present: info.present, + total: info.total, + allHere: info.missing === 0, + }); +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + /** โหมดเทสต์ (admin game-timing): เลือกเกมต่อการ์ด 3 ใบ — ช่องที่ตั้ง key = เจาะจง, ช่องว่าง = สุ่ม */ + const forcedKeys = normalizeForcedMinigameKeys(runtimeGameTiming && runtimeGameTiming.forcedMinigameKeys); + const randomPool = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + let ri = 0; + const pick3 = []; + for (let i = 0; i < 3; i++) { + const fk = forcedKeys[i]; + let entry = fk ? available.find((e) => e.key === fk) : null; + if (!entry) { entry = randomPool[ri % randomPool.length]; ri += 1; } + pick3.push(entry); + } + space.suspectCardMinigames = pick3.map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); + // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ + space.playerEvidence = {}; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; +} + +function ensureSpacePlayerEvidence(space) { + if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {}; + if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {}; +} + +/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */ +function normalizeEvidenceSlotList(cards) { + if (!Array.isArray(cards)) return []; + const out = []; + for (let i = 0; i < cards.length; i++) { + const c = sanitizeStoredEvidenceCard(cards[i]); + if (c) out.push(c); + } + return out; +} + +/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */ +function peerNickKey(space, playerId) { + if (!space || !playerId) return ''; + const p = space.peers && space.peers.get ? space.peers.get(playerId) : null; + if (!p || !p.nickname) return ''; + return normalizeLobbyNickname(p.nickname); +} + +/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */ +function clonePlayerEvidence(space, playerId) { + ensureSpacePlayerEvidence(space); + let row = space.playerEvidence[playerId]; + if (!row || !row.length) { + const nk = peerNickKey(space, playerId); + if (nk && space.evidenceByNick[nk]) { + row = space.evidenceByNick[nk]; + space.playerEvidence[playerId] = row; + } + } + if (!row) row = [[], [], []]; + return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i])); +} + +/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */ +function syncEvidenceByNick(space, playerId) { + ensureSpacePlayerEvidence(space); + const nk = peerNickKey(space, playerId); + if (!nk) return; + const row = space.playerEvidence[playerId]; + if (Array.isArray(row)) space.evidenceByNick[nk] = row; +} + +/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */ +function playerSuspectProgressFromEvidence(space, playerId) { + const row = clonePlayerEvidence(space, playerId); + return row.map((arr) => arr.length); +} + +/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */ +function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) { + if (suspectIndex < 0 || suspectIndex > 2) return null; + ensureSpacePlayerEvidence(space); + /* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */ + if (!space.playerEvidence[playerId]) { + const nk = peerNickKey(space, playerId); + space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk]) + ? space.evidenceByNick[nk] + : [[], [], []]; + } + const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]); + /* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */ + if (list.length >= 3) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + const card = pickEvidenceCardForSuspect(space, suspectIndex, grade); + if (!card) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + list.push(card); + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return card; +} + +function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) { + const out = {}; + (playerIds || []).forEach((pid) => { + if (!space.peers.has(pid)) return; + const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade); + if (card != null) out[pid] = card; + }); + return out; +} + +function resolveDetectiveSuspectAwardIndex(space) { + if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) { + return space.suspectActiveIndex; + } + if (typeof space.lastDetectiveInvestigatedSuspect === 'number' + && space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) { + return space.lastDetectiveInvestigatedSuspect; + } + if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) { + return space.suspectPickIndex; + } + return -1; +} + +/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */ +function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { + const suspectIdx = resolveDetectiveSuspectAwardIndex(space); + if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} }; + if (space.detectiveAwardDoneForRun) { + return { suspectIdx, awarded: {}, alreadyGranted: true }; + } + const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()]; + const grade = computeRunGradeForEvidence(space); + const awardedList = {}; + ids.forEach((pid) => { awardedList[pid] = []; }); + const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade); + ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); }); + /* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */ + let freeEvidenceCount = 0; + const pend = findPendingSpecialCard(space, 7); + if (pend) { + const def = specialCardDef(7); + freeEvidenceCount = (def && def.evidence) || 1; + for (let n = 0; n < freeEvidenceCount; n++) { + ids.forEach((pid) => { + if (!space.peers.has(pid)) return; + const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade); + if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; } + }); + } + pend.consumed = true; + } + space.detectiveAwardDoneForRun = true; + space.lastDetectiveInvestigatedSuspect = suspectIdx; + space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount }; + emitLobbyEvidenceSync(sid, space); + return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount }; +} + +function emitLobbyEvidenceSync(sid, space) { + if (!sid || !space) return; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('lobby-evidence-sync', { + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) { + const msg = message || 'จบมินิเกม — กลับ LobbyB'; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + const personal = { + ...basePayload, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }; + sock.emit('quiz-ended', { message: msg, returnToLobbyB: true }); + sock.emit('detective-minigame-ended', personal); + sock.emit('game-start', personal); + }); +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveAwardDoneForRun = false; + space.coinAwardDoneForRun = false; + space.lastCrownMissionGrade = null; + /* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */ + space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; }); + /* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */ + space.lastMinigameOver = null; + /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ + space.bannedThisRunPlayerId = space.bannedPlayerId || null; + console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId); + space.bannedPlayerId = null; + /* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน) + สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */ + space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + glog(sid, space, 'game-start', 'เริ่มมินิเกม: ' + ((md && md.name) || mapId) + ' (การ์ด ' + ((selectedIndex != null ? selectedIndex + 1 : '?')) + ')'); + /* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */ + maybeSpawnSpecialQuizForRun(space); + /* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */ + + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */ + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + /** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */ + const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID; + if (!isQuizMissionShell) { + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + /* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */ + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + /* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client + กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */ + if (md.gameType === 'quiz_carry') { + space.quizCarryLobbyReady = {}; + space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; }); + io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) { + ensurePostCaseLobbyMapLoaded(); + /* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */ + awardMinigameRankCoins(sid, space); + clearSpaceQuizTimers(space); + clearSpecialQuizTimers(space); + space.specialQuiz = null; + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + /* Last Light (MG2): เก็บเกรด mission (รวมบอท server-authoritative) ก่อนล้าง gauntletRun + → ใช้เป็น "แหล่งเดียว" ของเกรดหลักฐาน ให้ตรงกับสรุปภารกิจที่ผู้เล่นเห็น */ + if (space.gauntletRun && isGauntletCrownHeistMapBySpace(space)) { + try { space.lastCrownMissionGrade = buildGauntletCrownMissionPayload(space).grade; } + catch (_e) { space.lastCrownMissionGrade = null; } + } + stopGauntletTicker(space); + space.gauntletRun = null; + /* MG3 stack (server-auth): หยุด engine + ล้าง session (เกรดถูกเก็บไว้ใน lastStackMissionGrade ตอนจบแล้ว) */ + stopStackTicker(space); + if (space.stackSession) space.stackSession.active = false; + space.stackSession = null; + /* MG4 quiz_carry (server-auth): ล้าง session (คะแนนถูกอ่านไปแจกเหรียญใน awardMinigameRankCoins ด้านบนแล้ว) */ + if (space.quizCarrySession) space.quizCarrySession.active = false; + space.quizCarrySession = null; + 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; + let awardIds = []; + if (awardOptions && awardOptions.allPlayers) { + awardIds = [...space.peers.keys()]; + } else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) { + awardIds = [awardOptions.playerId]; + } else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) { + awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id)); + } + /* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */ + awardIds = awardIds.filter((id) => { + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false; + const pp = space.peers.get(id); + if (pp && pp.bannedSpectator) return false; + return true; + }); + /* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */ + if (!space.detectiveAwardDoneForRun && awardIds.length) { + /* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */ + grantDetectiveEvidenceForCurrentRun(sid, space, awardIds); + } + } + space.suspectActiveIndex = null; + space.bannedThisRunPlayerId = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const basePayload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + detectiveReturn: true, + }; + emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload); + /* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ" + ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */ + const banPend = findPendingSpecialCard(space, 3); + console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space)); + if (banPend && !space.pickVote) { + banPend.consumed = true; + /* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */ + space.pendingBanVoteCard = banPend.card; + } +} + +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: trialEligibleVoterTotal(space), + }); +} + +function computeAndEmitTrialResult(sid, space) { + /* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */ + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + /* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */ + { + /* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */ + const forceWrong = !!space.testForceWrongVote; + space.testForceWrongVote = false; + const c2 = [0, 0, 0]; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; }); + let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; } + const anyVotes = (c2[0] + c2[1] + c2[2]) > 0; + const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit); + const pend = findPendingSpecialCard(space, 5); + if (wrong && pend && !space.trialBailUsed) { + pend.consumed = true; + space.trialBailUsed = true; + space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */ + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); + openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted }); + return; + } + } + space.trialPhase = 'revealed'; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + if (p && p.nickname) return p.nickname; + if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) { + const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10); + return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท'; + } + return String(id).slice(0, 6); + }); + /* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */ + let disruptorId = null; + if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) { + for (const [pid, p] of space.peers) { + const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey; + const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname; + if (keyMatch || nickMatch) { disruptorId = pid; break; } + } + } + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + disruptorId, + hasDisruptor: !!disruptorId, + }); + + /* ===== Achievement auto-track ===== */ + try { + const achv = []; + /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */ + winners.forEach((id) => { + const p = space.peers.get(id); + if (p && p.playerKey) { + achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 }); + achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 }); + } + }); + /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */ + if (disruptorId && space.disruptorPlayerKey) { + const dKey = space.disruptorPlayerKey; + achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */ + /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */ + let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; } + const anyV = (counts[0] + counts[1] + counts[2]) > 0; + if (anyV && mv !== culprit) { + achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */ + } + } + submitAchievementProgress(achv); + } catch (e) { console.error('[achv] trial-result', e && e.message); } + + space.silencedPlayerId = null; +} + +/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ +/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */ +const TESTIMONY_BOT_PREFIX = '__lobby_bot_'; + +function testimonyBotIds(space) { + const n = effectiveBotSlotCount(space); + const out = []; + for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i); + return out; +} + +function buildTestimonyMembers(space) { + const arr = []; + space.peers.forEach((p, id) => { + arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false }); + }); + testimonyBotIds(space).forEach((bid, i) => { + arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true }); + }); + return arr; +} + +/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */ +function testimonyTotalParticipants(space) { + return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); +} + +/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */ +function botTestimonyPickCount(space) { + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + let maxOwned = 0; + space.peers.forEach((_p, pid) => { + const ev = clonePlayerEvidence(space, pid); + const len = Array.isArray(ev[round]) ? ev[round].length : 0; + if (len > maxOwned) maxOwned = len; + }); + /* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */ + if (maxOwned === 0) return 1; + return Math.max(1, Math.min(2, maxOwned)); +} + +/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */ +function autoSubmitBotTestimonyPicks(space) { + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + const want = botTestimonyPickCount(space); + testimonyBotIds(space).forEach((bid) => { + if (space.testimonySubmitted[bid] != null) return; + const picks = []; + for (let n = 0; n < want; n++) { + const c = pickEvidenceCardForSuspect(space, round, 'C'); + if (c) picks.push(c); + } + space.testimonySubmitted[bid] = picks; + }); +} + +function emitTestimonyOpen(sid, space) { + space.testimonySubmitted = {}; + space.testimonyReadyNext = {}; + space.testimonyRevealed = false; + /* บอทส่งหลักฐานทันทีเมื่อเปิด round */ + autoSubmitBotTestimonyPicks(space); + const submittedIds = Object.keys(space.testimonySubmitted || {}); + const base = { + round: space.testimonyRound, + suspectIndex: space.testimonyRound, + members: buildTestimonyMembers(space), + totalPlayers: testimonyTotalParticipants(space), + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + submitted: submittedIds, + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('testimony-open', { + ...base, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + /* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */ + emitTestimonyStatus(sid, space); +} + +function emitTestimonyStatus(sid, space) { + io.to(sid).emit('testimony-status', { + round: space.testimonyRound, + submitted: Object.keys(space.testimonySubmitted || {}), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +function doTestimonyReveal(sid, space) { + space.testimonyRevealed = true; + /* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */ + autoSubmitBotTestimonyPicks(space); + /* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + io.to(sid).emit('testimony-reveal', { + round: space.testimonyRound, + picks: space.testimonySubmitted || {}, + members: buildTestimonyMembers(space), + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */ +/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */ +/* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */ +function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; } +const TRIAL_VOTE_EXTENSION_MS = 10000; + +function clearTrialVoteTimer(space) { + if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; } +} + +/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */ +function openTrialVotingPhase(sid, space, opts) { + opts = opts || {}; + space.testimonyActive = false; + space.trialPhase = 'voting'; + space.trialVotes = {}; + /* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */ + const excludedSuspect = opts.revote + ? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect + : (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null)) + : null; + if (!opts.revote) space.trialRevoteExcluded = null; + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ + let extraMs = 0; + let extensionCard = false; + const pend = findPendingSpecialCard(space, 2); + if (pend) { + extraMs = TRIAL_VOTE_EXTENSION_MS; + extensionCard = true; + pend.consumed = true; + } + const durMs = trialVoteMs() + extraMs; + space.trialVoteEndsAt = Date.now() + durMs; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + voteEndsAt: space.trialVoteEndsAt, + voteDurationMs: durMs, + extensionCard, + extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0, + revote: !!opts.revote, + excludedSuspect: excludedSuspect, + silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId), + }); + }); + if (extensionCard) { + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(pend.card), + addSec: TRIAL_VOTE_EXTENSION_MS / 1000, + context: 'trial', + }); + } + emitTrialVoteUpdate(sid, space); + space.trialVoteTimer = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.trialPhase !== 'voting') return; + computeAndEmitTrialResult(sid, spNow); + }, durMs + 120); +} + +/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */ +/* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */ +function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; } + +function clearPickVoteTimer(space) { + if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; } +} + +function pickVoteCandidates(space) { + const arr = []; + space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false })); + testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true })); + return arr; +} + +function autoVotePickBots(space) { + const pv = space.pickVote; + if (!pv) return; + testimonyBotIds(space).forEach((bid) => { + if (pv.votes[bid] != null) return; + const choices = pv.candidates.filter((c) => c.id !== bid); + if (!choices.length) return; + pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id; + }); +} + +function emitPickVoteUpdate(sid, space) { + const pv = space.pickVote; + if (!pv) return; + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + io.to(sid).emit('player-pick-vote-update', { + voted: Object.keys(pv.votes).length, + total: pv.candidates.length, + counts, + }); +} + +function startPlayerPickVote(sid, space, purpose, onResolve) { + clearPickVoteTimer(space); + const PV_MS = pickVoteMs(); + const candidates = pickVoteCandidates(space); + space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_MS, resolved: false, onResolve }; + autoVotePickBots(space); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('player-pick-vote-open', { + purpose, + candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: space.pickVote.endsAt, + durationMs: PV_MS, + }); + }); + emitPickVoteUpdate(sid, space); + space.pickVoteTimer = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.pickVote || sp.pickVote.resolved) return; + resolvePlayerPickVote(sid, sp); + }, PV_MS + 120); + maybeResolvePickVote(sid, space); +} + +function maybeResolvePickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + const ids = pv.candidates.map((c) => c.id); + if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space); +} + +function resolvePlayerPickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + pv.resolved = true; + clearPickVoteTimer(space); + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + let max = 0; + Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); + const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); + /* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */ + const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : null; + const tc = pv.candidates.find((c) => c.id === target); + const targetName = tc ? tc.nickname : ''; + io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts }); + const cb = pv.onResolve; + space.pickVote = null; + if (typeof cb === 'function') cb(target, targetName); +} + +function trialSilencedIsParticipant(space) { + const s = space.silencedPlayerId; + if (!s) return false; + if (space.peers.has(s)) return true; + return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0); +} + +function trialEligibleVoterTotal(space) { + let t = testimonyTotalParticipants(space); + if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1); + return t; +} + +function autoVoteBotsTrial(space) { + if (!space.trialVotes) space.trialVotes = {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3); + const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1; + testimonyBotIds(space).forEach((bid) => { + if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */ + if (space.trialVotes[bid] != null) return; + /* 40% โหวตถูก, 60% โหวตสุ่ม */ + let pick; + if (Math.random() < 0.4) { + pick = culprit; + } else { + pick = Math.floor(Math.random() * 3); + } + /* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */ + if (pick === excluded) pick = culprit; + space.trialVotes[bid] = pick; + }); +} + +function advanceTestimony(sid, space) { + space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; + if (space.testimonyRound >= 3) { + space.trialBailUsed = false; + space.silencedPlayerId = null; + /* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */ + const pend = findPendingSpecialCard(space, 4); + console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space)); + if (pend) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' }); + startPlayerPickVote(sid, space, 'silence', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.silencedPlayerId = targetId || null; + openTrialVotingPhase(sid, sp, { revote: false }); + }); + return; + } + openTrialVotingPhase(sid, space, { revote: false }); + } else { + emitTestimonyOpen(sid, space); + } +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + /* คะแนนสุดท้าย authoritative (ผู้เล่นจริง) — ส่งไปกับ quiz-ended ให้ทุก client ตัดสินผล complete/gameover ตรงกัน + (เดิมไม่ส่ง → client อ่าน playLiveQuizScores local ที่ quiz-result ข้อสุดท้ายอาจมาไม่ทัน → ผล 2 จอไม่ตรง) */ + const quizFinalScores = {}; + const sessF = space.quizSession; + if (sessF && sessF.players) { + Object.keys(sessF.players).forEach((pid) => { + const st = sessF.players[pid]; + quizFinalScores[pid] = (st && typeof st.score === 'number') ? Math.max(0, st.score) : 0; + }); + } + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) + เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" + การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true, scores: quizFinalScores }); + /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ + (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', scores: quizFinalScores }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + /* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */ + const qCur = sess.questions[sess.qIndex]; + if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + /* บอท: server เป็นเจ้าของคะแนน (จากคำตอบที่ตัดสินไว้) + คัดออกถ้าผิด (กฎเดียวกับคน "ตอบผิดถูกคัดออก") */ + if (sess.bots) { + sess.bots.forEach((b, botId) => { + if (!b.eliminated) { + if (b.answerRight) { + b.score += QUIZ_TF_POINTS_PER_CORRECT; + } else { + b.eliminated = true; + /* คัดออก → วาร์ปเข้าโซนผีทันที (เหมือนคนจริง) */ + const gc = quizRandomCellInZone(mdLive.quizGhostArea, mdLive); + if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; } + b.wanderTX = null; b.retargetAt = 0; + } + } + scores[botId] = b.score; + results.push({ + id: botId, nickname: 'บอท', right: !!b.answerRight, choice: b.choice, + eliminated: !!b.eliminated, score: b.score, + }); + b.tx = null; b.ty = null; /* รีเซ็ตเป้า → เดินมั่วจนข้อถัดไป */ + }); + } + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + /** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */ + const playFullQuizRound = + !!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space); + if (allWrong && !playFullQuizRound) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + space.quizSession.bots = createQuizBots(space, md); + scheduleQuizReadPhase(sid, space, md); +} + +/* ===== MG1 quiz bots — server-authoritative (roster + เดิน + ตอบ + คะแนน) ===== + เดิม host เดิน+คิดคะแนนบอทเอง → host หลุด = บอทหยุด + คะแนนเพี้ยน. ย้ายมา server → บอทเดินเอง time-based + คะแนน server เป็นเจ้าของ + id `__pv_bot_` 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; + try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId || '-') + ' 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, + 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) ได้ไหม: เดินได้ (ไม่ใช่กำแพง objects) + ไม่ทับ hub + ไม่ทับโต๊ะ submit กลาง (interactive) + → บอทไม่เดินทะลุ "โต๊ะตรงกลาง" อีก (เดิมเช็คแค่ hub+objects ทำให้ทะลุโต๊ะ interactive ที่ไม่ได้อยู่ใน objects) */ + const canStepTo = (x, y) => quizTileWalkableServer(md, x, y) + && !quizCarryFootprintOverlapsHubServer(md, x, y) + && !quizCarryFootprintOverlapsInteractiveServer(md, x, 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) => { + /* ทับ hub / โต๊ะ submit กลาง (interactive) / หรือ "ติดในคอนโซลกลาง" (ยืนได้แต่เดินออกไม่ได้) → ดันออกไปช่องว่างที่ถึงได้ */ + 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 t = tiles[(b.x | 0) % tiles.length]; b.x = t.x + 0.5; b.y = t.y + 0.5; b.botPath = []; } + } + /* ยังไม่มีเส้นทาง → ลองหยิบ/ส่งที่จุดยืนก่อน */ + if (!b.botPath || !b.botPath.length) quizCarryServerTryInteract(sid, space, bid, true); + if (!answering || !s.current) { wander(b); return; } + if (b.botPath && b.botPath.length) { followPath(b); return; } + if (now < (b.pathfindAfter || 0)) { wander(b); return; } + const tier = b.tier || 'avg'; + const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65; + if (b.held == null) { + /* เลือกข้อจะหยิบ (เลี่ยงข้อที่มีคนถือ) */ + let want = Math.random() < pCorrect ? s.current.correctIndex : Math.floor(Math.random() * Math.max(2, choiceCount)); + if (quizCarryOptionHeldByAnyoneServer(s, want, bid)) { + for (let i = 0; i < choiceCount; i++) { if (!quizCarryOptionHeldByAnyoneServer(s, i, bid)) { want = i; break; } } + } + const dest = quizCarryFindOptionTileServer(md, want, b); + if (dest) { b.botPath = quizCarryPathfindServer(md, b.x, b.y, dest.x + 0.5, dest.y + 0.5); b.wantIdx = want; } + b.pathfindAfter = now + 160 + Math.floor(Math.random() * 200); + if (!b.botPath || !b.botPath.length) wander(b); + } else { + const sub = quizCarryFindSubmitTileServer(md, b); + if (sub) b.botPath = quizCarryPathfindServer(md, b.x, b.y, sub.x + 0.5, sub.y + 0.5); + b.pathfindAfter = now + 160 + Math.floor(Math.random() * 200); + if (!b.botPath || !b.botPath.length) wander(b); + } + }); + + const arr = []; + s.bots.forEach((b, id) => arr.push({ + id, x: Math.round(b.x * 1000) / 1000, y: Math.round(b.y * 1000) / 1000, + direction: b.dir || 'down', isWalking: !!b.isWalking, held: b.held == null ? null : b.held, + })); + if (arr.length) io.to(sid).emit('quiz-carry-bot-move', { bots: arr }); +} + +/** หา tile โซน option (เลขช่อง want+1) ที่เดินถึงได้ — สุ่ม */ +function quizCarryFindOptionTileServer(md, optionIndex, b) { + const w = md.width || 20, h = md.height || 15; + const want = optionIndex + 1; + const pool = []; + for (let y = 0; y < h; y++) { + const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y]; + if (!row) continue; + for (let x = 0; x < w; x++) if (Number(row[x]) === want && quizTileWalkableServer(md, x + 0.5, y + 0.5)) pool.push({ x, y }); + } + if (!pool.length) return null; + return pool[Math.floor(Math.random() * pool.length)]; +} +/** หา tile โซนส่งคำตอบ (interactive ถ้ามี ไม่งั้นช่องชิด hub) */ +function quizCarryFindSubmitTileServer(md, b) { + const w = md.width || 20, h = md.height || 15; + const pool = []; + if (quizCarryMapHasInteractiveServer(md)) { + /* ช่องที่ "ยืนได้จริง + ชิดโต๊ะ 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; + } + 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); +} + +/* ── 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, + }; +} + +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 = 1500; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป */ +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, + }); +} +function stackAdvanceTurnServer(s) { + if (!s.turnOrder || !s.turnOrder.length) return; + s.turnIndex = (s.turnIndex + 1) % s.turnOrder.length; + s.botThinkUntil = 0; + s.botAimPhase = null; +} +function stackSessionMissionGrade(space) { + const s = space.stackSession; + if (!s) return 'C'; + const scores = []; + s.turnOrder.forEach((e) => { + if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); scores.push(Math.max(0, (b && b.score) | 0)); } + else if (e.id != null) scores.push(Math.max(0, s.humanPts[e.id] | 0)); + }); + const n = scores.length || 1; + const avg = Math.floor(scores.reduce((a, b) => a + b, 0) / n); + let grade = avg >= 80 ? 'A' : avg >= 60 ? 'B' : 'C'; + return grade; +} +function stackSessionEnd(sid, space, outcome) { + const s = space.stackSession; + if (!s || s.ended) return; + s.ended = true; + stopStackTicker(space); + /* คะแนนผู้เล่นจริง → reportedMiniScore (ใช้ทางเดิมของ coin/evidence) */ + s.turnOrder.forEach((e) => { + if (e.kind === 'human' && e.id != null) { + const p = space.peers.get(e.id); + if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; } + } + }); + try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; } + const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' }; + io.to(sid).emit('stack-over', overPayload); + space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() }; +} +function runStackTick(sid, space) { + const s = space.stackSession; + if (!s || !s.active || s.ended) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; } + const now = Date.now(); + /* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */ + if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; } + if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; } + if ((now - s.startMs) >= stackTimeLimitSecServer() * 1000) { stackSessionEnd(sid, space, 'time_up'); return; } + if (!s.turnOrder.length) return; + /* รอแอนิเมชันบล็อกก่อนหน้า (busy) → ไม่แกว่ง/ไม่ดรอป */ + if (now < s.busyUntil) return; + const entry = stackCurrentTurnEntry(s); + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + const bot = s.bots.get(entry.botId); + const tier = bot ? bot.tier : 'avg'; + if (s.botThinkUntil === 0) { + s.botThinkUntil = now + STACK_BOT_THINK_MIN_MS + Math.floor(Math.random() * (STACK_BOT_THINK_MAX_MS - STACK_BOT_THINK_MIN_MS)); + } else { + /* เลื่อนเฟสตามเวลา (ให้ client เห็นแกว่ง) */ + s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2; + /* ส่ง swingAmp+topCenterX ด้วย → client วาดบล็อกที่ห้อยตรงกับโมเดล server เป๊ะ (drop landOffset = swingAmp*sin(phase)) ไม่วาปตอนปล่อย */ + if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX, 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; + const _dbgAim = s.botAimPhase; const _dbgDeadline = (now >= s.botAimDeadline) && !(s.phase >= s.botAimPhase); + s.botAimPhase = null; + const hit = stackComputeDropServer(s, md); + /* [DBG-MG3drop] บอทดรอปที่ s.phase → เทียบ aimPhase/จุดลง/รองรับ/overlap/miss (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'BOT ' + (entry.botId || '') + ' phase=' + s.phase.toFixed(3) + + ' aimPhase=' + (Number.isFinite(_dbgAim) ? _dbgAim.toFixed(3) : 'x') + (_dbgDeadline ? '(DEADLINE!)' : '(reached)') + + ' sinDrop=' + Math.sin(s.phase).toFixed(3) + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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 }); + } + /* [DBG-MG6move] จับสถานะบอททุก ~2.5วิ → STILL=server ไม่ขับ, DEAD=ตาย(client ต้องซ่อน), MOV=ปกติ */ + if (!s._dbgMT || now - s._dbgMT > 2500) { + s._dbgMT = now; + const rows = []; + s.bots.forEach((b, id) => { + const mv = (b._ldx == null) ? '?' : ((Math.abs(b.cx - b._ldx) + Math.abs(b.cy - b._ldy) > 2) ? 'MOV' : 'STILL'); + b._ldx = b.cx; b._ldy = b.cy; + const tag = (id.indexOf(JS_BOT_PREFIX) === 0 ? 'pv' + id.slice(JS_BOT_PREFIX.length) : 'TK:' + String(id).slice(0, 5)); + rows.push(tag + (b.takeover ? 'tk' : '') + '·' + (b.eliminated ? 'DEAD' : mv)); + }); + glog(sid, space, 'mg6-move', '[' + rows.join(' ') + ']'); + } +} + +/* ============================================================ + 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' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true }); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + y0 = Math.max(0, Math.min(h - 1, y0)); + y1 = Math.max(0, Math.min(h - 1, y1)); + if (y1 < y0) { + const t = y0; + y0 = y1; + y1 = t; + } + return { y0, y1 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + /* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */ + if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) { + if (gr.endsAt != null) gr.endsAt += getGauntletTickMs(); + emitGauntletSync(sid, space); + return; + } + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client + เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */ + if (gr.finishPhase) { + if (gr.obstacles.length) { gr.obstacles = []; } + if (gr.finishEndAt == null) gr.finishEndAt = Date.now() + GAUNTLET_FINISH_WALK_MS; + if (Date.now() >= gr.finishEndAt) { + endGauntletGame(sid, space, 'finish'); + } + /* ไม่ emitGauntletSync ระหว่าง grace — ปล่อย client คุมการเดินเข้าเส้นเอง (กัน sync ดึงตำแหน่งกลับ) */ + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* G2: pre-run hold — หลัง 3-2-1 จบ ค้างที่จุด start ให้เห็นเส้นเริ่มก่อน (ยังไม่เลื่อน/เกิด obstacle/ขยับบอท) + client ค้าง bg เองด้วย max(0, serverNow − liveStartMs) → bg+bots/obstacles sync ที่ start พร้อมกัน */ + if (gr.liveStartMs != null && Date.now() < gr.liveStartMs) { + space.peers.forEach((p) => { p.gauntletJumpPending = false; }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* บอท server-authoritative — แถวเลนของบอทที่ยังเล่นอยู่ (ไม่ต้องพึ่ง gauntlet-preview-rows จาก host) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (b.gauntletEliminated) return; + const lr = gauntletLaneGridRowFromPlayerTop(md, b.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + } + /* แถวบนที่ client แจ้ง (บอท preview — editor หรือ client เก่า) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + let _gauntletLaserThisTick = false; + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + _gauntletLaserThisTick = true; + } + /* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */ + if (!_gauntletLaserThisTick) { + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + /* บอท server-authoritative — ตัดสินใจกระโดด + ฟิสิกส์ รอบ tick เดียวกับผู้เล่น (obstacle เลื่อนแล้วข้างบน) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (crownHeist && b.gauntletEliminated) return; + gauntletBotDecideJump(b, gr.obstacles, md, w, h); + gauntletBotStepPhysics(b, gr.obstacles, md, w, h, crownHeist); + }); + } + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; +} + +function mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(md) { + const w = md.width || 20; + const h = md.height || 15; + const out = [null, null, null, null, null, null]; + const raw = md && md.lobbyPlayerSpawns; + if (!Array.isArray(raw)) return out; + for (let i = 0; i < 6 && i < raw.length; i++) { + const cell = raw[i]; + if (!cell || typeof cell !== 'object') continue; + const x = Math.floor(Number(cell.x)); + const y = Math.floor(Number(cell.y)); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (x < 0 || x >= w || y < 0 || y >= h) continue; + out[i] = { x, y }; + } + return out; +} + +/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) { + if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; + const g = md.shooterSpawnSlots; + if (!g) return; + const w = md.width || 20, h = md.height || 15; + for (let slot = 1; slot <= 6; slot++) { + const idx = slot - 1; + if (slots6[idx]) continue; + let found = false; + for (let yy = 0; yy < h && !found; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] === slot) { + slots6[idx] = { x: xx, y: yy }; + found = true; + break; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */ +/** เลือก host คนถัดไปเมื่อ host ปัจจุบันหลุด — คนที่อยู่ในห้องนานสุด (spawnJoinOrder ต่ำสุด) + เสถียรข้ามการ reconnect (spawnJoinOrder ผูกกับ playerKey) — แทน Map-first ที่สลับได้ */ +function pickNextHostPeer(space) { + if (!space || !space.peers || space.peers.size === 0) return null; + let best = null; + let bestOrd = Infinity; + space.peers.forEach((p, id) => { + if (!p) return; + const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999; + if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) { + bestOrd = ord; + best = p; + } + }); + return best; +} + +function resolveSpawnJoinOrder(space, playerKey) { + if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {}; + const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const used = new Set(); + space.peers.forEach((p) => { + if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder); + }); + /* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */ + if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) { + const prev = space.spawnOrderByKey[key]; + if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev; + } + let ord = 0; + while (used.has(ord)) ord++; + if (key) space.spawnOrderByKey[key] = ord; + return ord; +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน < 4) */ +function openSuspectPhaseForRoom(sid, space, disruptorAccepted) { + if (!space) return; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + const phaseBase = { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: !!disruptorAccepted, + cardMinigames: space.suspectCardMinigames || [], + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('suspect-phase-open', { + ...phaseBase, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + /* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */ + const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer); + if (peerIds.length < 4 && !forceOffer) { + /* ไม่เสนอบทตัวป่วน (คน < 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */ + space.troublesomeOfferSent = true; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + openSuspectPhaseForRoom(sid, space, false); + return; + } + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 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) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + /* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */ + const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const prevHostId = space.hostId; + if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) { + space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */ + } else if (!space.hostId) { + space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */ + if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey; + } + /* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers + จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost) + แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */ + if (joinPlayerKey) { + const staleIds = []; + space.peers.forEach((p, pid) => { + if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid); + }); + staleIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + /* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */ + if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) { + delete space.graceReconnect[joinPlayerKey]; + if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; } + io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' }); + } + /* MG1 takeover: ผู้เล่นกลับมากลาง quiz (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (sess.bots) คืนสิทธิ์เล่น + กัน "ตัวซ้ำ" (บอท takeover + ตัวที่ rejoin) */ + const sessJ = space.quizSession; + if (sessJ && sessJ.bots) { + const dropBotIds = []; + sessJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBotIds.push(bid); }); + dropBotIds.forEach((bid) => { + sessJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); /* ลบ avatar บอท takeover เก่า — ตัวจริงจะมาใหม่ผ่าน user-joined */ + }); + } + /* MG2 takeover เช่นกัน: ผู้เล่นกลับมากลาง gauntlet (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (gauntletRun.bots) */ + const grJ = space.gauntletRun; + if (grJ && grJ.bots) { + const dropG = []; + grJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropG.push(bid); }); + dropG.forEach((bid) => { + grJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG3 takeover เช่นกัน: กลับมากลาง stack (playerKey เดิม) → ถอนบอท + คืน seat ให้คนจริง (inherit คะแนนกลับ) */ + const s3J = space.stackSession; + if (s3J && s3J.bots) { + let restored = false; + const dropS = []; + s3J.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropS.push(bid); }); + dropS.forEach((bid) => { + const inherited = Math.max(0, ((s3J.bots.get(bid) || {}).score) | 0); + s3J.bots.delete(bid); + const entry = (s3J.turnOrder || []).find((e) => e.kind === 'bot' && String(e.botId) === String(bid)); + if (entry) { entry.kind = 'human'; entry.id = socket.id; entry.botId = null; s3J.humanPts[socket.id] = inherited; restored = true; } + io.to(spaceId).emit('user-left', { id: bid }); + }); + if (restored) emitStackStateServer(spaceId, space); + } + /* MG4 takeover เช่นกัน: กลับมากลาง quiz_carry (playerKey เดิม) → ถอนบอท + คืน slot ให้คนจริง (inherit ป้าย/คะแนนกลับ) */ + const sCJ = space.quizCarrySession; + if (sCJ && sCJ.bots) { + const dropC = []; + sCJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropC.push(bid); }); + dropC.forEach((bid) => { + const bb = sCJ.bots.get(bid) || {}; + sCJ.bots.delete(bid); + sCJ.players[socket.id] = { held: (bb.held == null ? null : bb.held), score: Math.max(0, Number(bb.score) || 0) }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG5 takeover เช่นกัน: กลับมากลาง jump_survive (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit สถานะตาย) */ + const sJJ = space.jumpSurviveSession; + if (sJJ && sJJ.bots) { + const dropJ = []; + sJJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropJ.push(bid); }); + dropJ.forEach((bid) => { + const bb = sJJ.bots.get(bid) || {}; + sJJ.bots.delete(bid); + sJJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG6 takeover: กลับมากลาง space_shooter (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit คะแนน/ตาย) */ + const sSJ = space.spaceShooterSession; + if (sSJ && sSJ.bots) { + const dropSS = []; + sSJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropSS.push(bid); }); + dropSS.forEach((bid) => { + const bb = sSJ.bots.get(bid) || {}; + sSJ.bots.delete(bid); + sSJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG7 takeover: กลับมากลาง balloon_boss (playerKey เดิม) → ถอนบอท + คืน slot คนจริง */ + const sBJ = space.balloonBossSession; + if (sBJ && sBJ.bots) { + const dropBB = []; + sBJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBB.push(bid); }); + dropBB.forEach((bid) => { sBJ.bots.delete(bid); io.to(spaceId).emit('user-left', { id: bid }); }); + } + } + /* reconnect dedup ชั้น 2: ghost ที่ playerKey block จับไม่ได้ (peer เก่าไม่มี playerKey) + ลบด้วย "ชื่อ custom เดียวกัน" — แต่ **เฉพาะ peer เก่าที่ไม่มี valid playerKey** เท่านั้น + ⚠️ ห้ามลบ peer ที่มี playerKey คนละค่า: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละคน ต้องเก็บทั้งคู่ + (เดิมลบทุกตัวที่ชื่อตรง → client ที่ชื่อซ้ำ host ถูกลบ = "ไม่ได้เล่นเลย") */ + const validPkStr = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : ''; + const newNickNorm = normalizeLobbyNickname(nickname); + const newNickIsCustom = newNickNorm && newNickNorm !== 'ผู้เล่น' + && newNickNorm.indexOf('ผู้เล่น') !== 0 && newNickNorm.indexOf('บอท') !== 0; + if (newNickIsCustom) { + const ghostIds = []; + space.peers.forEach((p, pid) => { + if (pid === socket.id || !p) return; + const ppk = validPkStr(p); + if (ppk && ppk !== joinPlayerKey) return; /* คนละ playerKey = คนละคน (ชื่อซ้ำได้) → ไม่ลบ */ + if (normalizeLobbyNickname(p.nickname) === newNickNorm) ghostIds.push(pid); + }); + ghostIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + } + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + ensurePeerLobbyThemes(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey); + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = balloonBossBalloonsForMap(mdJoin); + /* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */ + let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10); + if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) { + /* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */ + let takenByHuman = false; + space.peers.forEach((p) => { + const ti = parseInt(p.lobbyColorThemeIndex, 10); + if (ti === chosenThemeIdx) takenByHuman = true; + }); + if (takenByHuman) { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + /* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */ + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0; + }); + } + } else { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } + let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10); + if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) { + chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder); + } + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + lobbyColorThemeIndex: chosenThemeIdx, + lobbySkinToneIndex: chosenSkinIdx, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '', + reportedMiniScore: 0, reportedMiniSurvived: true, + bannedSpectator: !!bannedSpectator, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* เก็บกวาด peer ซ้ำทั้งก้อน (ghost) — เผื่อ dedup ต่อ-join ด้านบนพลาด/race จนคนเดิมค้าง 2 ตัว */ + pruneDuplicateSpacePeers(spaceId, space); + glog(spaceId, space, 'join', 'สี#' + (peer.lobbyColorThemeIndex || '?') + ' · คน ' + space.peers.size + ' + บอท ' + effectiveBotSlotCount(space) + (joinPlayerKey ? '' : ' · NO-KEY'), nickname || 'ผู้เล่น'); + /* invariant: คน + บอท ≤ 6 — กัน "เงาสะท้อนของ user": คนหลุดแล้ว grace แปลงเป็นบอท (botSlotCount+1, สีตามคนออก) + พอคนนั้นกลับเข้ามา (reconnect หลัง grace) จะมีทั้งคนจริง + บอทสีเดียวกัน = ตัวซ้ำ. ตัดบอทส่วนเกินออกให้รวมไม่เกิน 6 + (มินิเกม cap ที่ 6-humans อยู่แล้ว ~rebalancePreviewBots แต่ LobbyB ไม่ได้ cap → ตัดที่ server ต้นทาง) */ + { + const humansNow = space.peers.size; + const botsNow = effectiveBotSlotCount(space); + if (humansNow + botsNow > LOBBY_SLOT_TOTAL) { + space.botSlotCount = Math.max(0, LOBBY_SLOT_TOTAL - humansNow); + } + /* re-sync สีบอททุกครั้งที่มีคน (re)join + แจ้ง "ทุกจอ" (รวมคนที่อยู่ในมินิเกม/LobbyB อยู่แล้ว): + เคส "เงาสะท้อนของ user" = คนหลุด→บอทใช้สีคนนั้น (pin)→คนนั้นกลับมา. join handler เคลียร์ pin ที่ชนสีคนแล้ว + (7704) + syncLobbyBotThemeSlots เลือกสีใหม่ที่ไม่ชนคน แต่เดิม emit เฉพาะตอน total>6 → จออื่นไม่อัปเดต = บอทยังสีเดิม. + emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */ + emitLobbyTintSync(spaceId, space); + } + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id); + /* นับช่อง spawn ให้พอกับ "คน + บอท" — เดิมส่งแค่ peers.size: ห้อง 1 คน = 1 ช่อง = pos[0] (เลนบนสุด) เสมอ + แล้วไปทับบอทที่ initGauntletPeersAll วางไว้ที่ pos[0] → คน(host ใหม่)+บอท วิ่งเลนเดียวกันเลนบนสุด (bug ที่เจอซ้ำ) */ + const grJoin = space.gauntletRun; + const botCntJoin = (grJoin && grJoin.bots) ? grJoin.bots.size : 0; + const pos = gauntletSpawnPositions(mdJoin, space.peers.size + botCntJoin); + /* เลี่ยงทับทั้ง "peer อื่น" และ "บอท" ที่อยู่ในเลนแล้ว — ดันไปช่องว่างถัดไป (เดิมเช็คแต่ peer ไม่เช็คบอท) */ + const occupied = new Set(); + space.peers.forEach((q) => { if (q && q.id !== socket.id) occupied.add(Math.floor(q.x) + ',' + Math.floor(q.y)); }); + if (grJoin && grJoin.bots) grJoin.bots.forEach((b) => { if (b) occupied.add(Math.floor(Number(b.x)) + ',' + Math.floor(Number(b.y))); }); + let pidx = Number.isFinite(ord) ? Math.max(0, Math.min(ord, pos.length - 1)) : pos.length - 1; + let pguard = 0; + while (pos[pidx] && occupied.has(Math.floor(pos[pidx].x) + ',' + Math.floor(pos[pidx].y)) && pguard < pos.length) { pidx = (pidx + 1) % pos.length; pguard++; } + const pt = pos[pidx] != null ? pos[pidx] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + botSlotCount: effectiveBotSlotCount(space), + lobbyBotThemes: syncLobbyBotThemeSlots(space), + joinLocked: !!space.joinLocked, + missionLiveBeginsAt: space.missionLiveBeginsAt || null, + }; + if (serverMapIsPostCaseLobbyB(space)) { + joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null; + joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null; + } + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + /* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */ + const sqIcon = specialQuizIconPayload(space); + if (sqIcon) joinCb.specialQuizIcon = sqIcon; + if (typeof cb === 'function') cb(joinCb); + /* กันค้างตอน reconnect: event "จบมินิเกม" (balloon-boss-over/stack-over/space-shooter-over) + host ยิงครั้งเดียวผ่าน socket.to — ถ้า client หลุดตอนนั้นจะค้างถาวร (ไม่มี replay). + เก็บ over ล่าสุดไว้ → resend ให้ socket ที่เพิ่ง (re)join ภายใน 45s ถ้ายังอยู่ในแมปมินิเกม + (gameType !== 'lobby' = ยังไม่ทรานซิชันกลับ). delay เล็กน้อยให้ client applyMapAndStart เสร็จก่อน */ + const lmo = space.lastMinigameOver; + if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') { + const targetSid = socket.id; + setTimeout(() => { + try { + const sp = spaces.get(spaceId); + if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) { + io.to(targetSid).emit(lmo.event, lmo.payload); + } + } catch (_e) { /* ignore */ } + }, 400); + } + if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */ + emitLobbyTintSync(spaceId, space); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + /* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที + (คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */ + if (serverMapIsPostCaseLobbyB(space)) { + /* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */ + emitDetectiveLobbyBPresence(spaceId, space); + console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id); + if (space.pendingBanVoteCard && !space.pickVote) { + const banCard = space.pendingBanVoteCard; + space.pendingBanVoteCard = null; + /* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */ + setTimeout(() => { + const sp = spaces.get(spaceId); + if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) { + console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp))); + return; + } + console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')'); + io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' }); + startPlayerPickVote(spaceId, sp, 'ban', (targetId) => { + const sp2 = spaces.get(spaceId); + console.log('[ban-debug] ban vote RESOLVED target=', targetId); + if (sp2) sp2.bannedPlayerId = targetId || null; + }); + }, 2000); + } else if (space.pickVote && !space.pickVote.resolved) { + const pv = space.pickVote; + socket.emit('player-pick-vote-open', { + purpose: pv.purpose, + candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: pv.endsAt, + durationMs: pickVoteMs(), + }); + } + } + }); + + /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ + /** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */ + socket.on('special-quiz-force', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + space.forceSpecialQuiz = !!(data && data.on); + console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id); + }); + + /** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ + socket.on('special-quiz-testmode', (data) => { + socket.data.testMode = !!(data && data.on); + }); + + /** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */ + socket.on('test-force-wrong-vote', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return; + space.testForceWrongVote = true; + console.log('[test] force-wrong-vote armed by', socket.id); + }); + + /** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */ + socket.on('special-quiz-arm', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return; + sq.armScheduled = true; + scheduleSpecialQuizIconAppearAndExpiry(sid, space); + }); + + socket.on('special-quiz-collide', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return reply({ ok: false }); + const started = startSpecialQuizSession(sid, space); + reply({ ok: !!started }); + }); + + /** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */ + socket.on('special-quiz-answer', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return reply({ ok: false }); + const q = sess.questions[sess.qIndex]; + if (!q) return reply({ ok: false }); + /* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */ + let choice; + if (data && data.debugCorrect && socket.data.testMode) { + choice = q.correctIndex; + } else { + choice = Number(data && data.choiceIndex); + } + if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) { + return reply({ ok: false }); + } + if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' }); + sess.answers[socket.id] = choice; + reply({ ok: true }); + /* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */ + io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true }); + /* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ + maybeResolveSpecialQuizAllAnswered(sid, space); + }); + + /** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */ + socket.on('special-quiz-continue', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + /* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */ + if (!space.specialQuizAwaitContinue) return reply({ ok: true }); + /* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */ + if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set(); + space.specialQuizContinueAcks.add(socket.id); + reply({ ok: true }); + maybeFinishSpecialQuizContinue(sid, space); + }); + + /** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */ + socket.on('lobby-tint-update', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + const peer = space && space.peers.get(socket.id); + if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + let themeIndex = Math.floor(Number(data && data.themeIndex)); + let skinIndex = Math.floor(Number(data && data.skinToneIndex)); + if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) { + return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' }); + } + if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) { + skinIndex = peer.lobbySkinToneIndex || 1; + } + /* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join) + กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */ + let humanTaken = false; + space.peers.forEach((op, oid) => { + if (oid === socket.id) return; + if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true; + }); + if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); + if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */ + }); + } + peer.lobbyColorThemeIndex = themeIndex; + peer.lobbySkinToneIndex = skinIndex; + emitLobbyTintSync(sid, space); + reply({ + ok: true, + lobbyColorThemeIndex: themeIndex, + lobbySkinToneIndex: skinIndex, + lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], + }); + }); + + /* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */ + /* DEPRECATED: server เป็นเจ้าของบอท lobby เองแล้ว (stepLobbyBotsForSpace/lobbyBotSweepTick) + → ไม่ relay ตำแหน่งบอทจาก client อีก (กัน host เวอร์ชันเก่าฉีดตำแหน่งชนกับ server tick = บอทกระตุก/2 แหล่ง) */ + socket.on('lobby-bot-move', () => { /* no-op: server-authoritative */ }); + + /* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */ + socket.on('fill-bot-state', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ + if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; + /* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host" + แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */ + const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined); + socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans }); + }); + + /* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host) + payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */ + socket.on('balloon-boss-shot', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */ + const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); + if (!ok) return; + socket.to(sid).emit('balloon-boss-shot', { + sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy, + ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), + }); + }); + + /* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */ + socket.on('balloon-boss-over', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const reason = (data.reason === 'victory' || data.reason === 'all_dead' || data.reason === 'time') ? data.reason : 'all_dead'; + const scores = Array.isArray(data.scores) + ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated })) + : []; + const overPayload = { reason, scores }; + socket.to(sid).emit('balloon-boss-over', overPayload); + /* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */ + space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() }; + }); + + /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ + → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ + socket.on('balloon-boss-sync', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const bossDmg = Math.max(0, Number(data.bossDmg) || 0); + const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */ + const players = Array.isArray(data.players) + ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ + id: p.id, + d: Math.max(0, Number(p.d) || 0), + b: Math.max(0, Number(p.b) || 0), + e: !!p.e, + })) + : []; + socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor }); + }); + + /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + socket.on('stack-drop', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: client ไม่เป็นเจ้าของดรอป */ + const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; + const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; }; + socket.to(sid).emit('stack-drop', { + placedCx: num(data.placedCx), placedW: num(data.placedW), + miss: !!data.miss, perfect: !!data.perfect, + pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), + overlap: num(data.overlap), + supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), + delta: num(data.delta), + seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, + theme: themeOf(data.theme), /* สี block ที่วาง (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client freeze สีตัวเอง = เพี้ยน */ + actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, + }); + }); + /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ + socket.on('quiz-end-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ + if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'quiz') return; + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); + }); + + /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ + socket.on('stack-swing', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: server แกว่งเอง */ + const ph = Number(data.phase); + if (!Number.isFinite(ph)) return; + socket.to(sid).emit('stack-swing', { phase: ph }); + }); + + /* MG3 stack (server-auth) — รับ "เจตนากดวาง" จากผู้เล่นจริง → server คำนวณ ณ เฟสปัจจุบัน + emit ผลให้ทุกเครื่อง */ + socket.on('stack-drop-req', (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); + /* [DBG-MG3drop] เทียบ client-sent phase vs s.phase + จุดลง/รองรับ/overlap/miss/หัวใจ (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'HUMAN cph=' + (Number.isFinite(cph) ? cph.toFixed(3) : 'x') + + ' sPhase=' + s.phase.toFixed(3) + ' used=' + dropPhase.toFixed(3) + (dropPhase === cph ? '(client)' : '(server)') + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + ' perfect=' + hit.perfect + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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) { + return reply({ + ok: false, + waitingPlayers: true, + present: waitInfo.present, + total: waitInfo.total, + error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')', + }); + } + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; + const cardEntry = space.suspectCardMinigames[selectedIndex]; + if (!cardEntry || !cardEntry.mapId) { + return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); + } + /* จำกัดสืบผู้ต้องสงสัยละไม่เกิน 3 ครั้ง (#1) — นับจาก max หลักฐานของผู้ต้องสงสัยนี้ข้ามผู้เล่น + (แต่ละรอบทุกคนได้ +1) → กันเล่นครั้งที่ 4 + กันการ์ดพิเศษโผล่รอบที่ 4 (#2) */ + let timesPlayedThisSuspect = 0; + space.peers.forEach((_p, pid) => { + const prog = playerSuspectProgressFromEvidence(space, pid); + const n = Array.isArray(prog) ? (prog[selectedIndex] | 0) : 0; + if (n > timesPlayedThisSuspect) timesPlayedThisSuspect = n; + }); + if (timesPlayedThisSuspect >= 3) { + return reply({ ok: false, error: 'สืบผู้ต้องสงสัยคนนี้ครบ 3 ครั้งแล้ว — เลือกคนอื่น' }); + } + /* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */ + const pend = findPendingSpecialCard(space, 3); + if (pend && !space.pickVote) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' }); + startPlayerPickVote(sid, space, 'ban', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.bannedPlayerId = targetId || null; + const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex); + if (!res || !res.ok) { + io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true }); + } + }); + return reply({ ok: true, banVote: true }); + } + const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); + reply(started); + }); + + socket.on('detective-minigame-finished', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + } + /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */ + const finPeer = space.peers.get(socket.id); + if (finPeer && data && typeof data === 'object') { + /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง + หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */ + if (Number.isFinite(Number(data.score))) { + let rs = Math.max(0, Math.floor(Number(data.score))); + if (rs > REPORTED_MINI_SCORE_MAX) { + console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX); + rs = REPORTED_MINI_SCORE_MAX; + } + finPeer.reportedMiniScore = rs; + } + if (data.survived === false) finPeer.reportedMiniSurvived = false; + } + if (!space.detectiveMinigameActive) { + if (serverMapIsPostCaseLobbyB(space)) { + const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]); + return reply({ + ok: true, + alreadyOnLobbyB: true, + suspectIndex: grant.suspectIdx, + awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null, + awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [], + grade: grant.grade || null, + freeEvidenceCount: grant.freeEvidenceCount || 0, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + } + return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); + } + /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก" + จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด + ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */ + const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) { + if (finPeer) finPeer.detectiveFinished = true; + const allDone = [...space.peers.values()].every((p) => + p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished + ); + if (!allDone) { + /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */ + return reply({ ok: true, holdInScene: true }); + } + /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */ + } + // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน) + returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true }); + const g = space.lastEvidenceGrant || {}; + reply({ + ok: true, + suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1, + awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null, + awardedCards: (g.awardedList && g.awardedList[socket.id]) || [], + grade: g.grade || null, + freeEvidenceCount: g.freeEvidenceCount || 0, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + }); + + /** + * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที + * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง) + */ + socket.on('detective-debug-award-evidence', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + const idx = Math.floor(Number(data && data.suspectIndex)); + if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' }); + const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C'); + if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' }); + emitLobbyEvidenceSync(sid, space); + reply({ + ok: true, + suspectIndex: idx, + awardedCard: card, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + }); + + // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย) + socket.on('suspect-accuse-open', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' }); + const prog = playerSuspectProgressFromEvidence(space, socket.id); + if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' }); + if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3); + space.suspectPhaseActive = false; + // เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต + space.testimonyActive = true; + space.testimonyRound = 0; + emitTestimonyOpen(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นส่งหลักฐาน 1–2 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง + socket.on('testimony-submit', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' }); + if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' }); + const raw = (data && Array.isArray(data.picks)) ? data.picks : []; + const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []); + const required = Math.max(1, Math.min(2, owned.length)); + /* raw = ตำแหน่ง index ใน owned (0..n-1) → เก็บเป็น card object */ + const usedIdx = []; + const picks = []; + raw.forEach((n) => { + const v = Math.floor(Number(n)); + if (v >= 0 && v < owned.length && usedIdx.indexOf(v) === -1) { usedIdx.push(v); picks.push(owned[v]); } + }); + if (owned.length === 0) { + /* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */ + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = []; + emitTestimonyStatus(sid, space); + /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design */ + return reply({ ok: true, skipped: true }); + } + if (picks.length !== required) { + return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' }); + } + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = picks.slice(0, required); + emitTestimonyStatus(sid, space); + /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design + (UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */ + reply({ ok: true }); + }); + + // Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง) + socket.on('testimony-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' }); + doTestimonyReveal(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) — ไม่ auto-advance รอ host กด "เริ่มพิจารณาคดี" + socket.on('testimony-ready-next', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' }); + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + space.testimonyReadyNext[socket.id] = true; + const totalRN = testimonyTotalParticipants(space); + io.to(sid).emit('testimony-ready-update', { + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: totalRN, + hostId: space.hostId, + }); + /* ไม่ auto-advance — รอ host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote.png) เอง */ + reply({ ok: true }); + }); + + // Host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote) — ไปปากคำถัดไป/เข้าโหวต + socket.on('testimony-start-vote', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังไม่ถึงเวลา' }); + /* host อาจกดก่อนทุกคน READY — บังคับให้ทุกคน (รวมบอท) ready ก่อน advance เพื่อให้ flow consistent */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + advanceTestimony(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นโหวตชี้ตัวคนร้าย + socket.on('trial-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' }); + if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true }); + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); + /* รอบจับผิดตัว: ห้ามโหวตผู้ต้องสงสัยที่โหวตผิดไปแล้ว */ + if (Number.isInteger(space.trialRevoteExcluded) && idx === space.trialRevoteExcluded) { + return reply({ ok: false, error: 'ผู้ต้องสงสัยนี้ถูกโหวตไปแล้ว (ผิด) — เลือกคนอื่น', excluded: true }); + } + space.trialVotes = space.trialVotes || {}; + space.trialVotes[socket.id] = idx; + emitTrialVoteUpdate(sid, space); + const totalTV = trialEligibleVoterTotal(space); + if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + + /* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */ + socket.on('player-pick-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const pv = space.pickVote; + if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' }); + const tid = String((data && data.targetId) || ''); + if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' }); + pv.votes[socket.id] = tid; + reply({ ok: true, targetId: tid }); + emitPickVoteUpdate(sid, space); + maybeResolvePickVote(sid, space); + }); + + /** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */ + socket.on('host-console-apply', (data, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const humans = space.peers.size; + let total = parseInt(data && data.totalSlots, 10); + if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space); + total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total)); + let bots = parseInt(data && data.botSlotCount, 10); + if (!Number.isFinite(bots)) bots = total - humans; + bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots)); + total = humans + bots; + space.botSlotCount = bots; + space.maxPlayers = Math.max(humans, total - bots); + const lobbyBotThemes = syncLobbyBotThemeSlots(space); + io.to(sid).emit('host-console-settings', { + maxPlayers: space.maxPlayers, + botSlotCount: space.botSlotCount, + totalSlots: total, + lobbyBotThemes, + }); + emitLobbyTintSync(sid, space); + cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes }); + }); + + socket.on('host-console-kick-peer', ({ targetId }, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const tid = targetId != null ? String(targetId) : ''; + if (!tid || tid === socket.id || tid === space.hostId) { + return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' }); + } + if (!space.peers.has(tid)) { + return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' }); + } + const targetSock = io.sockets.sockets.get(tid); + if (targetSock) { + targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' }); + targetSock.leave(sid); + delete targetSock.data.spaceId; + } + space.peers.delete(tid); + io.to(sid).emit('user-left', { id: tid }); + cb && cb({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.ready = !!ready; + io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready }); + break; + } + } + }); + + socket.on('start-game', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id) && space.hostId === socket.id) { + let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : ''; + let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : ''; + if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = ''; + /* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */ + if (caseId && !/^(?:[1-9]|1[0-5])$/.test(caseId)) caseId = ''; + ensurePostCaseLobbyMapLoaded(); + const mdBefore = getLobbyLayoutMapForSpace(space); + if (mdBefore && mdBefore.gameType === 'quiz') { + const qErr = validateQuizMap(mdBefore); + if (qErr) return reply({ ok: false, error: qErr }); + clearSpaceQuizTimers(space); + startQuizGame(sid, space, mdBefore); + io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true }); + return reply({ ok: true }); + } + const detectiveB = !!(data && data.detectiveLobbyBStart); + if (detectiveB && (!lobbyLevel || !caseId)) { + return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' }); + } + const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : ''; + const isNamedLobbyA = mapNameNorm === 'lobbya'; + const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet'); + /* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */ + const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore) + && lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID) + && ( + mdBefore.gameType === 'lobby' + || isNamedLobbyA + || (detectiveB && notZepOrFrogger) + )); + if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) { + return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' }); + } + /* detective LobbyA→LobbyB: ไม่บังคับยืนโซนส้ม (หน้าจอเลือกคดีบังแผนที่ ขยับตัวไม่ได้) */ + if (!isLobbyADetectiveStart && !lobbyHostStandingInStartArea(space, socket.id)) { + return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' }); + } + if (isLobbyADetectiveStart) { + assignDetectiveSuspectCardMinigames(space); + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID); + const caseNickWl = new Set(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + space.detectiveLobbyLevel = Number(lobbyLevel) || 1; + space.detectiveLobbyCaseId = Number(caseId) || 1; + space.caseId = Number(caseId) || 1; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space) + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + /* เก็บข้อมูลก่อนลบ 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) { + 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 */ } + 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-preplaylock b/www/html/Game/server.js.bak-preplaylock new file mode 100644 index 0000000..41b7878 --- /dev/null +++ b/www/html/Game/server.js.bak-preplaylock @@ -0,0 +1,12756 @@ +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'); + +/* ===== Quiz Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ +const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json'); +/** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */ +let qbLeaderboardStore = {}; +function loadQbLeaderboard() { + try { + if (fs.existsSync(QB_LEADERBOARD_PATH)) { + const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8')); + qbLeaderboardStore = (j && typeof j === 'object') ? j : {}; + } + } catch (e) { qbLeaderboardStore = {}; } +} +function saveQbLeaderboard() { + try { + const dir = path.dirname(QB_LEADERBOARD_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8'); + } catch (e) { /* ignore */ } +} +function qbLeaderboardSubmit(roomId, name, score, characterId) { + roomId = String(roomId || '').trim(); + name = String(name || '').trim().slice(0, 24); + score = Math.max(0, Math.floor(Number(score) || 0)); + if (!roomId || !name) return; + if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = []; + const list = qbLeaderboardStore[roomId]; + const ex = list.find((e) => e && e.name === name); + if (ex) { + if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); } + } else { + list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() }); + } + list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts)); + if (list.length > 500) list.length = 500; + saveQbLeaderboard(); +} +function qbLeaderboardGet(roomId, name) { + roomId = String(roomId || '').trim(); + const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : []; + const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' })); + let me = null; + const nm = String(name || '').trim(); + if (nm) { + const idx = list.findIndex((e) => e && e.name === nm); + if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' }; + } + return { top, me, total: list.length }; +} +loadQbLeaderboard(); + +/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ +const os = require('os'); +let __prevCpuSample = null; +function __cpuSample() { + const cpus = os.cpus() || []; + let idle = 0, total = 0; + for (const c of cpus) { + for (const k in c.times) total += c.times[k]; + idle += c.times.idle; + } + return { idle, total }; +} +/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ +function __cpuPercent() { + const cur = __cpuSample(); + const prev = __prevCpuSample; + __prevCpuSample = cur; + if (!prev) return null; + const idleD = cur.idle - prev.idle; + const totalD = cur.total - prev.total; + if (totalD <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); +} +__cpuSample(); // เก็บ baseline แรก +function getDiskStats() { + try { + if (typeof fs.statfsSync !== 'function') return null; + const s = fs.statfsSync(__dirname); + const total = s.blocks * s.bsize; + const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด + const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ + const used = total - free; + if (!(total > 0)) return null; + return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; + } catch (e) { return null; } +} +/* จำนวนผู้เล่น/ห้อง/socket ออนไลน์ตอนนี้ — โชว์ในหน้า Admin เทียบกับเพดานความจุ */ +function getOnlineStats() { + let players = 0, rooms = 0; + try { + for (const [, sp] of spaces) { + if (sp && sp.peers) { players += sp.peers.size; rooms++; } + } + } catch (e) { /* ignore */ } + let sockets = 0; + try { sockets = (io && io.engine && io.engine.clientsCount) || 0; } catch (e) { /* ignore */ } + return { sockets, players, rooms }; +} + +function getSystemStats() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const cores = (os.cpus() || []).length || 1; + const cpuPct = __cpuPercent(); + const mu = process.memoryUsage(); + return { + ok: true, + cpu: { + percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) + cores, + load1: Math.round(load[0] * 100) / 100, + load5: Math.round(load[1] * 100) / 100, + load15: Math.round(load[2] * 100) / 100, + loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), + }, + mem: { + total: totalMem, + free: freeMem, + used: usedMem, + usedPercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDiskStats(), + node: { + rss: mu.rss, + heapUsed: mu.heapUsed, + rssPercent: Math.round((mu.rss / totalMem) * 100), + version: process.version, + uptime: Math.round(process.uptime()), + }, + uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, + /* ผู้ใช้ออนไลน์ + เพดานความจุ (ประเมินจาก 2 vCPU / socket fan-out): สบาย ≤300 · เริ่มหนัก ≥500 */ + online: getOnlineStats(), + capacity: { comfortable: 300, strain: 500 }, + hostname: os.hostname(), + platform: os.platform(), + ts: Date.now(), + }; +} + +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */ +const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5]; +const SPECIAL_QUIZ_CASES = [1, 2, 3]; +const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary']; +const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; + +function specialCardTxtFilename(id) { + return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`; +} + +function specialCardImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`; +} + +function specialCardTxtImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`; +} + +function specialQuizSetKey(level, caseId) { + return 'L' + level + '_C' + caseId; +} + +/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */ +function sanitizeSpecialQuizQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + const type = q.type === 'mcq' ? 'mcq' : 'tf'; + if (type === 'mcq') { + /* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */ + const choices = (Array.isArray(q.choices) ? q.choices : []) + .map((c) => String(c == null ? '' : c).trim()) + .slice(0, 8); + while (choices.length && choices[choices.length - 1] === '') choices.pop(); + if (choices.filter((c) => c).length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') { + ci = choices.findIndex((c) => c); + if (ci < 0) ci = 0; + } + return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci }; + } + return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue }; +} + +function sanitizeSpecialCard(c) { + const src = (c && typeof c === 'object') ? c : {}; + const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare'; + let cardId = Number(src.cardId); + if (!(cardId >= 1 && cardId <= 7)) cardId = null; + let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600); + let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600); + if (cardId) { + if (!imageUrl) imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } else if (imageUrl) { + const m = imageUrl.match(/\/card-(\d)\.png/i); + if (m) { + cardId = Number(m[1]); + imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } + } + return { + cardId: cardId || null, + th: String(src.th == null ? '' : src.th).trim().slice(0, 160), + en: String(src.en == null ? '' : src.en).trim().slice(0, 160), + rarity, + imageUrl, + txtImageUrl, + }; +} + +/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */ +function sanitizeSpecialQuizSets(obj) { + const out = {}; + SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => { + const key = specialQuizSetKey(L, C); + const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {}; + const questions = (Array.isArray(src.questions) ? src.questions : []) + .map(sanitizeSpecialQuizQuestion) + .filter(Boolean) + .slice(0, 50); + out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) }; + })); + return out; +} + +/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */ +function getSpecialQuizSet(settings, level, caseId) { + const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({}); + /* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่) + key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */ + const cid = Math.max(1, Math.min(15, Number(caseId) || 1)); + const L = Math.ceil(cid / 3); + const C = ((cid - 1) % 3) + 1; + return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; +} + +/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ +/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ +/** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */ +const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7']; +const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; +/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ +/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */ +const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; +/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน (roster คดี/LobbyB) + ⚠️ ห้ามตั้ง 0: เปลี่ยนหน้า lobbyB→play→lobbyB (socket หลุดชั่วคราว) จะถูกถอดจาก roster → rejoin ไม่ได้ ("ห้องนี้เริ่มคดีแล้ว") + หมายเหตุ: bot takeover "ในเกม MG1" (sess.bots) เป็นทันทีอยู่แล้ว แยกจาก grace นี้ */ +const DISCONNECT_GRACE_SEC = 10; + +/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */ +function normalizeTestSpecialCardByMap(v) { + const out = {}; + if (v && typeof v === 'object') { + SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => { + const c = Math.floor(Number(v[mid])); + if (c >= 1 && c <= 7) out[mid] = c; + }); + } + return out; +} +/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */ +const SPECIAL_QUIZ_ANSWER_MS = 20000; +/** หน่วงโชว์เฉลยก่อนข้อต่อไป */ +const SPECIAL_QUIZ_REVEAL_MS = 2600; + +/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */ +function specialQuizNormalizeQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + if (q.type === 'mcq') { + const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== ''); + if (choices.length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0; + return { text, choices, correctIndex: ci }; + } + return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 }; +} + +function specialQuizShuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function specialQuizTileWalkable(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const obj = md.objects && md.objects[y]; + if (obj && obj[x] === 1) return false; + /* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */ + const bp = md.blockPlayer && md.blockPlayer[y]; + if (bp && bp[x] === 1) return false; + return true; +} + +/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */ +function specialQuizTileInGameZone(md, x, y) { + if (quizCellOn(md.quizTrueArea || [], x, y)) return true; + if (quizCellOn(md.quizFalseArea || [], x, y)) return true; + if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true; + const oa = md.quizCarryOptionArea; + if (oa && oa[y] && oa[y][x]) return true; + return false; +} + +/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */ +function pickSpecialQuizSpawnTile(md, occupiedLaneCount) { + const w = md.width || 20; + const h = md.height || 15; + /* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer → + ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้ + → เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */ + if (md.gameType === 'quiz_carry') { + const nearMarkedTable = (x, y) => { + for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) { + if (!Array.isArray(g)) continue; + for (let dy = -2; dy <= 2; dy++) { + const gy = g[y + dy]; + if (!gy) continue; + for (let dx = -2; dx <= 2; dx++) { + if (gy[x + dx]) return true; + } + } + } + return false; + }; + const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length) + ? md.lobbyPlayerSpawns + : [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }]; + const qcCands = []; + seeds.forEach((s) => { + const bx = Math.floor(Number(s.x)); + const by = Math.floor(Number(s.y)); + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const dist = Math.abs(dx) + Math.abs(dy); + if (dist < 2 || dist > 6) continue; + const xx = bx + dx; + const yy = by + dy; + if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) { + qcCands.push({ x: xx, y: yy }); + } + } + } + }); + if (qcCands.length) { + const p = qcCands[Math.floor(Math.random() * qcCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns + (ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */ + if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) { + /* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */ + const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0) + ? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount)) + : md.gauntletPlayerSpawns.length; + const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes); + const rows = Array.from(new Set(usableSpawns + .map((s) => Math.floor(Number(s.y))) + .filter((n) => Number.isFinite(n) && n >= 0 && n < h))); + const laneCands = []; + const xMin = Math.max(4, Math.floor(w * 0.30)); + const xMax = Math.min(w - 2, Math.floor(w * 0.72)); + rows.forEach((ry) => { + for (let x = xMin; x <= xMax; x++) { + if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry }); + } + }); + if (laneCands.length) { + const p = laneCands[Math.floor(Math.random() * laneCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง + (ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */ + if (md.gameType === 'stack') { + const zc = []; + [md.stackReleaseArea, md.stackLandArea].forEach((g) => { + if (Array.isArray(g)) { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y }); + } + }); + if (zc.length) { + let cx2 = 0; + let cy2 = 0; + zc.forEach((c) => { cx2 += c.x; cy2 += c.y; }); + cx2 = Math.round(cx2 / zc.length); + cy2 = Math.round(cy2 / zc.length); + for (let rad = 0; rad <= 6; rad++) { + for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) { + for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 }; + } + } + } + /* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */ + return { x: cx2 + 0.5, y: cy2 + 0.5 }; + } + } + /* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack + (สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */ + if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) { + const painted = []; + const paintedWalkable = []; + for (let yy = 0; yy < h; yy++) { + const row = md.specialQuizSpawnArea[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (!row[xx]) continue; + painted.push({ x: xx, y: yy }); + if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy }); + } + } + const pickFrom = paintedWalkable.length ? paintedWalkable : painted; + if (pickFrom.length) { + const p = pickFrom[Math.floor(Math.random() * pickFrom.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* jump_survive (กระโดด mnptfts2): ผู้เล่นยืนบน "แพลตฟอร์ม" — พื้นที่ว่างนับ walkable ผิด (ลอยกลางอากาศเก็บไม่ได้) + ไอคอนคลิก/แตะเก็บได้ทุกมินิเกม (trySpecialQuizClickAt) → วางบนแพลตฟอร์มใกล้จุดเกิด (อยู่ในจอตั้งแต่ต้น คลิกได้) + = แก้ #44 อีเวนต์พิเศษ (ทนาย/เทพ) ไม่ขึ้นเพราะ BFS หา walkable ไม่เจอ → return null */ + if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) { + const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }; + const bx = Math.floor(Number(sp.x)); + const by = Math.floor(Number(sp.y)); + const plats = []; + for (let y = 0; y < h; y++) { + const row = md.jumpSurvivePlatformArea[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x]) plats.push({ x, y }); + } + } + if (plats.length) { + plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by))); + const near = plats.slice(0, Math.min(8, plats.length)); + const p = near[Math.floor(Math.random() * near.length)]; + const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */ + return { x: p.x + 0.5, y: iy + 0.5 }; + } + } + /* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้ + fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */ + let sx = -1; + let sy = -1; + const spawnGrid = md.spawnArea; + if (Array.isArray(spawnGrid)) { + for (let y = 0; y < h && sx < 0; y++) { + const row = spawnGrid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) { + const sp = md.spawn || { x: 1, y: 1 }; + const fx = Math.floor(Number(sp.x)); + const fy = Math.floor(Number(sp.y)); + if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; } + } + if (sx < 0) { + /* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก + เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */ + const ccx = Math.floor(w / 2); + const ccy = Math.floor(h / 2); + const maxRad = Math.max(w, h); + for (let rad = 0; rad <= maxRad && sx < 0; rad++) { + for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) { + for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; } + } + } + } + } + if (sx < 0) { + for (let y = 0; y < h && sx < 0; y++) { + for (let x = 0; x < w; x++) { + if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) return null; + const q = [[sx, sy]]; + const seen = new Set([sx + ',' + sy]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + const cands = []; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = nx + ',' + ny; + if (seen.has(k)) continue; + if (!specialQuizTileWalkable(md, nx, ny)) continue; + seen.add(k); + q.push([nx, ny]); + if (!specialQuizTileInGameZone(md, nx, ny)) { + const dist = Math.abs(nx - sx) + Math.abs(ny - sy); + if (dist >= 2) cands.push({ x: nx, y: ny }); + } + } + } + if (!cands.length) return null; + /* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ · + ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */ + const near = cands.filter((c) => { + const d = Math.abs(c.x - sx) + Math.abs(c.y - sy); + return d >= 2 && d <= 8; + }); + const pool = near.length ? near : cands; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x + 0.5, y: pick.y + 0.5 }; +} + +function clearSpecialQuizTimers(space) { + if (Array.isArray(space.specialQuizTimers)) { + space.specialQuizTimers.forEach((t) => clearTimeout(t)); + } + space.specialQuizTimers = []; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + space.specialQuizAwaitContinue = false; +} + +/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */ +function specialQuizIconPayload(space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done || !sq.appeared) return null; + return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 }; +} + +/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง (1 ใบต่อครั้งที่ติด — ดีไซน์ "1 ใน 3") + Test Mode (testSpecialCardByMap / forceSpecialQuiz) → ข้าม roll ขึ้นเสมอ */ +function maybeSpawnSpecialQuizForRun(space) { + clearSpecialQuizTimers(space); + space.specialQuiz = null; + space.specialCardAwardedThisRun = null; + space.timeDilationPendingSec = 0; + /* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้ + หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */ + if (!space || !space.detectiveMinigameActive) return; + const mapId = String(space.mapId || '').trim(); + if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return; + const level = Number(space.detectiveLobbyLevel) || 1; + const caseId = Number(space.detectiveLobbyCaseId) || 1; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, level, caseId); + const questions = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + let card = set && set.specialCard; + /* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */ + const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId]; + const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7); + if (testForcedCard) { + const def = specialCardDef(testCardId) || {}; + card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th }); + } + const hasCard = !!(card && (card.cardId || card.imageUrl)); + if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; } + const forced = !!space.forceSpecialQuiz || testForcedCard; + const roll = Math.random(); + if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; } + if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; } + /* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */ + const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); + const tile = pickSpecialQuizSpawnTile(md, occupiedLanes); + if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; } + const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)]; + console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y); + space.specialQuiz = { + iconType, + x: tile.x, + y: tile.y, + level, + caseId, + card, + triggered: false, + done: false, + session: null, + }; +} + +function emitSpecialQuizIcon(sid, space) { + const payload = specialQuizIconPayload(space); + if (payload) io.to(sid).emit('special-quiz-icon', payload); +} + +/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */ +const SPECIAL_QUIZ_ICON_DELAY_MS = 2000; +function scheduleSpecialQuizIconAppearAndExpiry(sid, space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done) return; + const tAppear = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return; + const cur = sp.specialQuiz; + const sec = readSpecialQuizIconExpireSec(); + cur.appeared = true; + cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0; + emitSpecialQuizIcon(sid, sp); + if (sec > 0) { + const tExpire = setTimeout(() => { + const sp2 = spaces.get(sid); + if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return; + sp2.specialQuiz = null; + io.to(sid).emit('special-quiz-icon-clear', {}); + console.log('[special-quiz] icon EXPIRED after', sec + 's'); + }, sec * 1000); + if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = []; + sp.specialQuizTimers.push(tExpire); + } + }, SPECIAL_QUIZ_ICON_DELAY_MS); + if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = []; + space.specialQuizTimers.push(tAppear); +} + +/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */ +function buildSpecialQuizRoster(space) { + const roster = []; + space.peers.forEach((p, id) => { + roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }); + }); + const botN = effectiveBotSlotCount(space); + for (let i = 0; i < botN; i++) { + roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true }); + } + return roster; +} + +function specialQuizRosterPayload(sess) { + return (sess && Array.isArray(sess.roster) ? sess.roster : []) + .map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot })); +} + +/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */ +function specialQuizExpectedIds(space, sess) { + const realAlive = new Set(space.peers.keys()); + const out = []; + (sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => { + if (m.isBot) out.push(m.id); + else if (realAlive.has(m.id)) out.push(m.id); + }); + return out; +} + +/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ +function maybeResolveSpecialQuizAllAnswered(sid, space) { + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return; + const expected = specialQuizExpectedIds(space, sess); + const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid])); + if (allAnswered) resolveSpecialQuizQuestion(sid, space, false); +} + +/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */ +function scheduleSpecialQuizBotAnswers(sid, space, sess, q) { + const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot); + if (!bots.length) return; + const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500); + bots.forEach((b) => { + const delay = 900 + Math.floor(Math.random() * windowMs); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session; + if (!spNow || sNow !== sess || sess.resolving) return; + if (Number.isInteger(sess.answers[b.id])) return; + sess.answers[b.id] = q.correctIndex; + io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true }); + maybeResolveSpecialQuizAllAnswered(sid, spNow); + }, delay); + space.specialQuizTimers.push(t); + }); +} + +function sendSpecialQuizQuestion(sid, space) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess) return; + const q = sess.questions[sess.qIndex]; + if (!q) { endSpecialQuizSession(sid, space, false); return; } + sess.answers = {}; + sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS; + io.to(sid).emit('special-quiz-question', { + index: sess.qIndex, + total: sess.questions.length, + text: q.text, + choices: q.choices, + endsAt: sess.phaseEndsAt, + iconType: sq.iconType, + roster: specialQuizRosterPayload(sess), + }); + scheduleSpecialQuizBotAnswers(sid, space, sess, q); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + resolveSpecialQuizQuestion(sid, spNow, true); + }, SPECIAL_QUIZ_ANSWER_MS + 80); + space.specialQuizTimers.push(t); +} + +/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */ +function resolveSpecialQuizQuestion(sid, space, fromTimeout) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess || sess.resolving) return; + sess.resolving = true; + clearSpecialQuizTimers(space); + const q = sess.questions[sess.qIndex]; + const expected = specialQuizExpectedIds(space, sess); + let allCorrect = expected.length > 0; + expected.forEach((pid) => { + const a = sess.answers[pid]; + if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false; + }); + io.to(sid).emit('special-quiz-result', { + index: sess.qIndex, + correctIndex: q.correctIndex, + answers: { ...sess.answers }, + roster: specialQuizRosterPayload(sess), + allCorrect, + timeout: !!fromTimeout, + }); + if (!allCorrect) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, false); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, true); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + sess.resolving = false; + sendSpecialQuizQuestion(sid, spNow); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); +} + +/** + * นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน: + * minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation) + * now = ใช้ทันทีตอนได้รับ (Fund coins) + * after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence) + * pre_trial = ก่อนโหวตพิจารณาคดี (Silence) + * trial_vote = ระหว่างโหวตพิจารณาคดี (Extension) + * trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin) + * lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban) + */ +const SPECIAL_CARD_DEFS = { + 1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' }, + 2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' }, + 3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' }, + 4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' }, + 5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' }, + 6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' }, + 7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, +}; + +function specialCardDef(cardId) { + return SPECIAL_CARD_DEFS[Number(cardId)] || null; +} + +/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */ +function specialCardClientPayload(card) { + if (!card) return null; + const def = specialCardDef(card.cardId) || {}; + return { + cardId: card.cardId || null, + th: card.th || def.th || '', + en: card.en || '', + rarity: card.rarity || 'common', + imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''), + txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''), + effectKey: def.key || '', + effectWhen: def.when || '', + addSec: def.addSec || 0, + desc: def.th || '', + }; +} + +/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */ +const TIME_DILATION_MAX_PENDING_SEC = 30; + +/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */ +function applySpecialCardImmediate(sid, space, card) { + const def = specialCardDef(card && card.cardId); + if (!def) return; + if (def.when === 'minigame' && def.key === 'time_dilation') { + /* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง + cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */ + space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10)); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + addSec: def.addSec || 10, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + if (def.when === 'now' && def.key === 'fund') { + /* +COINS — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม + noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */ + const coins = def.coins || 10; + const awards = [...space.peers.values()] + .filter((p) => p.playerKey) + .map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true })); + submitGameAwards(awards); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + coins: coins, + coinsServerAwarded: true, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) + — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ +} + +/* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */ +function dbgCards(space) { + return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : ''))); +} +function pendingSpecialCardList(space) { + if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = []; + return space.pendingSpecialCards; +} +/** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */ +function addPendingSpecialCard(space, card) { + if (!card || !card.cardId) return null; + const list = pendingSpecialCardList(space); + const cid = Number(card.cardId); + if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; } + const entry = { cardId: cid, card: card, consumed: false }; + list.push(entry); + console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space)); + return entry; +} +/** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */ +function findPendingSpecialCard(space, cardId) { + const cid = Number(cardId); + return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null; +} +function consumePendingSpecialCard(space, cardId) { + const e = findPendingSpecialCard(space, cardId); + if (e) e.consumed = true; +} + +function endSpecialQuizSession(sid, space, success) { + const sq = space.specialQuiz; + if (!sq) return; + clearSpecialQuizTimers(space); + sq.done = true; + sq.session = null; + let cardOut = null; + console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId)); + if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { + space.specialCardAwardedThisRun = sq.card; + cardOut = specialCardClientPayload(sq.card); + addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */ + applySpecialCardImmediate(sid, space, sq.card); + } + io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); + /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) + — มี safety timeout กันค้างถ้าไม่มีใครกด */ + space.specialQuizAwaitContinue = true; + space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */ + /* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */ + io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size }); + if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = setTimeout(() => { + space.specialQuizContinueTimer = null; + finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */ + }, 32000); +} + +/** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */ +function maybeFinishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set()); + const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */ + const ready = expected.filter((pid) => acks.has(pid)).length; + io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length }); + if (expected.length === 0 || expected.every((pid) => acks.has(pid))) { + finishSpecialQuizContinue(sid, space); + } +} + +/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ +function finishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + space.specialQuizAwaitContinue = false; + space.specialQuizContinueAcks = null; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + /* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */ + io.to(sid).emit('special-quiz-resume-all', {}); + resumeQuizAfterSpecialQuiz(sid, space); +} + +/* เกมหยุดชั่วคราวระหว่างคำถามพิเศษ (กำลังตอบ หรือรอกด «เล่นต่อ») + ใช้ร่วมกันทุก minigame ฝั่ง server — เดิมมีแต่ gauntlet เช็ค ทำให้ MG6/MG7 บอทยังเดิน/ยิงตอน pause */ +function isSpecialQuizPausedServer(space) { + if (!space) return false; + return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)); +} + +function startSpecialQuizSession(sid, space) { + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return false; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, sq.level, sq.caseId); + const all = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + if (!all.length) return false; + /* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */ + const picked = specialQuizShuffle(all).slice(0, 1); + sq.triggered = true; + sq.session = { + questions: picked, + qIndex: 0, + answers: {}, + phaseEndsAt: 0, + resolving: false, + roster: buildSpecialQuizRoster(space), + }; + clearSpecialQuizTimers(space); + pauseQuizForSpecialQuiz(sid, space); + io.to(sid).emit('special-quiz-icon-clear', {}); + sendSpecialQuizQuestion(sid, space); + return true; +} + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 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, + }; +} + +/** อ่านเวลา 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, + }; + } + } 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), + /* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */ + lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud') + ? !!d.lobbyDebugHud + : !!prev.lobbyDebugHud, + lobbyDebugHudNames: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHudNames') + ? (Array.isArray(d.lobbyDebugHudNames) ? d.lobbyDebugHudNames.slice(0, 30).map((s) => String(s).slice(0, 40)) : []) + : (Array.isArray(prev.lobbyDebugHudNames) ? prev.lobbyDebugHudNames : []), + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const specialQuizSets = d.specialQuizSets != null + ? sanitizeSpecialQuizSets(d.specialQuizSets) + : sanitizeSpecialQuizSets(prev.specialQuizSets); + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + specialQuizSets, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + specialQuizSets: out.specialQuizSets, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + let cw = Math.floor(Number(md.characterCellsW)); + let ch = Math.floor(Number(md.characterCellsH)); + const hasW = Number.isFinite(cw) && cw >= 1; + const hasH = Number.isFinite(ch) && ch >= 1; + if (hasW && hasH) { + return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; + } + const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); + return { cw: leg, ch: leg }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(md); + let colW = Math.floor(Number(md.characterCollisionW)); + let colH = Math.floor(Number(md.characterCollisionH)); + if (!Number.isFinite(colW) || colW < 1) colW = cw; + if (!Number.isFinite(colH) || colH < 1) colH = ch; + colW = Math.max(1, Math.min(cw, colW)); + colH = Math.max(1, Math.min(ch, colH)); + return { cw, ch, colW, colH }; +} + +const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; +const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; + +function quizBattleSpriteBoundsTileKeys(md, px, py) { + const out = []; + if (!md || md.gameType !== 'quiz_battle') return out; + const x = Number(px), y = Number(py); + if (!Number.isFinite(x) || !Number.isFinite(y)) return out; + const w = md.width || 20, h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const cx = x + cw * 0.5; + const feetY = y + ch; + const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; + const bottom = feetY; + if (left < 0 || right > w || top < 0 || bottom > h) return out; + const minTx = Math.floor(left); + const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); + const minTy = Math.floor(top); + const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`); + } + return out; +} + +function serverWallCollisionTileKeys(md, px, py) { + if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(md, px, py); + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + for (let ty = offY; ty < offY + colH; ty++) { + for (let tx = offX; tx < offX + colW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; +} + +function validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */ +function pauseQuizForSpecialQuiz(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.active || space.quizFrozenForSpecial) return; + const phase = sess.phase; + if (phase !== 'read' && phase !== 'answer') return; + const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now()); + space.quizFrozenForSpecial = true; + space.quizFreezePhase = phase; + space.quizFreezeRemainingMs = remaining; + clearSpaceQuizTimers(space); + io.to(sid).emit('quiz-paused', { phase }); +} + +/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */ +function resumeQuizAfterSpecialQuiz(sid, space) { + if (!space.quizFrozenForSpecial) return; + space.quizFrozenForSpecial = false; + const sess = space.quizSession; + const phase = space.quizFreezePhase; + const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0); + space.quizFreezePhase = null; + space.quizFreezeRemainingMs = 0; + if (!sess || !sess.active) return; + const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space); + if (!md) return; + /* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */ + let extraMs = 0; + if (space.timeDilationPendingSec > 0) { + extraMs = space.timeDilationPendingSec * 1000; + space.timeDilationPendingSec = 0; + } + sess.phaseEndsAt = Date.now() + remaining + extraMs; + if (phase === 'read') { + sess.phase = 'read'; + const q = sess.questions[sess.qIndex]; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + text: String((q && q.text) || '').trim(), + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining); + space.quizTimers.push(t); + } else { + sess.phase = 'answer'; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining); + space.quizTimers.push(t); + } +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function ensurePeerLobbyThemes(space) { + if (!space || !space.peers) return; + const seen = new Set(); + space.peers.forEach((p, id) => { + let ti = parseInt(p.lobbyColorThemeIndex, 10); + if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) { + ti = pickFreeLobbyThemeIndex(space, id); + p.lobbyColorThemeIndex = ti; + } + seen.add(ti); + if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) { + p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0); + } + }); +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null, + lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, + lobbySkinToneIndex: p.lobbySkinToneIndex || null, + bannedSpectator: !!p.bannedSpectator, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ + → client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client + เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */ +/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */ +function isBannedPeerForRun(space, id) { + if (!space) return false; + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true; + const pp = space.peers && space.peers.get(id); + return !!(pp && pp.bannedSpectator); +} + +/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */ +function readyMapWithoutBanned(space, srcMap) { + const out = {}; + if (srcMap && typeof srcMap === 'object') { + Object.keys(srcMap).forEach((id) => { + if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id]; + }); + } + return out; +} + +function quizCarryLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady), + peers: buildPeersSnapForSpace(space), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + }; +} + +/* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน + แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */ +function crownLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + peers: buildPeersSnapForSpace(space), + }; +} + +/* เช็คว่าพร้อมเริ่มมินิเกม crown-lobby ไหม: ยังไม่เริ่ม + เข้าครบ + ทุกคน (ไม่นับ banned) กด Ready */ +function crownLobbyAllReadyToStart(space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)); + if (humanIds.length === 0) return false; + const expectedActive = space.minigameExpectedActiveCount || 0; + if (expectedActive > 0 && humanIds.length < expectedActive) return false; + return humanIds.every((id) => rm[id]); +} + +/* เริ่มมินิเกม crown-lobby (server เป็นคนสั่ง) — idempotent ด้วย gr.lobbyStarted (รีเซ็ตเองทุกมินิเกมเพราะ gr สร้างใหม่) + ใช้ทั้ง host กด START และ auto-start เมื่อทุกคน ready (Track B: MG1 ไม่ต้องพึ่ง host) */ +function performCrownLobbyStart(sid, space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + gr.lobbyStarted = true; + const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; + space.missionLiveBeginsAt = liveBeginsAt; + if (isQuizQuestionMissionShellSpace(space)) { + /* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ตรงกับตอน client จบ 3-2-1) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'quiz') { + clearSpaceQuizTimers(sp); + startQuizGame(sid, sp, md2); + } + }, LIVE_COUNTDOWN_MS); + } + if (isStackTowerMissionShellSpace(space)) { + /* MG3 stack: เริ่ม server engine ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'stack') startStackTicker(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isJumpSurviveMissionShellSpace(space)) { + /* MG5 jump_survive: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'jump_survive') startJumpSurviveGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isSpaceShooterMissionShellSpace(space)) { + /* MG6 space_shooter: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'space_shooter') startSpaceShooterGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isBalloonBossMissionShellSpace(space)) { + /* MG7 balloon_boss: เริ่ม server engine (บอท+รอบ+HP บอส) ตอน client จบ countdown */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'balloon_boss') startBalloonBossGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt }); + return true; +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +/* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด + ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */ +function detectiveLobbyBMissingParticipants(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 }; + const present = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) present.add(n); + }); + const total = roster.size; + return { missing: Math.max(0, total - present.size), present: present.size, total }; +} + +/* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */ +function emitDetectiveLobbyBPresence(sid, space) { + if (!sid || !space) return; + if (!serverMapIsPostCaseLobbyB(space)) return; + const info = detectiveLobbyBMissingParticipants(space); + if (info.total <= 0) return; + io.to(sid).emit('detective-lobbyb-presence', { + present: info.present, + total: info.total, + allHere: info.missing === 0, + }); +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + /** โหมดเทสต์ (admin game-timing): เลือกเกมต่อการ์ด 3 ใบ — ช่องที่ตั้ง key = เจาะจง, ช่องว่าง = สุ่ม */ + const forcedKeys = normalizeForcedMinigameKeys(runtimeGameTiming && runtimeGameTiming.forcedMinigameKeys); + const randomPool = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + let ri = 0; + const pick3 = []; + for (let i = 0; i < 3; i++) { + const fk = forcedKeys[i]; + let entry = fk ? available.find((e) => e.key === fk) : null; + if (!entry) { entry = randomPool[ri % randomPool.length]; ri += 1; } + pick3.push(entry); + } + space.suspectCardMinigames = pick3.map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); + // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ + space.playerEvidence = {}; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; +} + +function ensureSpacePlayerEvidence(space) { + if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {}; + if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {}; +} + +/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */ +function normalizeEvidenceSlotList(cards) { + if (!Array.isArray(cards)) return []; + const out = []; + for (let i = 0; i < cards.length; i++) { + const c = sanitizeStoredEvidenceCard(cards[i]); + if (c) out.push(c); + } + return out; +} + +/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */ +function peerNickKey(space, playerId) { + if (!space || !playerId) return ''; + const p = space.peers && space.peers.get ? space.peers.get(playerId) : null; + if (!p || !p.nickname) return ''; + return normalizeLobbyNickname(p.nickname); +} + +/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */ +function clonePlayerEvidence(space, playerId) { + ensureSpacePlayerEvidence(space); + let row = space.playerEvidence[playerId]; + if (!row || !row.length) { + const nk = peerNickKey(space, playerId); + if (nk && space.evidenceByNick[nk]) { + row = space.evidenceByNick[nk]; + space.playerEvidence[playerId] = row; + } + } + if (!row) row = [[], [], []]; + return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i])); +} + +/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */ +function syncEvidenceByNick(space, playerId) { + ensureSpacePlayerEvidence(space); + const nk = peerNickKey(space, playerId); + if (!nk) return; + const row = space.playerEvidence[playerId]; + if (Array.isArray(row)) space.evidenceByNick[nk] = row; +} + +/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */ +function playerSuspectProgressFromEvidence(space, playerId) { + const row = clonePlayerEvidence(space, playerId); + return row.map((arr) => arr.length); +} + +/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */ +function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) { + if (suspectIndex < 0 || suspectIndex > 2) return null; + ensureSpacePlayerEvidence(space); + /* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */ + if (!space.playerEvidence[playerId]) { + const nk = peerNickKey(space, playerId); + space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk]) + ? space.evidenceByNick[nk] + : [[], [], []]; + } + const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]); + /* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */ + if (list.length >= 3) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + const card = pickEvidenceCardForSuspect(space, suspectIndex, grade); + if (!card) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + list.push(card); + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return card; +} + +function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) { + const out = {}; + (playerIds || []).forEach((pid) => { + if (!space.peers.has(pid)) return; + const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade); + if (card != null) out[pid] = card; + }); + return out; +} + +function resolveDetectiveSuspectAwardIndex(space) { + if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) { + return space.suspectActiveIndex; + } + if (typeof space.lastDetectiveInvestigatedSuspect === 'number' + && space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) { + return space.lastDetectiveInvestigatedSuspect; + } + if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) { + return space.suspectPickIndex; + } + return -1; +} + +/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */ +function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { + const suspectIdx = resolveDetectiveSuspectAwardIndex(space); + if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} }; + if (space.detectiveAwardDoneForRun) { + return { suspectIdx, awarded: {}, alreadyGranted: true }; + } + const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()]; + const grade = computeRunGradeForEvidence(space); + const awardedList = {}; + ids.forEach((pid) => { awardedList[pid] = []; }); + const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade); + ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); }); + /* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */ + let freeEvidenceCount = 0; + const pend = findPendingSpecialCard(space, 7); + if (pend) { + const def = specialCardDef(7); + freeEvidenceCount = (def && def.evidence) || 1; + for (let n = 0; n < freeEvidenceCount; n++) { + ids.forEach((pid) => { + if (!space.peers.has(pid)) return; + const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade); + if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; } + }); + } + pend.consumed = true; + } + space.detectiveAwardDoneForRun = true; + space.lastDetectiveInvestigatedSuspect = suspectIdx; + space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount }; + emitLobbyEvidenceSync(sid, space); + return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount }; +} + +function emitLobbyEvidenceSync(sid, space) { + if (!sid || !space) return; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('lobby-evidence-sync', { + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) { + const msg = message || 'จบมินิเกม — กลับ LobbyB'; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + const personal = { + ...basePayload, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }; + sock.emit('quiz-ended', { message: msg, returnToLobbyB: true }); + sock.emit('detective-minigame-ended', personal); + sock.emit('game-start', personal); + }); +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveAwardDoneForRun = false; + space.coinAwardDoneForRun = false; + space.lastCrownMissionGrade = null; + /* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */ + space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; }); + /* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */ + space.lastMinigameOver = null; + /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ + space.bannedThisRunPlayerId = space.bannedPlayerId || null; + console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId); + space.bannedPlayerId = null; + /* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน) + สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */ + space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + glog(sid, space, 'game-start', 'เริ่มมินิเกม: ' + ((md && md.name) || mapId) + ' (การ์ด ' + ((selectedIndex != null ? selectedIndex + 1 : '?')) + ')'); + /* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */ + maybeSpawnSpecialQuizForRun(space); + /* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */ + + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */ + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + /** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */ + const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID; + if (!isQuizMissionShell) { + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + /* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */ + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + /* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client + กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */ + if (md.gameType === 'quiz_carry') { + space.quizCarryLobbyReady = {}; + space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; }); + io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) { + ensurePostCaseLobbyMapLoaded(); + /* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */ + awardMinigameRankCoins(sid, space); + clearSpaceQuizTimers(space); + clearSpecialQuizTimers(space); + space.specialQuiz = null; + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + /* Last Light (MG2): เก็บเกรด mission (รวมบอท server-authoritative) ก่อนล้าง gauntletRun + → ใช้เป็น "แหล่งเดียว" ของเกรดหลักฐาน ให้ตรงกับสรุปภารกิจที่ผู้เล่นเห็น */ + if (space.gauntletRun && isGauntletCrownHeistMapBySpace(space)) { + try { space.lastCrownMissionGrade = buildGauntletCrownMissionPayload(space).grade; } + catch (_e) { space.lastCrownMissionGrade = null; } + } + stopGauntletTicker(space); + space.gauntletRun = null; + /* MG3 stack (server-auth): หยุด engine + ล้าง session (เกรดถูกเก็บไว้ใน lastStackMissionGrade ตอนจบแล้ว) */ + stopStackTicker(space); + if (space.stackSession) space.stackSession.active = false; + space.stackSession = null; + /* MG4 quiz_carry (server-auth): ล้าง session (คะแนนถูกอ่านไปแจกเหรียญใน awardMinigameRankCoins ด้านบนแล้ว) */ + if (space.quizCarrySession) space.quizCarrySession.active = false; + space.quizCarrySession = null; + 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; + let awardIds = []; + if (awardOptions && awardOptions.allPlayers) { + awardIds = [...space.peers.keys()]; + } else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) { + awardIds = [awardOptions.playerId]; + } else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) { + awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id)); + } + /* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */ + awardIds = awardIds.filter((id) => { + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false; + const pp = space.peers.get(id); + if (pp && pp.bannedSpectator) return false; + return true; + }); + /* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */ + if (!space.detectiveAwardDoneForRun && awardIds.length) { + /* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */ + grantDetectiveEvidenceForCurrentRun(sid, space, awardIds); + } + } + space.suspectActiveIndex = null; + space.bannedThisRunPlayerId = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const basePayload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + detectiveReturn: true, + }; + emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload); + /* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ" + ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */ + const banPend = findPendingSpecialCard(space, 3); + console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space)); + if (banPend && !space.pickVote) { + banPend.consumed = true; + /* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */ + space.pendingBanVoteCard = banPend.card; + } +} + +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: trialEligibleVoterTotal(space), + }); +} + +function computeAndEmitTrialResult(sid, space) { + /* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */ + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + /* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */ + { + /* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */ + const forceWrong = !!space.testForceWrongVote; + space.testForceWrongVote = false; + const c2 = [0, 0, 0]; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; }); + let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; } + const anyVotes = (c2[0] + c2[1] + c2[2]) > 0; + const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit); + const pend = findPendingSpecialCard(space, 5); + if (wrong && pend && !space.trialBailUsed) { + pend.consumed = true; + space.trialBailUsed = true; + space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */ + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); + openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted }); + return; + } + } + space.trialPhase = 'revealed'; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + if (p && p.nickname) return p.nickname; + if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) { + const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10); + return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท'; + } + return String(id).slice(0, 6); + }); + /* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */ + let disruptorId = null; + if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) { + for (const [pid, p] of space.peers) { + const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey; + const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname; + if (keyMatch || nickMatch) { disruptorId = pid; break; } + } + } + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + disruptorId, + hasDisruptor: !!disruptorId, + }); + + /* ===== Achievement auto-track ===== */ + try { + const achv = []; + /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */ + winners.forEach((id) => { + const p = space.peers.get(id); + if (p && p.playerKey) { + achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 }); + achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 }); + } + }); + /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */ + if (disruptorId && space.disruptorPlayerKey) { + const dKey = space.disruptorPlayerKey; + achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */ + /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */ + let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; } + const anyV = (counts[0] + counts[1] + counts[2]) > 0; + if (anyV && mv !== culprit) { + achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */ + } + } + submitAchievementProgress(achv); + } catch (e) { console.error('[achv] trial-result', e && e.message); } + + space.silencedPlayerId = null; +} + +/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ +/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */ +const TESTIMONY_BOT_PREFIX = '__lobby_bot_'; + +function testimonyBotIds(space) { + const n = effectiveBotSlotCount(space); + const out = []; + for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i); + return out; +} + +function buildTestimonyMembers(space) { + const arr = []; + space.peers.forEach((p, id) => { + arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false }); + }); + testimonyBotIds(space).forEach((bid, i) => { + arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true }); + }); + return arr; +} + +/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */ +function testimonyTotalParticipants(space) { + return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); +} + +/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */ +function botTestimonyPickCount(space) { + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + let maxOwned = 0; + space.peers.forEach((_p, pid) => { + const ev = clonePlayerEvidence(space, pid); + const len = Array.isArray(ev[round]) ? ev[round].length : 0; + if (len > maxOwned) maxOwned = len; + }); + /* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */ + if (maxOwned === 0) return 1; + return Math.max(1, Math.min(2, maxOwned)); +} + +/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */ +function autoSubmitBotTestimonyPicks(space) { + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + const want = botTestimonyPickCount(space); + testimonyBotIds(space).forEach((bid) => { + if (space.testimonySubmitted[bid] != null) return; + const picks = []; + for (let n = 0; n < want; n++) { + const c = pickEvidenceCardForSuspect(space, round, 'C'); + if (c) picks.push(c); + } + space.testimonySubmitted[bid] = picks; + }); +} + +function emitTestimonyOpen(sid, space) { + space.testimonySubmitted = {}; + space.testimonyReadyNext = {}; + space.testimonyRevealed = false; + /* บอทส่งหลักฐานทันทีเมื่อเปิด round */ + autoSubmitBotTestimonyPicks(space); + const submittedIds = Object.keys(space.testimonySubmitted || {}); + const base = { + round: space.testimonyRound, + suspectIndex: space.testimonyRound, + members: buildTestimonyMembers(space), + totalPlayers: testimonyTotalParticipants(space), + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + submitted: submittedIds, + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('testimony-open', { + ...base, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + /* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */ + emitTestimonyStatus(sid, space); +} + +function emitTestimonyStatus(sid, space) { + io.to(sid).emit('testimony-status', { + round: space.testimonyRound, + submitted: Object.keys(space.testimonySubmitted || {}), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +function doTestimonyReveal(sid, space) { + space.testimonyRevealed = true; + /* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */ + autoSubmitBotTestimonyPicks(space); + /* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + io.to(sid).emit('testimony-reveal', { + round: space.testimonyRound, + picks: space.testimonySubmitted || {}, + members: buildTestimonyMembers(space), + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */ +/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */ +/* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */ +function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; } +const TRIAL_VOTE_EXTENSION_MS = 10000; + +function clearTrialVoteTimer(space) { + if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; } +} + +/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */ +function openTrialVotingPhase(sid, space, opts) { + opts = opts || {}; + space.testimonyActive = false; + space.trialPhase = 'voting'; + space.trialVotes = {}; + /* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */ + const excludedSuspect = opts.revote + ? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect + : (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null)) + : null; + if (!opts.revote) space.trialRevoteExcluded = null; + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ + let extraMs = 0; + let extensionCard = false; + const pend = findPendingSpecialCard(space, 2); + if (pend) { + extraMs = TRIAL_VOTE_EXTENSION_MS; + extensionCard = true; + pend.consumed = true; + } + const durMs = trialVoteMs() + extraMs; + space.trialVoteEndsAt = Date.now() + durMs; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + voteEndsAt: space.trialVoteEndsAt, + voteDurationMs: durMs, + extensionCard, + extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0, + revote: !!opts.revote, + excludedSuspect: excludedSuspect, + silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId), + }); + }); + if (extensionCard) { + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(pend.card), + addSec: TRIAL_VOTE_EXTENSION_MS / 1000, + context: 'trial', + }); + } + emitTrialVoteUpdate(sid, space); + space.trialVoteTimer = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.trialPhase !== 'voting') return; + computeAndEmitTrialResult(sid, spNow); + }, durMs + 120); +} + +/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */ +/* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */ +function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; } + +function clearPickVoteTimer(space) { + if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; } +} + +function pickVoteCandidates(space) { + const arr = []; + space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false })); + testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true })); + return arr; +} + +function autoVotePickBots(space) { + const pv = space.pickVote; + if (!pv) return; + testimonyBotIds(space).forEach((bid) => { + if (pv.votes[bid] != null) return; + const choices = pv.candidates.filter((c) => c.id !== bid); + if (!choices.length) return; + pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id; + }); +} + +function emitPickVoteUpdate(sid, space) { + const pv = space.pickVote; + if (!pv) return; + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + io.to(sid).emit('player-pick-vote-update', { + voted: Object.keys(pv.votes).length, + total: pv.candidates.length, + counts, + }); +} + +function startPlayerPickVote(sid, space, purpose, onResolve) { + clearPickVoteTimer(space); + const PV_MS = pickVoteMs(); + const candidates = pickVoteCandidates(space); + space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_MS, resolved: false, onResolve }; + autoVotePickBots(space); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('player-pick-vote-open', { + purpose, + candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: space.pickVote.endsAt, + durationMs: PV_MS, + }); + }); + emitPickVoteUpdate(sid, space); + space.pickVoteTimer = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.pickVote || sp.pickVote.resolved) return; + resolvePlayerPickVote(sid, sp); + }, PV_MS + 120); + maybeResolvePickVote(sid, space); +} + +function maybeResolvePickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + const ids = pv.candidates.map((c) => c.id); + if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space); +} + +function resolvePlayerPickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + pv.resolved = true; + clearPickVoteTimer(space); + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + let max = 0; + Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); + const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); + /* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */ + const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : null; + const tc = pv.candidates.find((c) => c.id === target); + const targetName = tc ? tc.nickname : ''; + io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts }); + const cb = pv.onResolve; + space.pickVote = null; + if (typeof cb === 'function') cb(target, targetName); +} + +function trialSilencedIsParticipant(space) { + const s = space.silencedPlayerId; + if (!s) return false; + if (space.peers.has(s)) return true; + return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0); +} + +function trialEligibleVoterTotal(space) { + let t = testimonyTotalParticipants(space); + if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1); + return t; +} + +function autoVoteBotsTrial(space) { + if (!space.trialVotes) space.trialVotes = {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3); + const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1; + testimonyBotIds(space).forEach((bid) => { + if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */ + if (space.trialVotes[bid] != null) return; + /* 40% โหวตถูก, 60% โหวตสุ่ม */ + let pick; + if (Math.random() < 0.4) { + pick = culprit; + } else { + pick = Math.floor(Math.random() * 3); + } + /* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */ + if (pick === excluded) pick = culprit; + space.trialVotes[bid] = pick; + }); +} + +function advanceTestimony(sid, space) { + space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; + if (space.testimonyRound >= 3) { + space.trialBailUsed = false; + space.silencedPlayerId = null; + /* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */ + const pend = findPendingSpecialCard(space, 4); + console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space)); + if (pend) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' }); + startPlayerPickVote(sid, space, 'silence', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.silencedPlayerId = targetId || null; + openTrialVotingPhase(sid, sp, { revote: false }); + }); + return; + } + openTrialVotingPhase(sid, space, { revote: false }); + } else { + emitTestimonyOpen(sid, space); + } +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + /* คะแนนสุดท้าย authoritative (ผู้เล่นจริง) — ส่งไปกับ quiz-ended ให้ทุก client ตัดสินผล complete/gameover ตรงกัน + (เดิมไม่ส่ง → client อ่าน playLiveQuizScores local ที่ quiz-result ข้อสุดท้ายอาจมาไม่ทัน → ผล 2 จอไม่ตรง) */ + const quizFinalScores = {}; + const sessF = space.quizSession; + if (sessF && sessF.players) { + Object.keys(sessF.players).forEach((pid) => { + const st = sessF.players[pid]; + quizFinalScores[pid] = (st && typeof st.score === 'number') ? Math.max(0, st.score) : 0; + }); + } + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) + เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" + การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true, scores: quizFinalScores }); + /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ + (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', scores: quizFinalScores }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + /* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */ + const qCur = sess.questions[sess.qIndex]; + if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + /* บอท: server เป็นเจ้าของคะแนน (จากคำตอบที่ตัดสินไว้) + คัดออกถ้าผิด (กฎเดียวกับคน "ตอบผิดถูกคัดออก") */ + if (sess.bots) { + sess.bots.forEach((b, botId) => { + if (!b.eliminated) { + if (b.answerRight) { + b.score += QUIZ_TF_POINTS_PER_CORRECT; + } else { + b.eliminated = true; + /* คัดออก → วาร์ปเข้าโซนผีทันที (เหมือนคนจริง) */ + const gc = quizRandomCellInZone(mdLive.quizGhostArea, mdLive); + if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; } + b.wanderTX = null; b.retargetAt = 0; + } + } + scores[botId] = b.score; + results.push({ + id: botId, nickname: 'บอท', right: !!b.answerRight, choice: b.choice, + eliminated: !!b.eliminated, score: b.score, + }); + b.tx = null; b.ty = null; /* รีเซ็ตเป้า → เดินมั่วจนข้อถัดไป */ + }); + } + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + /** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */ + const playFullQuizRound = + !!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space); + if (allWrong && !playFullQuizRound) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + space.quizSession.bots = createQuizBots(space, md); + scheduleQuizReadPhase(sid, space, md); +} + +/* ===== MG1 quiz bots — server-authoritative (roster + เดิน + ตอบ + คะแนน) ===== + เดิม host เดิน+คิดคะแนนบอทเอง → host หลุด = บอทหยุด + คะแนนเพี้ยน. ย้ายมา server → บอทเดินเอง time-based + คะแนน server เป็นเจ้าของ + id `__pv_bot_` 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; + try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId || '-') + ' 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, + 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) => { + /* ทับ 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', 'push-out ' + bid + ' (' + fromX.toFixed(2) + ',' + fromY.toFixed(2) + ')->(' + b.x.toFixed(2) + ',' + b.y.toFixed(2) + ')'); } 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; + } + 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); +} + +/* ── 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, + }; +} + +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 = 1500; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป */ +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, + }); +} +function stackAdvanceTurnServer(s) { + if (!s.turnOrder || !s.turnOrder.length) return; + s.turnIndex = (s.turnIndex + 1) % s.turnOrder.length; + s.botThinkUntil = 0; + s.botAimPhase = null; +} +function stackSessionMissionGrade(space) { + const s = space.stackSession; + if (!s) return 'C'; + const scores = []; + s.turnOrder.forEach((e) => { + if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); scores.push(Math.max(0, (b && b.score) | 0)); } + else if (e.id != null) scores.push(Math.max(0, s.humanPts[e.id] | 0)); + }); + const n = scores.length || 1; + const avg = Math.floor(scores.reduce((a, b) => a + b, 0) / n); + let grade = avg >= 80 ? 'A' : avg >= 60 ? 'B' : 'C'; + return grade; +} +function stackSessionEnd(sid, space, outcome) { + const s = space.stackSession; + if (!s || s.ended) return; + s.ended = true; + stopStackTicker(space); + /* คะแนนผู้เล่นจริง → reportedMiniScore (ใช้ทางเดิมของ coin/evidence) */ + s.turnOrder.forEach((e) => { + if (e.kind === 'human' && e.id != null) { + const p = space.peers.get(e.id); + if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; } + } + }); + try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; } + const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' }; + io.to(sid).emit('stack-over', overPayload); + space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() }; +} +function runStackTick(sid, space) { + const s = space.stackSession; + if (!s || !s.active || s.ended) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; } + const now = Date.now(); + /* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */ + if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; } + if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; } + if ((now - s.startMs) >= stackTimeLimitSecServer() * 1000) { stackSessionEnd(sid, space, 'time_up'); return; } + if (!s.turnOrder.length) return; + /* รอแอนิเมชันบล็อกก่อนหน้า (busy) → ไม่แกว่ง/ไม่ดรอป */ + if (now < s.busyUntil) return; + const entry = stackCurrentTurnEntry(s); + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + const bot = s.bots.get(entry.botId); + const tier = bot ? bot.tier : 'avg'; + if (s.botThinkUntil === 0) { + s.botThinkUntil = now + STACK_BOT_THINK_MIN_MS + Math.floor(Math.random() * (STACK_BOT_THINK_MAX_MS - STACK_BOT_THINK_MIN_MS)); + } else { + /* เลื่อนเฟสตามเวลา (ให้ client เห็นแกว่ง) */ + s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2; + /* ส่ง swingAmp+topCenterX ด้วย → client วาดบล็อกที่ห้อยตรงกับโมเดล server เป๊ะ (drop landOffset = swingAmp*sin(phase)) ไม่วาปตอนปล่อย */ + if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX, 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; + const _dbgAim = s.botAimPhase; const _dbgDeadline = (now >= s.botAimDeadline) && !(s.phase >= s.botAimPhase); + s.botAimPhase = null; + const hit = stackComputeDropServer(s, md); + /* [DBG-MG3drop] บอทดรอปที่ s.phase → เทียบ aimPhase/จุดลง/รองรับ/overlap/miss (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'BOT ' + (entry.botId || '') + ' phase=' + s.phase.toFixed(3) + + ' aimPhase=' + (Number.isFinite(_dbgAim) ? _dbgAim.toFixed(3) : 'x') + (_dbgDeadline ? '(DEADLINE!)' : '(reached)') + + ' sinDrop=' + Math.sin(s.phase).toFixed(3) + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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 }); + } + /* [DBG-MG6move] จับสถานะบอททุก ~2.5วิ → STILL=server ไม่ขับ, DEAD=ตาย(client ต้องซ่อน), MOV=ปกติ */ + if (!s._dbgMT || now - s._dbgMT > 2500) { + s._dbgMT = now; + const rows = []; + s.bots.forEach((b, id) => { + const mv = (b._ldx == null) ? '?' : ((Math.abs(b.cx - b._ldx) + Math.abs(b.cy - b._ldy) > 2) ? 'MOV' : 'STILL'); + b._ldx = b.cx; b._ldy = b.cy; + const tag = (id.indexOf(JS_BOT_PREFIX) === 0 ? 'pv' + id.slice(JS_BOT_PREFIX.length) : 'TK:' + String(id).slice(0, 5)); + rows.push(tag + (b.takeover ? 'tk' : '') + '·' + (b.eliminated ? 'DEAD' : mv)); + }); + glog(sid, space, 'mg6-move', '[' + rows.join(' ') + ']'); + } +} + +/* ============================================================ + 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' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true }); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + y0 = Math.max(0, Math.min(h - 1, y0)); + y1 = Math.max(0, Math.min(h - 1, y1)); + if (y1 < y0) { + const t = y0; + y0 = y1; + y1 = t; + } + return { y0, y1 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + /* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */ + if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) { + if (gr.endsAt != null) gr.endsAt += getGauntletTickMs(); + emitGauntletSync(sid, space); + return; + } + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client + เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */ + if (gr.finishPhase) { + if (gr.obstacles.length) { gr.obstacles = []; } + if (gr.finishEndAt == null) gr.finishEndAt = Date.now() + GAUNTLET_FINISH_WALK_MS; + if (Date.now() >= gr.finishEndAt) { + endGauntletGame(sid, space, 'finish'); + } + /* ไม่ emitGauntletSync ระหว่าง grace — ปล่อย client คุมการเดินเข้าเส้นเอง (กัน sync ดึงตำแหน่งกลับ) */ + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* G2: pre-run hold — หลัง 3-2-1 จบ ค้างที่จุด start ให้เห็นเส้นเริ่มก่อน (ยังไม่เลื่อน/เกิด obstacle/ขยับบอท) + client ค้าง bg เองด้วย max(0, serverNow − liveStartMs) → bg+bots/obstacles sync ที่ start พร้อมกัน */ + if (gr.liveStartMs != null && Date.now() < gr.liveStartMs) { + space.peers.forEach((p) => { p.gauntletJumpPending = false; }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* บอท server-authoritative — แถวเลนของบอทที่ยังเล่นอยู่ (ไม่ต้องพึ่ง gauntlet-preview-rows จาก host) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (b.gauntletEliminated) return; + const lr = gauntletLaneGridRowFromPlayerTop(md, b.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + } + /* แถวบนที่ client แจ้ง (บอท preview — editor หรือ client เก่า) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + let _gauntletLaserThisTick = false; + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + _gauntletLaserThisTick = true; + } + /* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */ + if (!_gauntletLaserThisTick) { + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + /* บอท server-authoritative — ตัดสินใจกระโดด + ฟิสิกส์ รอบ tick เดียวกับผู้เล่น (obstacle เลื่อนแล้วข้างบน) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (crownHeist && b.gauntletEliminated) return; + gauntletBotDecideJump(b, gr.obstacles, md, w, h); + gauntletBotStepPhysics(b, gr.obstacles, md, w, h, crownHeist); + }); + } + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; +} + +function mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(md) { + const w = md.width || 20; + const h = md.height || 15; + const out = [null, null, null, null, null, null]; + const raw = md && md.lobbyPlayerSpawns; + if (!Array.isArray(raw)) return out; + for (let i = 0; i < 6 && i < raw.length; i++) { + const cell = raw[i]; + if (!cell || typeof cell !== 'object') continue; + const x = Math.floor(Number(cell.x)); + const y = Math.floor(Number(cell.y)); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (x < 0 || x >= w || y < 0 || y >= h) continue; + out[i] = { x, y }; + } + return out; +} + +/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) { + if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; + const g = md.shooterSpawnSlots; + if (!g) return; + const w = md.width || 20, h = md.height || 15; + for (let slot = 1; slot <= 6; slot++) { + const idx = slot - 1; + if (slots6[idx]) continue; + let found = false; + for (let yy = 0; yy < h && !found; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] === slot) { + slots6[idx] = { x: xx, y: yy }; + found = true; + break; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */ +/** เลือก host คนถัดไปเมื่อ host ปัจจุบันหลุด — คนที่อยู่ในห้องนานสุด (spawnJoinOrder ต่ำสุด) + เสถียรข้ามการ reconnect (spawnJoinOrder ผูกกับ playerKey) — แทน Map-first ที่สลับได้ */ +function pickNextHostPeer(space) { + if (!space || !space.peers || space.peers.size === 0) return null; + let best = null; + let bestOrd = Infinity; + space.peers.forEach((p, id) => { + if (!p) return; + const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999; + if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) { + bestOrd = ord; + best = p; + } + }); + return best; +} + +function resolveSpawnJoinOrder(space, playerKey) { + if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {}; + const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const used = new Set(); + space.peers.forEach((p) => { + if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder); + }); + /* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */ + if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) { + const prev = space.spawnOrderByKey[key]; + if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev; + } + let ord = 0; + while (used.has(ord)) ord++; + if (key) space.spawnOrderByKey[key] = ord; + return ord; +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน < 4) */ +function openSuspectPhaseForRoom(sid, space, disruptorAccepted) { + if (!space) return; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + const phaseBase = { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: !!disruptorAccepted, + cardMinigames: space.suspectCardMinigames || [], + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('suspect-phase-open', { + ...phaseBase, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + /* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */ + const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer); + if (peerIds.length < 4 && !forceOffer) { + /* ไม่เสนอบทตัวป่วน (คน < 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */ + space.troublesomeOfferSent = true; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + openSuspectPhaseForRoom(sid, space, false); + return; + } + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 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) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + /* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */ + const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const prevHostId = space.hostId; + if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) { + space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */ + } else if (!space.hostId) { + space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */ + if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey; + } + /* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers + จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost) + แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */ + if (joinPlayerKey) { + const staleIds = []; + space.peers.forEach((p, pid) => { + if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid); + }); + staleIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + /* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */ + if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) { + delete space.graceReconnect[joinPlayerKey]; + if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; } + io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' }); + } + /* MG1 takeover: ผู้เล่นกลับมากลาง quiz (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (sess.bots) คืนสิทธิ์เล่น + กัน "ตัวซ้ำ" (บอท takeover + ตัวที่ rejoin) */ + const sessJ = space.quizSession; + if (sessJ && sessJ.bots) { + const dropBotIds = []; + sessJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBotIds.push(bid); }); + dropBotIds.forEach((bid) => { + sessJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); /* ลบ avatar บอท takeover เก่า — ตัวจริงจะมาใหม่ผ่าน user-joined */ + }); + } + /* MG2 takeover เช่นกัน: ผู้เล่นกลับมากลาง gauntlet (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (gauntletRun.bots) */ + const grJ = space.gauntletRun; + if (grJ && grJ.bots) { + const dropG = []; + grJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropG.push(bid); }); + dropG.forEach((bid) => { + grJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG3 takeover เช่นกัน: กลับมากลาง stack (playerKey เดิม) → ถอนบอท + คืน seat ให้คนจริง (inherit คะแนนกลับ) */ + const s3J = space.stackSession; + if (s3J && s3J.bots) { + let restored = false; + const dropS = []; + s3J.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropS.push(bid); }); + dropS.forEach((bid) => { + const inherited = Math.max(0, ((s3J.bots.get(bid) || {}).score) | 0); + s3J.bots.delete(bid); + const entry = (s3J.turnOrder || []).find((e) => e.kind === 'bot' && String(e.botId) === String(bid)); + if (entry) { entry.kind = 'human'; entry.id = socket.id; entry.botId = null; s3J.humanPts[socket.id] = inherited; restored = true; } + io.to(spaceId).emit('user-left', { id: bid }); + }); + if (restored) emitStackStateServer(spaceId, space); + } + /* MG4 takeover เช่นกัน: กลับมากลาง quiz_carry (playerKey เดิม) → ถอนบอท + คืน slot ให้คนจริง (inherit ป้าย/คะแนนกลับ) */ + const sCJ = space.quizCarrySession; + if (sCJ && sCJ.bots) { + const dropC = []; + sCJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropC.push(bid); }); + dropC.forEach((bid) => { + const bb = sCJ.bots.get(bid) || {}; + sCJ.bots.delete(bid); + sCJ.players[socket.id] = { held: (bb.held == null ? null : bb.held), score: Math.max(0, Number(bb.score) || 0) }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG5 takeover เช่นกัน: กลับมากลาง jump_survive (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit สถานะตาย) */ + const sJJ = space.jumpSurviveSession; + if (sJJ && sJJ.bots) { + const dropJ = []; + sJJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropJ.push(bid); }); + dropJ.forEach((bid) => { + const bb = sJJ.bots.get(bid) || {}; + sJJ.bots.delete(bid); + sJJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG6 takeover: กลับมากลาง space_shooter (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit คะแนน/ตาย) */ + const sSJ = space.spaceShooterSession; + if (sSJ && sSJ.bots) { + const dropSS = []; + sSJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropSS.push(bid); }); + dropSS.forEach((bid) => { + const bb = sSJ.bots.get(bid) || {}; + sSJ.bots.delete(bid); + sSJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG7 takeover: กลับมากลาง balloon_boss (playerKey เดิม) → ถอนบอท + คืน slot คนจริง */ + const sBJ = space.balloonBossSession; + if (sBJ && sBJ.bots) { + const dropBB = []; + sBJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBB.push(bid); }); + dropBB.forEach((bid) => { sBJ.bots.delete(bid); io.to(spaceId).emit('user-left', { id: bid }); }); + } + } + /* reconnect dedup ชั้น 2: ghost ที่ playerKey block จับไม่ได้ (peer เก่าไม่มี playerKey) + ลบด้วย "ชื่อ custom เดียวกัน" — แต่ **เฉพาะ peer เก่าที่ไม่มี valid playerKey** เท่านั้น + ⚠️ ห้ามลบ peer ที่มี playerKey คนละค่า: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละคน ต้องเก็บทั้งคู่ + (เดิมลบทุกตัวที่ชื่อตรง → client ที่ชื่อซ้ำ host ถูกลบ = "ไม่ได้เล่นเลย") */ + const validPkStr = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : ''; + const newNickNorm = normalizeLobbyNickname(nickname); + const newNickIsCustom = newNickNorm && newNickNorm !== 'ผู้เล่น' + && newNickNorm.indexOf('ผู้เล่น') !== 0 && newNickNorm.indexOf('บอท') !== 0; + if (newNickIsCustom) { + const ghostIds = []; + space.peers.forEach((p, pid) => { + if (pid === socket.id || !p) return; + const ppk = validPkStr(p); + if (ppk && ppk !== joinPlayerKey) return; /* คนละ playerKey = คนละคน (ชื่อซ้ำได้) → ไม่ลบ */ + if (normalizeLobbyNickname(p.nickname) === newNickNorm) ghostIds.push(pid); + }); + ghostIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + } + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + ensurePeerLobbyThemes(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey); + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = balloonBossBalloonsForMap(mdJoin); + /* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */ + let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10); + if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) { + /* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */ + let takenByHuman = false; + space.peers.forEach((p) => { + const ti = parseInt(p.lobbyColorThemeIndex, 10); + if (ti === chosenThemeIdx) takenByHuman = true; + }); + if (takenByHuman) { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + /* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */ + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0; + }); + } + } else { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } + let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10); + if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) { + chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder); + } + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + lobbyColorThemeIndex: chosenThemeIdx, + lobbySkinToneIndex: chosenSkinIdx, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '', + reportedMiniScore: 0, reportedMiniSurvived: true, + bannedSpectator: !!bannedSpectator, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* เก็บกวาด peer ซ้ำทั้งก้อน (ghost) — เผื่อ dedup ต่อ-join ด้านบนพลาด/race จนคนเดิมค้าง 2 ตัว */ + pruneDuplicateSpacePeers(spaceId, space); + glog(spaceId, space, 'join', 'สี#' + (peer.lobbyColorThemeIndex || '?') + ' · คน ' + space.peers.size + ' + บอท ' + effectiveBotSlotCount(space) + (joinPlayerKey ? '' : ' · NO-KEY'), nickname || 'ผู้เล่น'); + /* invariant: คน + บอท ≤ 6 — กัน "เงาสะท้อนของ user": คนหลุดแล้ว grace แปลงเป็นบอท (botSlotCount+1, สีตามคนออก) + พอคนนั้นกลับเข้ามา (reconnect หลัง grace) จะมีทั้งคนจริง + บอทสีเดียวกัน = ตัวซ้ำ. ตัดบอทส่วนเกินออกให้รวมไม่เกิน 6 + (มินิเกม cap ที่ 6-humans อยู่แล้ว ~rebalancePreviewBots แต่ LobbyB ไม่ได้ cap → ตัดที่ server ต้นทาง) */ + { + const humansNow = space.peers.size; + const botsNow = effectiveBotSlotCount(space); + if (humansNow + botsNow > LOBBY_SLOT_TOTAL) { + space.botSlotCount = Math.max(0, LOBBY_SLOT_TOTAL - humansNow); + } + /* re-sync สีบอททุกครั้งที่มีคน (re)join + แจ้ง "ทุกจอ" (รวมคนที่อยู่ในมินิเกม/LobbyB อยู่แล้ว): + เคส "เงาสะท้อนของ user" = คนหลุด→บอทใช้สีคนนั้น (pin)→คนนั้นกลับมา. join handler เคลียร์ pin ที่ชนสีคนแล้ว + (7704) + syncLobbyBotThemeSlots เลือกสีใหม่ที่ไม่ชนคน แต่เดิม emit เฉพาะตอน total>6 → จออื่นไม่อัปเดต = บอทยังสีเดิม. + emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */ + emitLobbyTintSync(spaceId, space); + } + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id); + /* นับช่อง spawn ให้พอกับ "คน + บอท" — เดิมส่งแค่ peers.size: ห้อง 1 คน = 1 ช่อง = pos[0] (เลนบนสุด) เสมอ + แล้วไปทับบอทที่ initGauntletPeersAll วางไว้ที่ pos[0] → คน(host ใหม่)+บอท วิ่งเลนเดียวกันเลนบนสุด (bug ที่เจอซ้ำ) */ + const grJoin = space.gauntletRun; + const botCntJoin = (grJoin && grJoin.bots) ? grJoin.bots.size : 0; + const pos = gauntletSpawnPositions(mdJoin, space.peers.size + botCntJoin); + /* เลี่ยงทับทั้ง "peer อื่น" และ "บอท" ที่อยู่ในเลนแล้ว — ดันไปช่องว่างถัดไป (เดิมเช็คแต่ peer ไม่เช็คบอท) */ + const occupied = new Set(); + space.peers.forEach((q) => { if (q && q.id !== socket.id) occupied.add(Math.floor(q.x) + ',' + Math.floor(q.y)); }); + if (grJoin && grJoin.bots) grJoin.bots.forEach((b) => { if (b) occupied.add(Math.floor(Number(b.x)) + ',' + Math.floor(Number(b.y))); }); + let pidx = Number.isFinite(ord) ? Math.max(0, Math.min(ord, pos.length - 1)) : pos.length - 1; + let pguard = 0; + while (pos[pidx] && occupied.has(Math.floor(pos[pidx].x) + ',' + Math.floor(pos[pidx].y)) && pguard < pos.length) { pidx = (pidx + 1) % pos.length; pguard++; } + const pt = pos[pidx] != null ? pos[pidx] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + botSlotCount: effectiveBotSlotCount(space), + lobbyBotThemes: syncLobbyBotThemeSlots(space), + joinLocked: !!space.joinLocked, + missionLiveBeginsAt: space.missionLiveBeginsAt || null, + }; + if (serverMapIsPostCaseLobbyB(space)) { + joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null; + joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null; + } + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + /* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */ + const sqIcon = specialQuizIconPayload(space); + if (sqIcon) joinCb.specialQuizIcon = sqIcon; + if (typeof cb === 'function') cb(joinCb); + /* กันค้างตอน reconnect: event "จบมินิเกม" (balloon-boss-over/stack-over/space-shooter-over) + host ยิงครั้งเดียวผ่าน socket.to — ถ้า client หลุดตอนนั้นจะค้างถาวร (ไม่มี replay). + เก็บ over ล่าสุดไว้ → resend ให้ socket ที่เพิ่ง (re)join ภายใน 45s ถ้ายังอยู่ในแมปมินิเกม + (gameType !== 'lobby' = ยังไม่ทรานซิชันกลับ). delay เล็กน้อยให้ client applyMapAndStart เสร็จก่อน */ + const lmo = space.lastMinigameOver; + if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') { + const targetSid = socket.id; + setTimeout(() => { + try { + const sp = spaces.get(spaceId); + if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) { + io.to(targetSid).emit(lmo.event, lmo.payload); + } + } catch (_e) { /* ignore */ } + }, 400); + } + if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */ + emitLobbyTintSync(spaceId, space); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + /* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที + (คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */ + if (serverMapIsPostCaseLobbyB(space)) { + /* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */ + emitDetectiveLobbyBPresence(spaceId, space); + console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id); + if (space.pendingBanVoteCard && !space.pickVote) { + const banCard = space.pendingBanVoteCard; + space.pendingBanVoteCard = null; + /* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */ + setTimeout(() => { + const sp = spaces.get(spaceId); + if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) { + console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp))); + return; + } + console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')'); + io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' }); + startPlayerPickVote(spaceId, sp, 'ban', (targetId) => { + const sp2 = spaces.get(spaceId); + console.log('[ban-debug] ban vote RESOLVED target=', targetId); + if (sp2) sp2.bannedPlayerId = targetId || null; + }); + }, 2000); + } else if (space.pickVote && !space.pickVote.resolved) { + const pv = space.pickVote; + socket.emit('player-pick-vote-open', { + purpose: pv.purpose, + candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: pv.endsAt, + durationMs: pickVoteMs(), + }); + } + } + }); + + /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ + /** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */ + socket.on('special-quiz-force', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + space.forceSpecialQuiz = !!(data && data.on); + console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id); + }); + + /** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ + socket.on('special-quiz-testmode', (data) => { + socket.data.testMode = !!(data && data.on); + }); + + /** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */ + socket.on('test-force-wrong-vote', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return; + space.testForceWrongVote = true; + console.log('[test] force-wrong-vote armed by', socket.id); + }); + + /** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */ + socket.on('special-quiz-arm', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return; + sq.armScheduled = true; + scheduleSpecialQuizIconAppearAndExpiry(sid, space); + }); + + socket.on('special-quiz-collide', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return reply({ ok: false }); + const started = startSpecialQuizSession(sid, space); + reply({ ok: !!started }); + }); + + /** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */ + socket.on('special-quiz-answer', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return reply({ ok: false }); + const q = sess.questions[sess.qIndex]; + if (!q) return reply({ ok: false }); + /* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */ + let choice; + if (data && data.debugCorrect && socket.data.testMode) { + choice = q.correctIndex; + } else { + choice = Number(data && data.choiceIndex); + } + if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) { + return reply({ ok: false }); + } + if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' }); + sess.answers[socket.id] = choice; + reply({ ok: true }); + /* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */ + io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true }); + /* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ + maybeResolveSpecialQuizAllAnswered(sid, space); + }); + + /** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */ + socket.on('special-quiz-continue', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + /* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */ + if (!space.specialQuizAwaitContinue) return reply({ ok: true }); + /* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */ + if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set(); + space.specialQuizContinueAcks.add(socket.id); + reply({ ok: true }); + maybeFinishSpecialQuizContinue(sid, space); + }); + + /** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */ + socket.on('lobby-tint-update', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + const peer = space && space.peers.get(socket.id); + if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + let themeIndex = Math.floor(Number(data && data.themeIndex)); + let skinIndex = Math.floor(Number(data && data.skinToneIndex)); + if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) { + return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' }); + } + if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) { + skinIndex = peer.lobbySkinToneIndex || 1; + } + /* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join) + กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */ + let humanTaken = false; + space.peers.forEach((op, oid) => { + if (oid === socket.id) return; + if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true; + }); + if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); + if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */ + }); + } + peer.lobbyColorThemeIndex = themeIndex; + peer.lobbySkinToneIndex = skinIndex; + emitLobbyTintSync(sid, space); + reply({ + ok: true, + lobbyColorThemeIndex: themeIndex, + lobbySkinToneIndex: skinIndex, + lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], + }); + }); + + /* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */ + /* DEPRECATED: server เป็นเจ้าของบอท lobby เองแล้ว (stepLobbyBotsForSpace/lobbyBotSweepTick) + → ไม่ relay ตำแหน่งบอทจาก client อีก (กัน host เวอร์ชันเก่าฉีดตำแหน่งชนกับ server tick = บอทกระตุก/2 แหล่ง) */ + socket.on('lobby-bot-move', () => { /* no-op: server-authoritative */ }); + + /* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */ + socket.on('fill-bot-state', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ + if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; + /* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host" + แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */ + const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined); + socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans }); + }); + + /* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host) + payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */ + socket.on('balloon-boss-shot', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */ + const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); + if (!ok) return; + socket.to(sid).emit('balloon-boss-shot', { + sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy, + ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), + }); + }); + + /* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */ + socket.on('balloon-boss-over', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const reason = (data.reason === 'victory' || data.reason === 'all_dead' || data.reason === 'time') ? data.reason : 'all_dead'; + const scores = Array.isArray(data.scores) + ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated })) + : []; + const overPayload = { reason, scores }; + socket.to(sid).emit('balloon-boss-over', overPayload); + /* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */ + space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() }; + }); + + /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ + → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ + socket.on('balloon-boss-sync', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const bossDmg = Math.max(0, Number(data.bossDmg) || 0); + const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */ + const players = Array.isArray(data.players) + ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ + id: p.id, + d: Math.max(0, Number(p.d) || 0), + b: Math.max(0, Number(p.b) || 0), + e: !!p.e, + })) + : []; + socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor }); + }); + + /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + socket.on('stack-drop', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: client ไม่เป็นเจ้าของดรอป */ + const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; + const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; }; + socket.to(sid).emit('stack-drop', { + placedCx: num(data.placedCx), placedW: num(data.placedW), + miss: !!data.miss, perfect: !!data.perfect, + pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), + overlap: num(data.overlap), + supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), + delta: num(data.delta), + seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, + theme: themeOf(data.theme), /* สี block ที่วาง (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client freeze สีตัวเอง = เพี้ยน */ + actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, + }); + }); + /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ + socket.on('quiz-end-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ + if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'quiz') return; + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); + }); + + /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ + socket.on('stack-swing', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: server แกว่งเอง */ + const ph = Number(data.phase); + if (!Number.isFinite(ph)) return; + socket.to(sid).emit('stack-swing', { phase: ph }); + }); + + /* MG3 stack (server-auth) — รับ "เจตนากดวาง" จากผู้เล่นจริง → server คำนวณ ณ เฟสปัจจุบัน + emit ผลให้ทุกเครื่อง */ + socket.on('stack-drop-req', (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); + /* [DBG-MG3drop] เทียบ client-sent phase vs s.phase + จุดลง/รองรับ/overlap/miss/หัวใจ (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'HUMAN cph=' + (Number.isFinite(cph) ? cph.toFixed(3) : 'x') + + ' sPhase=' + s.phase.toFixed(3) + ' used=' + dropPhase.toFixed(3) + (dropPhase === cph ? '(client)' : '(server)') + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + ' perfect=' + hit.perfect + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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) { + return reply({ + ok: false, + waitingPlayers: true, + present: waitInfo.present, + total: waitInfo.total, + error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')', + }); + } + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; + const cardEntry = space.suspectCardMinigames[selectedIndex]; + if (!cardEntry || !cardEntry.mapId) { + return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); + } + /* จำกัดสืบผู้ต้องสงสัยละไม่เกิน 3 ครั้ง (#1) — นับจาก max หลักฐานของผู้ต้องสงสัยนี้ข้ามผู้เล่น + (แต่ละรอบทุกคนได้ +1) → กันเล่นครั้งที่ 4 + กันการ์ดพิเศษโผล่รอบที่ 4 (#2) */ + let timesPlayedThisSuspect = 0; + space.peers.forEach((_p, pid) => { + const prog = playerSuspectProgressFromEvidence(space, pid); + const n = Array.isArray(prog) ? (prog[selectedIndex] | 0) : 0; + if (n > timesPlayedThisSuspect) timesPlayedThisSuspect = n; + }); + if (timesPlayedThisSuspect >= 3) { + return reply({ ok: false, error: 'สืบผู้ต้องสงสัยคนนี้ครบ 3 ครั้งแล้ว — เลือกคนอื่น' }); + } + /* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */ + const pend = findPendingSpecialCard(space, 3); + if (pend && !space.pickVote) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' }); + startPlayerPickVote(sid, space, 'ban', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.bannedPlayerId = targetId || null; + const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex); + if (!res || !res.ok) { + io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true }); + } + }); + return reply({ ok: true, banVote: true }); + } + const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); + reply(started); + }); + + socket.on('detective-minigame-finished', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + } + /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */ + const finPeer = space.peers.get(socket.id); + if (finPeer && data && typeof data === 'object') { + /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง + หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */ + if (Number.isFinite(Number(data.score))) { + let rs = Math.max(0, Math.floor(Number(data.score))); + if (rs > REPORTED_MINI_SCORE_MAX) { + console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX); + rs = REPORTED_MINI_SCORE_MAX; + } + finPeer.reportedMiniScore = rs; + } + if (data.survived === false) finPeer.reportedMiniSurvived = false; + } + if (!space.detectiveMinigameActive) { + if (serverMapIsPostCaseLobbyB(space)) { + const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]); + return reply({ + ok: true, + alreadyOnLobbyB: true, + suspectIndex: grant.suspectIdx, + awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null, + awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [], + grade: grant.grade || null, + freeEvidenceCount: grant.freeEvidenceCount || 0, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + } + return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); + } + /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก" + จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด + ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */ + const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) { + if (finPeer) finPeer.detectiveFinished = true; + const allDone = [...space.peers.values()].every((p) => + p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished + ); + if (!allDone) { + /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */ + return reply({ ok: true, holdInScene: true }); + } + /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */ + } + // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน) + returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true }); + const g = space.lastEvidenceGrant || {}; + reply({ + ok: true, + suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1, + awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null, + awardedCards: (g.awardedList && g.awardedList[socket.id]) || [], + grade: g.grade || null, + freeEvidenceCount: g.freeEvidenceCount || 0, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + }); + + /** + * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที + * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง) + */ + socket.on('detective-debug-award-evidence', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + const idx = Math.floor(Number(data && data.suspectIndex)); + if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' }); + const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C'); + if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' }); + emitLobbyEvidenceSync(sid, space); + reply({ + ok: true, + suspectIndex: idx, + awardedCard: card, + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + }); + }); + + // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย) + socket.on('suspect-accuse-open', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' }); + const prog = playerSuspectProgressFromEvidence(space, socket.id); + if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' }); + if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3); + space.suspectPhaseActive = false; + // เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต + space.testimonyActive = true; + space.testimonyRound = 0; + emitTestimonyOpen(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นส่งหลักฐาน 1–2 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง + socket.on('testimony-submit', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' }); + if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' }); + const raw = (data && Array.isArray(data.picks)) ? data.picks : []; + const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []); + const required = Math.max(1, Math.min(2, owned.length)); + /* raw = ตำแหน่ง index ใน owned (0..n-1) → เก็บเป็น card object */ + const usedIdx = []; + const picks = []; + raw.forEach((n) => { + const v = Math.floor(Number(n)); + if (v >= 0 && v < owned.length && usedIdx.indexOf(v) === -1) { usedIdx.push(v); picks.push(owned[v]); } + }); + if (owned.length === 0) { + /* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */ + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = []; + emitTestimonyStatus(sid, space); + /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design */ + return reply({ ok: true, skipped: true }); + } + if (picks.length !== required) { + return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' }); + } + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = picks.slice(0, required); + emitTestimonyStatus(sid, space); + /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design + (UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */ + reply({ ok: true }); + }); + + // Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง) + socket.on('testimony-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' }); + doTestimonyReveal(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) — ไม่ auto-advance รอ host กด "เริ่มพิจารณาคดี" + socket.on('testimony-ready-next', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' }); + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + space.testimonyReadyNext[socket.id] = true; + const totalRN = testimonyTotalParticipants(space); + io.to(sid).emit('testimony-ready-update', { + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: totalRN, + hostId: space.hostId, + }); + /* ไม่ auto-advance — รอ host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote.png) เอง */ + reply({ ok: true }); + }); + + // Host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote) — ไปปากคำถัดไป/เข้าโหวต + socket.on('testimony-start-vote', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังไม่ถึงเวลา' }); + /* host อาจกดก่อนทุกคน READY — บังคับให้ทุกคน (รวมบอท) ready ก่อน advance เพื่อให้ flow consistent */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + advanceTestimony(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นโหวตชี้ตัวคนร้าย + socket.on('trial-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' }); + if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true }); + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); + /* รอบจับผิดตัว: ห้ามโหวตผู้ต้องสงสัยที่โหวตผิดไปแล้ว */ + if (Number.isInteger(space.trialRevoteExcluded) && idx === space.trialRevoteExcluded) { + return reply({ ok: false, error: 'ผู้ต้องสงสัยนี้ถูกโหวตไปแล้ว (ผิด) — เลือกคนอื่น', excluded: true }); + } + space.trialVotes = space.trialVotes || {}; + space.trialVotes[socket.id] = idx; + emitTrialVoteUpdate(sid, space); + const totalTV = trialEligibleVoterTotal(space); + if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + + /* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */ + socket.on('player-pick-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const pv = space.pickVote; + if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' }); + const tid = String((data && data.targetId) || ''); + if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' }); + pv.votes[socket.id] = tid; + reply({ ok: true, targetId: tid }); + emitPickVoteUpdate(sid, space); + maybeResolvePickVote(sid, space); + }); + + /** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */ + socket.on('host-console-apply', (data, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const humans = space.peers.size; + let total = parseInt(data && data.totalSlots, 10); + if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space); + total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total)); + let bots = parseInt(data && data.botSlotCount, 10); + if (!Number.isFinite(bots)) bots = total - humans; + bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots)); + total = humans + bots; + space.botSlotCount = bots; + space.maxPlayers = Math.max(humans, total - bots); + const lobbyBotThemes = syncLobbyBotThemeSlots(space); + io.to(sid).emit('host-console-settings', { + maxPlayers: space.maxPlayers, + botSlotCount: space.botSlotCount, + totalSlots: total, + lobbyBotThemes, + }); + emitLobbyTintSync(sid, space); + cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes }); + }); + + socket.on('host-console-kick-peer', ({ targetId }, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const tid = targetId != null ? String(targetId) : ''; + if (!tid || tid === socket.id || tid === space.hostId) { + return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' }); + } + if (!space.peers.has(tid)) { + return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' }); + } + const targetSock = io.sockets.sockets.get(tid); + if (targetSock) { + targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' }); + targetSock.leave(sid); + delete targetSock.data.spaceId; + } + space.peers.delete(tid); + io.to(sid).emit('user-left', { id: tid }); + cb && cb({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.ready = !!ready; + io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready }); + break; + } + } + }); + + socket.on('start-game', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id) && space.hostId === socket.id) { + let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : ''; + let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : ''; + if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = ''; + /* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */ + if (caseId && !/^(?:[1-9]|1[0-5])$/.test(caseId)) caseId = ''; + ensurePostCaseLobbyMapLoaded(); + const mdBefore = getLobbyLayoutMapForSpace(space); + if (mdBefore && mdBefore.gameType === 'quiz') { + const qErr = validateQuizMap(mdBefore); + if (qErr) return reply({ ok: false, error: qErr }); + clearSpaceQuizTimers(space); + startQuizGame(sid, space, mdBefore); + io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true }); + return reply({ ok: true }); + } + const detectiveB = !!(data && data.detectiveLobbyBStart); + if (detectiveB && (!lobbyLevel || !caseId)) { + return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' }); + } + const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : ''; + const isNamedLobbyA = mapNameNorm === 'lobbya'; + const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet'); + /* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */ + const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore) + && lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID) + && ( + mdBefore.gameType === 'lobby' + || isNamedLobbyA + || (detectiveB && notZepOrFrogger) + )); + if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) { + return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' }); + } + /* detective LobbyA→LobbyB: ไม่บังคับยืนโซนส้ม (หน้าจอเลือกคดีบังแผนที่ ขยับตัวไม่ได้) */ + if (!isLobbyADetectiveStart && !lobbyHostStandingInStartArea(space, socket.id)) { + return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' }); + } + if (isLobbyADetectiveStart) { + assignDetectiveSuspectCardMinigames(space); + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID); + const caseNickWl = new Set(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + space.detectiveLobbyLevel = Number(lobbyLevel) || 1; + space.detectiveLobbyCaseId = Number(caseId) || 1; + space.caseId = Number(caseId) || 1; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space) + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + /* เก็บข้อมูลก่อนลบ 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) { + 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 */ } + 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-soundapi b/www/html/Game/server.js.bak-soundapi new file mode 100644 index 0000000..d6a132b --- /dev/null +++ b/www/html/Game/server.js.bak-soundapi @@ -0,0 +1,12856 @@ +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'); + +/* ===== Quiz Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ +const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json'); +/** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */ +let qbLeaderboardStore = {}; +function loadQbLeaderboard() { + try { + if (fs.existsSync(QB_LEADERBOARD_PATH)) { + const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8')); + qbLeaderboardStore = (j && typeof j === 'object') ? j : {}; + } + } catch (e) { qbLeaderboardStore = {}; } +} +function saveQbLeaderboard() { + try { + const dir = path.dirname(QB_LEADERBOARD_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8'); + } catch (e) { /* ignore */ } +} +function qbLeaderboardSubmit(roomId, name, score, characterId) { + roomId = String(roomId || '').trim(); + name = String(name || '').trim().slice(0, 24); + score = Math.max(0, Math.floor(Number(score) || 0)); + if (!roomId || !name) return; + if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = []; + const list = qbLeaderboardStore[roomId]; + const ex = list.find((e) => e && e.name === name); + if (ex) { + if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); } + } else { + list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() }); + } + list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts)); + if (list.length > 500) list.length = 500; + saveQbLeaderboard(); +} +function qbLeaderboardGet(roomId, name) { + roomId = String(roomId || '').trim(); + const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : []; + const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' })); + let me = null; + const nm = String(name || '').trim(); + if (nm) { + const idx = list.findIndex((e) => e && e.name === nm); + if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' }; + } + return { top, me, total: list.length }; +} +loadQbLeaderboard(); + +/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ +const os = require('os'); +let __prevCpuSample = null; +function __cpuSample() { + const cpus = os.cpus() || []; + let idle = 0, total = 0; + for (const c of cpus) { + for (const k in c.times) total += c.times[k]; + idle += c.times.idle; + } + return { idle, total }; +} +/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ +function __cpuPercent() { + const cur = __cpuSample(); + const prev = __prevCpuSample; + __prevCpuSample = cur; + if (!prev) return null; + const idleD = cur.idle - prev.idle; + const totalD = cur.total - prev.total; + if (totalD <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); +} +__cpuSample(); // เก็บ baseline แรก +function getDiskStats() { + try { + if (typeof fs.statfsSync !== 'function') return null; + const s = fs.statfsSync(__dirname); + const total = s.blocks * s.bsize; + const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด + const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ + const used = total - free; + if (!(total > 0)) return null; + return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; + } catch (e) { return null; } +} +/* จำนวนผู้เล่น/ห้อง/socket ออนไลน์ตอนนี้ — โชว์ในหน้า Admin เทียบกับเพดานความจุ */ +function getOnlineStats() { + let players = 0, rooms = 0; + try { + for (const [, sp] of spaces) { + if (sp && sp.peers) { players += sp.peers.size; rooms++; } + } + } catch (e) { /* ignore */ } + let sockets = 0; + try { sockets = (io && io.engine && io.engine.clientsCount) || 0; } catch (e) { /* ignore */ } + return { sockets, players, rooms }; +} + +function getSystemStats() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const cores = (os.cpus() || []).length || 1; + const cpuPct = __cpuPercent(); + const mu = process.memoryUsage(); + return { + ok: true, + cpu: { + percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) + cores, + load1: Math.round(load[0] * 100) / 100, + load5: Math.round(load[1] * 100) / 100, + load15: Math.round(load[2] * 100) / 100, + loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), + }, + mem: { + total: totalMem, + free: freeMem, + used: usedMem, + usedPercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDiskStats(), + node: { + rss: mu.rss, + heapUsed: mu.heapUsed, + rssPercent: Math.round((mu.rss / totalMem) * 100), + version: process.version, + uptime: Math.round(process.uptime()), + }, + uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, + /* ผู้ใช้ออนไลน์ + เพดานความจุ (ประเมินจาก 2 vCPU / socket fan-out): สบาย ≤300 · เริ่มหนัก ≥500 */ + online: getOnlineStats(), + capacity: { comfortable: 300, strain: 500 }, + hostname: os.hostname(), + platform: os.platform(), + ts: Date.now(), + }; +} + +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */ +const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5]; +const SPECIAL_QUIZ_CASES = [1, 2, 3]; +const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary']; +const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; + +function specialCardTxtFilename(id) { + return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`; +} + +function specialCardImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`; +} + +function specialCardTxtImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`; +} + +function specialQuizSetKey(level, caseId) { + return 'L' + level + '_C' + caseId; +} + +/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */ +function sanitizeSpecialQuizQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + const type = q.type === 'mcq' ? 'mcq' : 'tf'; + if (type === 'mcq') { + /* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */ + const choices = (Array.isArray(q.choices) ? q.choices : []) + .map((c) => String(c == null ? '' : c).trim()) + .slice(0, 8); + while (choices.length && choices[choices.length - 1] === '') choices.pop(); + if (choices.filter((c) => c).length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') { + ci = choices.findIndex((c) => c); + if (ci < 0) ci = 0; + } + return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci }; + } + return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue }; +} + +function sanitizeSpecialCard(c) { + const src = (c && typeof c === 'object') ? c : {}; + const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare'; + let cardId = Number(src.cardId); + if (!(cardId >= 1 && cardId <= 7)) cardId = null; + let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600); + let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600); + if (cardId) { + if (!imageUrl) imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } else if (imageUrl) { + const m = imageUrl.match(/\/card-(\d)\.png/i); + if (m) { + cardId = Number(m[1]); + imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } + } + return { + cardId: cardId || null, + th: String(src.th == null ? '' : src.th).trim().slice(0, 160), + en: String(src.en == null ? '' : src.en).trim().slice(0, 160), + rarity, + imageUrl, + txtImageUrl, + }; +} + +/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */ +function sanitizeSpecialQuizSets(obj) { + const out = {}; + SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => { + const key = specialQuizSetKey(L, C); + const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {}; + const questions = (Array.isArray(src.questions) ? src.questions : []) + .map(sanitizeSpecialQuizQuestion) + .filter(Boolean) + .slice(0, 50); + out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) }; + })); + return out; +} + +/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */ +function getSpecialQuizSet(settings, level, caseId) { + const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({}); + /* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่) + key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */ + const cid = Math.max(1, Math.min(15, Number(caseId) || 1)); + const L = Math.ceil(cid / 3); + const C = ((cid - 1) % 3) + 1; + return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; +} + +/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ +/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ +/** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */ +const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7']; +const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; +/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ +/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */ +const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; +/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน (roster คดี/LobbyB) + ⚠️ ห้ามตั้ง 0: เปลี่ยนหน้า lobbyB→play→lobbyB (socket หลุดชั่วคราว) จะถูกถอดจาก roster → rejoin ไม่ได้ ("ห้องนี้เริ่มคดีแล้ว") + หมายเหตุ: bot takeover "ในเกม MG1" (sess.bots) เป็นทันทีอยู่แล้ว แยกจาก grace นี้ */ +const DISCONNECT_GRACE_SEC = 10; + +/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */ +function normalizeTestSpecialCardByMap(v) { + const out = {}; + if (v && typeof v === 'object') { + SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => { + const c = Math.floor(Number(v[mid])); + if (c >= 1 && c <= 7) out[mid] = c; + }); + } + return out; +} +/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */ +const SPECIAL_QUIZ_ANSWER_MS = 20000; +/** หน่วงโชว์เฉลยก่อนข้อต่อไป */ +const SPECIAL_QUIZ_REVEAL_MS = 2600; + +/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */ +function specialQuizNormalizeQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + if (q.type === 'mcq') { + const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== ''); + if (choices.length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0; + return { text, choices, correctIndex: ci }; + } + return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 }; +} + +function specialQuizShuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function specialQuizTileWalkable(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const obj = md.objects && md.objects[y]; + if (obj && obj[x] === 1) return false; + /* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */ + const bp = md.blockPlayer && md.blockPlayer[y]; + if (bp && bp[x] === 1) return false; + return true; +} + +/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */ +function specialQuizTileInGameZone(md, x, y) { + if (quizCellOn(md.quizTrueArea || [], x, y)) return true; + if (quizCellOn(md.quizFalseArea || [], x, y)) return true; + if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true; + const oa = md.quizCarryOptionArea; + if (oa && oa[y] && oa[y][x]) return true; + return false; +} + +/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */ +function pickSpecialQuizSpawnTile(md, occupiedLaneCount) { + const w = md.width || 20; + const h = md.height || 15; + /* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer → + ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้ + → เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */ + if (md.gameType === 'quiz_carry') { + const nearMarkedTable = (x, y) => { + for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) { + if (!Array.isArray(g)) continue; + for (let dy = -2; dy <= 2; dy++) { + const gy = g[y + dy]; + if (!gy) continue; + for (let dx = -2; dx <= 2; dx++) { + if (gy[x + dx]) return true; + } + } + } + return false; + }; + const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length) + ? md.lobbyPlayerSpawns + : [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }]; + const qcCands = []; + seeds.forEach((s) => { + const bx = Math.floor(Number(s.x)); + const by = Math.floor(Number(s.y)); + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const dist = Math.abs(dx) + Math.abs(dy); + if (dist < 2 || dist > 6) continue; + const xx = bx + dx; + const yy = by + dy; + if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) { + qcCands.push({ x: xx, y: yy }); + } + } + } + }); + if (qcCands.length) { + const p = qcCands[Math.floor(Math.random() * qcCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns + (ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */ + if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) { + /* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */ + const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0) + ? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount)) + : md.gauntletPlayerSpawns.length; + const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes); + const rows = Array.from(new Set(usableSpawns + .map((s) => Math.floor(Number(s.y))) + .filter((n) => Number.isFinite(n) && n >= 0 && n < h))); + const laneCands = []; + const xMin = Math.max(4, Math.floor(w * 0.30)); + const xMax = Math.min(w - 2, Math.floor(w * 0.72)); + rows.forEach((ry) => { + for (let x = xMin; x <= xMax; x++) { + if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry }); + } + }); + if (laneCands.length) { + const p = laneCands[Math.floor(Math.random() * laneCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง + (ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */ + if (md.gameType === 'stack') { + const zc = []; + [md.stackReleaseArea, md.stackLandArea].forEach((g) => { + if (Array.isArray(g)) { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y }); + } + }); + if (zc.length) { + let cx2 = 0; + let cy2 = 0; + zc.forEach((c) => { cx2 += c.x; cy2 += c.y; }); + cx2 = Math.round(cx2 / zc.length); + cy2 = Math.round(cy2 / zc.length); + for (let rad = 0; rad <= 6; rad++) { + for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) { + for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 }; + } + } + } + /* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */ + return { x: cx2 + 0.5, y: cy2 + 0.5 }; + } + } + /* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack + (สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */ + if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) { + const painted = []; + const paintedWalkable = []; + for (let yy = 0; yy < h; yy++) { + const row = md.specialQuizSpawnArea[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (!row[xx]) continue; + painted.push({ x: xx, y: yy }); + if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy }); + } + } + const pickFrom = paintedWalkable.length ? paintedWalkable : painted; + if (pickFrom.length) { + const p = pickFrom[Math.floor(Math.random() * pickFrom.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* jump_survive (กระโดด mnptfts2): ผู้เล่นยืนบน "แพลตฟอร์ม" — พื้นที่ว่างนับ walkable ผิด (ลอยกลางอากาศเก็บไม่ได้) + ไอคอนคลิก/แตะเก็บได้ทุกมินิเกม (trySpecialQuizClickAt) → วางบนแพลตฟอร์มใกล้จุดเกิด (อยู่ในจอตั้งแต่ต้น คลิกได้) + = แก้ #44 อีเวนต์พิเศษ (ทนาย/เทพ) ไม่ขึ้นเพราะ BFS หา walkable ไม่เจอ → return null */ + if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) { + const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }; + const bx = Math.floor(Number(sp.x)); + const by = Math.floor(Number(sp.y)); + const plats = []; + for (let y = 0; y < h; y++) { + const row = md.jumpSurvivePlatformArea[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x]) plats.push({ x, y }); + } + } + if (plats.length) { + plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by))); + const near = plats.slice(0, Math.min(8, plats.length)); + const p = near[Math.floor(Math.random() * near.length)]; + const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */ + return { x: p.x + 0.5, y: iy + 0.5 }; + } + } + /* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้ + fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */ + let sx = -1; + let sy = -1; + const spawnGrid = md.spawnArea; + if (Array.isArray(spawnGrid)) { + for (let y = 0; y < h && sx < 0; y++) { + const row = spawnGrid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) { + const sp = md.spawn || { x: 1, y: 1 }; + const fx = Math.floor(Number(sp.x)); + const fy = Math.floor(Number(sp.y)); + if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; } + } + if (sx < 0) { + /* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก + เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */ + const ccx = Math.floor(w / 2); + const ccy = Math.floor(h / 2); + const maxRad = Math.max(w, h); + for (let rad = 0; rad <= maxRad && sx < 0; rad++) { + for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) { + for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; } + } + } + } + } + if (sx < 0) { + for (let y = 0; y < h && sx < 0; y++) { + for (let x = 0; x < w; x++) { + if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) return null; + const q = [[sx, sy]]; + const seen = new Set([sx + ',' + sy]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + const cands = []; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = nx + ',' + ny; + if (seen.has(k)) continue; + if (!specialQuizTileWalkable(md, nx, ny)) continue; + seen.add(k); + q.push([nx, ny]); + if (!specialQuizTileInGameZone(md, nx, ny)) { + const dist = Math.abs(nx - sx) + Math.abs(ny - sy); + if (dist >= 2) cands.push({ x: nx, y: ny }); + } + } + } + if (!cands.length) return null; + /* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ · + ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */ + const near = cands.filter((c) => { + const d = Math.abs(c.x - sx) + Math.abs(c.y - sy); + return d >= 2 && d <= 8; + }); + const pool = near.length ? near : cands; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x + 0.5, y: pick.y + 0.5 }; +} + +function clearSpecialQuizTimers(space) { + if (Array.isArray(space.specialQuizTimers)) { + space.specialQuizTimers.forEach((t) => clearTimeout(t)); + } + space.specialQuizTimers = []; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + space.specialQuizAwaitContinue = false; +} + +/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */ +function specialQuizIconPayload(space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done || !sq.appeared) return null; + return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 }; +} + +/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง (1 ใบต่อครั้งที่ติด — ดีไซน์ "1 ใน 3") + Test Mode (testSpecialCardByMap / forceSpecialQuiz) → ข้าม roll ขึ้นเสมอ */ +function maybeSpawnSpecialQuizForRun(space) { + clearSpecialQuizTimers(space); + space.specialQuiz = null; + space.specialCardAwardedThisRun = null; + space.timeDilationPendingSec = 0; + /* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้ + หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */ + if (!space || !space.detectiveMinigameActive) return; + const mapId = String(space.mapId || '').trim(); + if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return; + const level = Number(space.detectiveLobbyLevel) || 1; + const caseId = Number(space.detectiveLobbyCaseId) || 1; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, level, caseId); + const questions = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + let card = set && set.specialCard; + /* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */ + const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId]; + const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7); + if (testForcedCard) { + const def = specialCardDef(testCardId) || {}; + card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th }); + } + const hasCard = !!(card && (card.cardId || card.imageUrl)); + if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; } + const forced = !!space.forceSpecialQuiz || testForcedCard; + const roll = Math.random(); + if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; } + if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; } + /* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */ + const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); + const tile = pickSpecialQuizSpawnTile(md, occupiedLanes); + if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; } + const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)]; + console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y); + space.specialQuiz = { + iconType, + x: tile.x, + y: tile.y, + level, + caseId, + card, + triggered: false, + done: false, + session: null, + }; +} + +function emitSpecialQuizIcon(sid, space) { + const payload = specialQuizIconPayload(space); + if (payload) io.to(sid).emit('special-quiz-icon', payload); +} + +/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */ +const SPECIAL_QUIZ_ICON_DELAY_MS = 2000; +function scheduleSpecialQuizIconAppearAndExpiry(sid, space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done) return; + const tAppear = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return; + const cur = sp.specialQuiz; + const sec = readSpecialQuizIconExpireSec(); + cur.appeared = true; + cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0; + emitSpecialQuizIcon(sid, sp); + if (sec > 0) { + const tExpire = setTimeout(() => { + const sp2 = spaces.get(sid); + if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return; + sp2.specialQuiz = null; + io.to(sid).emit('special-quiz-icon-clear', {}); + console.log('[special-quiz] icon EXPIRED after', sec + 's'); + }, sec * 1000); + if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = []; + sp.specialQuizTimers.push(tExpire); + } + }, SPECIAL_QUIZ_ICON_DELAY_MS); + if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = []; + space.specialQuizTimers.push(tAppear); +} + +/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */ +function buildSpecialQuizRoster(space) { + const roster = []; + space.peers.forEach((p, id) => { + roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }); + }); + const botN = effectiveBotSlotCount(space); + for (let i = 0; i < botN; i++) { + roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true }); + } + return roster; +} + +function specialQuizRosterPayload(sess) { + return (sess && Array.isArray(sess.roster) ? sess.roster : []) + .map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot })); +} + +/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */ +function specialQuizExpectedIds(space, sess) { + const realAlive = new Set(space.peers.keys()); + const out = []; + (sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => { + if (m.isBot) out.push(m.id); + else if (realAlive.has(m.id)) out.push(m.id); + }); + return out; +} + +/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ +function maybeResolveSpecialQuizAllAnswered(sid, space) { + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return; + const expected = specialQuizExpectedIds(space, sess); + const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid])); + if (allAnswered) resolveSpecialQuizQuestion(sid, space, false); +} + +/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */ +function scheduleSpecialQuizBotAnswers(sid, space, sess, q) { + const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot); + if (!bots.length) return; + const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500); + bots.forEach((b) => { + const delay = 900 + Math.floor(Math.random() * windowMs); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session; + if (!spNow || sNow !== sess || sess.resolving) return; + if (Number.isInteger(sess.answers[b.id])) return; + sess.answers[b.id] = q.correctIndex; + io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true }); + maybeResolveSpecialQuizAllAnswered(sid, spNow); + }, delay); + space.specialQuizTimers.push(t); + }); +} + +function sendSpecialQuizQuestion(sid, space) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess) return; + const q = sess.questions[sess.qIndex]; + if (!q) { endSpecialQuizSession(sid, space, false); return; } + sess.answers = {}; + sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS; + io.to(sid).emit('special-quiz-question', { + index: sess.qIndex, + total: sess.questions.length, + text: q.text, + choices: q.choices, + endsAt: sess.phaseEndsAt, + iconType: sq.iconType, + roster: specialQuizRosterPayload(sess), + }); + scheduleSpecialQuizBotAnswers(sid, space, sess, q); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + resolveSpecialQuizQuestion(sid, spNow, true); + }, SPECIAL_QUIZ_ANSWER_MS + 80); + space.specialQuizTimers.push(t); +} + +/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */ +function resolveSpecialQuizQuestion(sid, space, fromTimeout) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess || sess.resolving) return; + sess.resolving = true; + clearSpecialQuizTimers(space); + const q = sess.questions[sess.qIndex]; + const expected = specialQuizExpectedIds(space, sess); + let allCorrect = expected.length > 0; + expected.forEach((pid) => { + const a = sess.answers[pid]; + if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false; + }); + io.to(sid).emit('special-quiz-result', { + index: sess.qIndex, + correctIndex: q.correctIndex, + answers: { ...sess.answers }, + roster: specialQuizRosterPayload(sess), + allCorrect, + timeout: !!fromTimeout, + }); + if (!allCorrect) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, false); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, true); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + sess.resolving = false; + sendSpecialQuizQuestion(sid, spNow); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); +} + +/** + * นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน: + * minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation) + * now = ใช้ทันทีตอนได้รับ (Fund coins) + * after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence) + * pre_trial = ก่อนโหวตพิจารณาคดี (Silence) + * trial_vote = ระหว่างโหวตพิจารณาคดี (Extension) + * trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin) + * lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban) + */ +const SPECIAL_CARD_DEFS = { + 1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' }, + 2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' }, + 3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' }, + 4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' }, + 5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' }, + 6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' }, + 7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, +}; + +function specialCardDef(cardId) { + return SPECIAL_CARD_DEFS[Number(cardId)] || null; +} + +/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */ +function specialCardClientPayload(card) { + if (!card) return null; + const def = specialCardDef(card.cardId) || {}; + return { + cardId: card.cardId || null, + th: card.th || def.th || '', + en: card.en || '', + rarity: card.rarity || 'common', + imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''), + txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''), + effectKey: def.key || '', + effectWhen: def.when || '', + addSec: def.addSec || 0, + desc: def.th || '', + }; +} + +/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */ +const TIME_DILATION_MAX_PENDING_SEC = 30; + +/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */ +function applySpecialCardImmediate(sid, space, card) { + const def = specialCardDef(card && card.cardId); + if (!def) return; + if (def.when === 'minigame' && def.key === 'time_dilation') { + /* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง + cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */ + space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10)); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + addSec: def.addSec || 10, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + if (def.when === 'now' && def.key === 'fund') { + /* +COINS — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม + noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */ + const coins = def.coins || 10; + const awards = [...space.peers.values()] + .filter((p) => p.playerKey) + .map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true })); + submitGameAwards(awards); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + coins: coins, + coinsServerAwarded: true, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) + — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ +} + +/* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */ +function dbgCards(space) { + return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : ''))); +} +function pendingSpecialCardList(space) { + if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = []; + return space.pendingSpecialCards; +} +/** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */ +function addPendingSpecialCard(space, card) { + if (!card || !card.cardId) return null; + const list = pendingSpecialCardList(space); + const cid = Number(card.cardId); + if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; } + const entry = { cardId: cid, card: card, consumed: false }; + list.push(entry); + console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space)); + return entry; +} +/** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */ +function findPendingSpecialCard(space, cardId) { + const cid = Number(cardId); + return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null; +} +function consumePendingSpecialCard(space, cardId) { + const e = findPendingSpecialCard(space, cardId); + if (e) e.consumed = true; +} + +function endSpecialQuizSession(sid, space, success) { + const sq = space.specialQuiz; + if (!sq) return; + clearSpecialQuizTimers(space); + sq.done = true; + sq.session = null; + let cardOut = null; + console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId)); + if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { + space.specialCardAwardedThisRun = sq.card; + cardOut = specialCardClientPayload(sq.card); + addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */ + applySpecialCardImmediate(sid, space, sq.card); + } + io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); + /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) + — มี safety timeout กันค้างถ้าไม่มีใครกด */ + space.specialQuizAwaitContinue = true; + space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */ + /* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */ + io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size }); + if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = setTimeout(() => { + space.specialQuizContinueTimer = null; + finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */ + }, 32000); +} + +/** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */ +function maybeFinishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set()); + const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */ + const ready = expected.filter((pid) => acks.has(pid)).length; + io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length }); + if (expected.length === 0 || expected.every((pid) => acks.has(pid))) { + finishSpecialQuizContinue(sid, space); + } +} + +/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ +function finishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + space.specialQuizAwaitContinue = false; + space.specialQuizContinueAcks = null; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + /* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */ + io.to(sid).emit('special-quiz-resume-all', {}); + resumeQuizAfterSpecialQuiz(sid, space); +} + +/* เกมหยุดชั่วคราวระหว่างคำถามพิเศษ (กำลังตอบ หรือรอกด «เล่นต่อ») + ใช้ร่วมกันทุก minigame ฝั่ง server — เดิมมีแต่ gauntlet เช็ค ทำให้ MG6/MG7 บอทยังเดิน/ยิงตอน pause */ +function isSpecialQuizPausedServer(space) { + if (!space) return false; + return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)); +} + +function startSpecialQuizSession(sid, space) { + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return false; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, sq.level, sq.caseId); + const all = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + if (!all.length) return false; + /* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */ + const picked = specialQuizShuffle(all).slice(0, 1); + sq.triggered = true; + sq.session = { + questions: picked, + qIndex: 0, + answers: {}, + phaseEndsAt: 0, + resolving: false, + roster: buildSpecialQuizRoster(space), + }; + clearSpecialQuizTimers(space); + pauseQuizForSpecialQuiz(sid, space); + io.to(sid).emit('special-quiz-icon-clear', {}); + sendSpecialQuizQuestion(sid, space); + return true; +} + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 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, + }; +} + +/** อ่านเวลา 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, + }; + } + } 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), + /* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */ + lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud') + ? !!d.lobbyDebugHud + : !!prev.lobbyDebugHud, + lobbyDebugHudNames: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHudNames') + ? (Array.isArray(d.lobbyDebugHudNames) ? d.lobbyDebugHudNames.slice(0, 30).map((s) => String(s).slice(0, 40)) : []) + : (Array.isArray(prev.lobbyDebugHudNames) ? prev.lobbyDebugHudNames : []), + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const specialQuizSets = d.specialQuizSets != null + ? sanitizeSpecialQuizSets(d.specialQuizSets) + : sanitizeSpecialQuizSets(prev.specialQuizSets); + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + specialQuizSets, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + specialQuizSets: out.specialQuizSets, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + let cw = Math.floor(Number(md.characterCellsW)); + let ch = Math.floor(Number(md.characterCellsH)); + const hasW = Number.isFinite(cw) && cw >= 1; + const hasH = Number.isFinite(ch) && ch >= 1; + if (hasW && hasH) { + return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; + } + const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); + return { cw: leg, ch: leg }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(md); + let colW = Math.floor(Number(md.characterCollisionW)); + let colH = Math.floor(Number(md.characterCollisionH)); + if (!Number.isFinite(colW) || colW < 1) colW = cw; + if (!Number.isFinite(colH) || colH < 1) colH = ch; + colW = Math.max(1, Math.min(cw, colW)); + colH = Math.max(1, Math.min(ch, colH)); + return { cw, ch, colW, colH }; +} + +const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; +const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; + +function quizBattleSpriteBoundsTileKeys(md, px, py) { + const out = []; + if (!md || md.gameType !== 'quiz_battle') return out; + const x = Number(px), y = Number(py); + if (!Number.isFinite(x) || !Number.isFinite(y)) return out; + const w = md.width || 20, h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const cx = x + cw * 0.5; + const feetY = y + ch; + const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; + const bottom = feetY; + if (left < 0 || right > w || top < 0 || bottom > h) return out; + const minTx = Math.floor(left); + const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); + const minTy = Math.floor(top); + const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`); + } + return out; +} + +function serverWallCollisionTileKeys(md, px, py) { + if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(md, px, py); + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + for (let ty = offY; ty < offY + colH; ty++) { + for (let tx = offX; tx < offX + colW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; +} + +function validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */ +function pauseQuizForSpecialQuiz(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.active || space.quizFrozenForSpecial) return; + const phase = sess.phase; + if (phase !== 'read' && phase !== 'answer') return; + const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now()); + space.quizFrozenForSpecial = true; + space.quizFreezePhase = phase; + space.quizFreezeRemainingMs = remaining; + clearSpaceQuizTimers(space); + io.to(sid).emit('quiz-paused', { phase }); +} + +/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */ +function resumeQuizAfterSpecialQuiz(sid, space) { + if (!space.quizFrozenForSpecial) return; + space.quizFrozenForSpecial = false; + const sess = space.quizSession; + const phase = space.quizFreezePhase; + const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0); + space.quizFreezePhase = null; + space.quizFreezeRemainingMs = 0; + if (!sess || !sess.active) return; + const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space); + if (!md) return; + /* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */ + let extraMs = 0; + if (space.timeDilationPendingSec > 0) { + extraMs = space.timeDilationPendingSec * 1000; + space.timeDilationPendingSec = 0; + } + sess.phaseEndsAt = Date.now() + remaining + extraMs; + if (phase === 'read') { + sess.phase = 'read'; + const q = sess.questions[sess.qIndex]; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + text: String((q && q.text) || '').trim(), + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining); + space.quizTimers.push(t); + } else { + sess.phase = 'answer'; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining); + space.quizTimers.push(t); + } +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function ensurePeerLobbyThemes(space) { + if (!space || !space.peers) return; + const seen = new Set(); + space.peers.forEach((p, id) => { + let ti = parseInt(p.lobbyColorThemeIndex, 10); + if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) { + ti = pickFreeLobbyThemeIndex(space, id); + p.lobbyColorThemeIndex = ti; + } + seen.add(ti); + if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) { + p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0); + } + }); +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null, + lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, + lobbySkinToneIndex: p.lobbySkinToneIndex || null, + bannedSpectator: !!p.bannedSpectator, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ + → client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client + เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */ +/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */ +function isBannedPeerForRun(space, id) { + if (!space) return false; + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true; + const pp = space.peers && space.peers.get(id); + return !!(pp && pp.bannedSpectator); +} + +/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */ +function readyMapWithoutBanned(space, srcMap) { + const out = {}; + if (srcMap && typeof srcMap === 'object') { + Object.keys(srcMap).forEach((id) => { + if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id]; + }); + } + return out; +} + +function quizCarryLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady), + peers: buildPeersSnapForSpace(space), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + }; +} + +/* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน + แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */ +function crownLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + peers: buildPeersSnapForSpace(space), + }; +} + +/* เช็คว่าพร้อมเริ่มมินิเกม crown-lobby ไหม: ยังไม่เริ่ม + เข้าครบ + ทุกคน (ไม่นับ banned) กด Ready */ +function crownLobbyAllReadyToStart(space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)); + if (humanIds.length === 0) return false; + const expectedActive = space.minigameExpectedActiveCount || 0; + if (expectedActive > 0 && humanIds.length < expectedActive) return false; + return humanIds.every((id) => rm[id]); +} + +/* เริ่มมินิเกม crown-lobby (server เป็นคนสั่ง) — idempotent ด้วย gr.lobbyStarted (รีเซ็ตเองทุกมินิเกมเพราะ gr สร้างใหม่) + ใช้ทั้ง host กด START และ auto-start เมื่อทุกคน ready (Track B: MG1 ไม่ต้องพึ่ง host) */ +function performCrownLobbyStart(sid, space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + gr.lobbyStarted = true; + const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; + space.missionLiveBeginsAt = liveBeginsAt; + if (isQuizQuestionMissionShellSpace(space)) { + /* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ตรงกับตอน client จบ 3-2-1) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'quiz') { + clearSpaceQuizTimers(sp); + startQuizGame(sid, sp, md2); + } + }, LIVE_COUNTDOWN_MS); + } + if (isStackTowerMissionShellSpace(space)) { + /* MG3 stack: เริ่ม server engine ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'stack') startStackTicker(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isJumpSurviveMissionShellSpace(space)) { + /* MG5 jump_survive: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'jump_survive') startJumpSurviveGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isSpaceShooterMissionShellSpace(space)) { + /* MG6 space_shooter: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'space_shooter') startSpaceShooterGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isBalloonBossMissionShellSpace(space)) { + /* MG7 balloon_boss: เริ่ม server engine (บอท+รอบ+HP บอส) ตอน client จบ countdown */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'balloon_boss') startBalloonBossGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt }); + return true; +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +/* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด + ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */ +function detectiveLobbyBMissingParticipants(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 }; + const present = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) present.add(n); + }); + const total = roster.size; + return { missing: Math.max(0, total - present.size), present: present.size, total }; +} + +/* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */ +function emitDetectiveLobbyBPresence(sid, space) { + if (!sid || !space) return; + if (!serverMapIsPostCaseLobbyB(space)) return; + const info = detectiveLobbyBMissingParticipants(space); + if (info.total <= 0) return; + io.to(sid).emit('detective-lobbyb-presence', { + present: info.present, + total: info.total, + allHere: info.missing === 0, + }); +} + +/* TC156 story-gate: นับผู้เล่นในคดี "ที่อ่าน story (cutscene) จบแล้ว" เทียบกับคนที่อยู่ในห้องตอนนี้ + allRead = ทุก roster-nick ที่ยัง present ได้ส่ง story-read-done แล้ว (คนที่ left ไม่นับ — presence gate คุมแยก) */ +function detectiveStoryReadStatus(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { present: 0, total: 0, allRead: true }; + const read = space.storyReadNicks || new Set(); + const presentRosterNicks = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) presentRosterNicks.add(n); + }); + let readCount = 0; + presentRosterNicks.forEach((n) => { if (read.has(n)) readCount++; }); + const allRead = !!space.storyReadComplete || (presentRosterNicks.size > 0 && readCount >= presentRosterNicks.size); + return { present: readCount, total: presentRosterNicks.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) || '', + }; + }); + // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ + space.playerEvidence = {}; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; +} + +function ensureSpacePlayerEvidence(space) { + if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {}; + if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {}; +} + +/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */ +function normalizeEvidenceSlotList(cards) { + if (!Array.isArray(cards)) return []; + const out = []; + for (let i = 0; i < cards.length; i++) { + const c = sanitizeStoredEvidenceCard(cards[i]); + if (c) out.push(c); + } + return out; +} + +/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */ +function peerNickKey(space, playerId) { + if (!space || !playerId) return ''; + const p = space.peers && space.peers.get ? space.peers.get(playerId) : null; + if (!p || !p.nickname) return ''; + return normalizeLobbyNickname(p.nickname); +} + +/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */ +function clonePlayerEvidence(space, playerId) { + ensureSpacePlayerEvidence(space); + let row = space.playerEvidence[playerId]; + if (!row || !row.length) { + const nk = peerNickKey(space, playerId); + if (nk && space.evidenceByNick[nk]) { + row = space.evidenceByNick[nk]; + space.playerEvidence[playerId] = row; + } + } + if (!row) row = [[], [], []]; + return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i])); +} + +/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */ +function syncEvidenceByNick(space, playerId) { + ensureSpacePlayerEvidence(space); + const nk = peerNickKey(space, playerId); + if (!nk) return; + const row = space.playerEvidence[playerId]; + if (Array.isArray(row)) space.evidenceByNick[nk] = row; +} + +/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */ +function playerSuspectProgressFromEvidence(space, playerId) { + const row = clonePlayerEvidence(space, playerId); + return row.map((arr) => arr.length); +} + +/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */ +function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) { + if (suspectIndex < 0 || suspectIndex > 2) return null; + ensureSpacePlayerEvidence(space); + /* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */ + if (!space.playerEvidence[playerId]) { + const nk = peerNickKey(space, playerId); + space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk]) + ? space.evidenceByNick[nk] + : [[], [], []]; + } + const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]); + /* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */ + if (list.length >= 3) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + const card = pickEvidenceCardForSuspect(space, suspectIndex, grade); + if (!card) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + list.push(card); + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return card; +} + +function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) { + const out = {}; + (playerIds || []).forEach((pid) => { + if (!space.peers.has(pid)) return; + const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade); + if (card != null) out[pid] = card; + }); + return out; +} + +function resolveDetectiveSuspectAwardIndex(space) { + if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) { + return space.suspectActiveIndex; + } + if (typeof space.lastDetectiveInvestigatedSuspect === 'number' + && space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) { + return space.lastDetectiveInvestigatedSuspect; + } + if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) { + return space.suspectPickIndex; + } + return -1; +} + +/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */ +function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { + const suspectIdx = resolveDetectiveSuspectAwardIndex(space); + if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} }; + if (space.detectiveAwardDoneForRun) { + return { suspectIdx, awarded: {}, alreadyGranted: true }; + } + const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()]; + const grade = computeRunGradeForEvidence(space); + const awardedList = {}; + ids.forEach((pid) => { awardedList[pid] = []; }); + const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade); + ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); }); + /* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */ + let freeEvidenceCount = 0; + const pend = findPendingSpecialCard(space, 7); + if (pend) { + const def = specialCardDef(7); + freeEvidenceCount = (def && def.evidence) || 1; + for (let n = 0; n < freeEvidenceCount; n++) { + ids.forEach((pid) => { + if (!space.peers.has(pid)) return; + const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade); + if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; } + }); + } + pend.consumed = true; + } + space.detectiveAwardDoneForRun = true; + space.lastDetectiveInvestigatedSuspect = suspectIdx; + space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount }; + emitLobbyEvidenceSync(sid, space); + return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount }; +} + +function emitLobbyEvidenceSync(sid, space) { + if (!sid || !space) return; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('lobby-evidence-sync', { + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) { + const msg = message || 'จบมินิเกม — กลับ LobbyB'; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + const personal = { + ...basePayload, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }; + sock.emit('quiz-ended', { message: msg, returnToLobbyB: true }); + sock.emit('detective-minigame-ended', personal); + sock.emit('game-start', personal); + }); +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveAwardDoneForRun = false; + space.coinAwardDoneForRun = false; + space.lastCrownMissionGrade = null; + /* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */ + space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; }); + /* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */ + space.lastMinigameOver = null; + /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ + space.bannedThisRunPlayerId = space.bannedPlayerId || null; + console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId); + space.bannedPlayerId = null; + /* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน) + สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */ + space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + glog(sid, space, 'game-start', 'เริ่มมินิเกม: ' + ((md && md.name) || mapId) + ' (การ์ด ' + ((selectedIndex != null ? selectedIndex + 1 : '?')) + ')'); + /* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */ + maybeSpawnSpecialQuizForRun(space); + /* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */ + + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */ + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + /** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */ + const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID; + if (!isQuizMissionShell) { + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + /* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */ + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + /* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client + กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */ + if (md.gameType === 'quiz_carry') { + space.quizCarryLobbyReady = {}; + space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; }); + io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) { + ensurePostCaseLobbyMapLoaded(); + /* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */ + awardMinigameRankCoins(sid, space); + clearSpaceQuizTimers(space); + clearSpecialQuizTimers(space); + space.specialQuiz = null; + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + /* Last Light (MG2): เก็บเกรด mission (รวมบอท server-authoritative) ก่อนล้าง gauntletRun + → ใช้เป็น "แหล่งเดียว" ของเกรดหลักฐาน ให้ตรงกับสรุปภารกิจที่ผู้เล่นเห็น */ + if (space.gauntletRun && isGauntletCrownHeistMapBySpace(space)) { + try { space.lastCrownMissionGrade = buildGauntletCrownMissionPayload(space).grade; } + catch (_e) { space.lastCrownMissionGrade = null; } + } + stopGauntletTicker(space); + space.gauntletRun = null; + /* MG3 stack (server-auth): หยุด engine + ล้าง session (เกรดถูกเก็บไว้ใน lastStackMissionGrade ตอนจบแล้ว) */ + stopStackTicker(space); + if (space.stackSession) space.stackSession.active = false; + space.stackSession = null; + /* MG4 quiz_carry (server-auth): ล้าง session (คะแนนถูกอ่านไปแจกเหรียญใน awardMinigameRankCoins ด้านบนแล้ว) */ + if (space.quizCarrySession) space.quizCarrySession.active = false; + space.quizCarrySession = null; + 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; + let awardIds = []; + if (awardOptions && awardOptions.allPlayers) { + awardIds = [...space.peers.keys()]; + } else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) { + awardIds = [awardOptions.playerId]; + } else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) { + awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id)); + } + /* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */ + awardIds = awardIds.filter((id) => { + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false; + const pp = space.peers.get(id); + if (pp && pp.bannedSpectator) return false; + return true; + }); + /* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */ + if (!space.detectiveAwardDoneForRun && awardIds.length) { + /* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */ + grantDetectiveEvidenceForCurrentRun(sid, space, awardIds); + } + } + space.suspectActiveIndex = null; + space.bannedThisRunPlayerId = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const basePayload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + detectiveReturn: true, + }; + emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload); + /* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ" + ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */ + const banPend = findPendingSpecialCard(space, 3); + console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space)); + if (banPend && !space.pickVote) { + banPend.consumed = true; + /* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */ + space.pendingBanVoteCard = banPend.card; + } +} + +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: trialEligibleVoterTotal(space), + }); +} + +function computeAndEmitTrialResult(sid, space) { + /* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */ + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + /* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */ + { + /* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */ + const forceWrong = !!space.testForceWrongVote; + space.testForceWrongVote = false; + const c2 = [0, 0, 0]; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; }); + let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; } + const anyVotes = (c2[0] + c2[1] + c2[2]) > 0; + const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit); + const pend = findPendingSpecialCard(space, 5); + if (wrong && pend && !space.trialBailUsed) { + pend.consumed = true; + space.trialBailUsed = true; + space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */ + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); + openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted }); + return; + } + } + space.trialPhase = 'revealed'; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + if (p && p.nickname) return p.nickname; + if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) { + const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10); + return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท'; + } + return String(id).slice(0, 6); + }); + /* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */ + let disruptorId = null; + if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) { + for (const [pid, p] of space.peers) { + const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey; + const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname; + if (keyMatch || nickMatch) { disruptorId = pid; break; } + } + } + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + disruptorId, + hasDisruptor: !!disruptorId, + }); + + /* ===== Achievement auto-track ===== */ + try { + const achv = []; + /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */ + winners.forEach((id) => { + const p = space.peers.get(id); + if (p && p.playerKey) { + achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 }); + achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 }); + } + }); + /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */ + if (disruptorId && space.disruptorPlayerKey) { + const dKey = space.disruptorPlayerKey; + achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */ + /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */ + let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; } + const anyV = (counts[0] + counts[1] + counts[2]) > 0; + if (anyV && mv !== culprit) { + achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */ + } + } + submitAchievementProgress(achv); + } catch (e) { console.error('[achv] trial-result', e && e.message); } + + space.silencedPlayerId = null; +} + +/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ +/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */ +const TESTIMONY_BOT_PREFIX = '__lobby_bot_'; + +function testimonyBotIds(space) { + const n = effectiveBotSlotCount(space); + const out = []; + for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i); + return out; +} + +function buildTestimonyMembers(space) { + const arr = []; + space.peers.forEach((p, id) => { + arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false }); + }); + testimonyBotIds(space).forEach((bid, i) => { + arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true }); + }); + return arr; +} + +/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */ +function testimonyTotalParticipants(space) { + return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); +} + +/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */ +function botTestimonyPickCount(space) { + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + let maxOwned = 0; + space.peers.forEach((_p, pid) => { + const ev = clonePlayerEvidence(space, pid); + const len = Array.isArray(ev[round]) ? ev[round].length : 0; + if (len > maxOwned) maxOwned = len; + }); + /* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */ + if (maxOwned === 0) return 1; + return Math.max(1, Math.min(2, maxOwned)); +} + +/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */ +function autoSubmitBotTestimonyPicks(space) { + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + const want = botTestimonyPickCount(space); + testimonyBotIds(space).forEach((bid) => { + if (space.testimonySubmitted[bid] != null) return; + const picks = []; + for (let n = 0; n < want; n++) { + const c = pickEvidenceCardForSuspect(space, round, 'C'); + if (c) picks.push(c); + } + space.testimonySubmitted[bid] = picks; + }); +} + +function emitTestimonyOpen(sid, space) { + space.testimonySubmitted = {}; + space.testimonyReadyNext = {}; + space.testimonyRevealed = false; + /* บอทส่งหลักฐานทันทีเมื่อเปิด round */ + autoSubmitBotTestimonyPicks(space); + const submittedIds = Object.keys(space.testimonySubmitted || {}); + const base = { + round: space.testimonyRound, + suspectIndex: space.testimonyRound, + members: buildTestimonyMembers(space), + totalPlayers: testimonyTotalParticipants(space), + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + submitted: submittedIds, + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('testimony-open', { + ...base, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + /* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */ + emitTestimonyStatus(sid, space); +} + +function emitTestimonyStatus(sid, space) { + io.to(sid).emit('testimony-status', { + round: space.testimonyRound, + submitted: Object.keys(space.testimonySubmitted || {}), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +function doTestimonyReveal(sid, space) { + space.testimonyRevealed = true; + /* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */ + autoSubmitBotTestimonyPicks(space); + /* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + io.to(sid).emit('testimony-reveal', { + round: space.testimonyRound, + picks: space.testimonySubmitted || {}, + members: buildTestimonyMembers(space), + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */ +/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */ +/* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */ +function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; } +const TRIAL_VOTE_EXTENSION_MS = 10000; + +function clearTrialVoteTimer(space) { + if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; } +} + +/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */ +function openTrialVotingPhase(sid, space, opts) { + opts = opts || {}; + space.testimonyActive = false; + space.trialPhase = 'voting'; + space.trialVotes = {}; + /* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */ + const excludedSuspect = opts.revote + ? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect + : (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null)) + : null; + if (!opts.revote) space.trialRevoteExcluded = null; + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ + let extraMs = 0; + let extensionCard = false; + const pend = findPendingSpecialCard(space, 2); + if (pend) { + extraMs = TRIAL_VOTE_EXTENSION_MS; + extensionCard = true; + pend.consumed = true; + } + const durMs = trialVoteMs() + extraMs; + space.trialVoteEndsAt = Date.now() + durMs; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + voteEndsAt: space.trialVoteEndsAt, + voteDurationMs: durMs, + extensionCard, + extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0, + revote: !!opts.revote, + excludedSuspect: excludedSuspect, + silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId), + }); + }); + if (extensionCard) { + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(pend.card), + addSec: TRIAL_VOTE_EXTENSION_MS / 1000, + context: 'trial', + }); + } + emitTrialVoteUpdate(sid, space); + space.trialVoteTimer = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.trialPhase !== 'voting') return; + computeAndEmitTrialResult(sid, spNow); + }, durMs + 120); +} + +/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */ +/* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */ +function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; } + +function clearPickVoteTimer(space) { + if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; } +} + +function pickVoteCandidates(space) { + const arr = []; + space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false })); + testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true })); + return arr; +} + +function autoVotePickBots(space) { + const pv = space.pickVote; + if (!pv) return; + testimonyBotIds(space).forEach((bid) => { + if (pv.votes[bid] != null) return; + const choices = pv.candidates.filter((c) => c.id !== bid); + if (!choices.length) return; + pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id; + }); +} + +function emitPickVoteUpdate(sid, space) { + const pv = space.pickVote; + if (!pv) return; + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + io.to(sid).emit('player-pick-vote-update', { + voted: Object.keys(pv.votes).length, + total: pv.candidates.length, + counts, + }); +} + +function startPlayerPickVote(sid, space, purpose, onResolve) { + clearPickVoteTimer(space); + const PV_MS = pickVoteMs(); + const candidates = pickVoteCandidates(space); + space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_MS, resolved: false, onResolve }; + autoVotePickBots(space); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('player-pick-vote-open', { + purpose, + candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: space.pickVote.endsAt, + durationMs: PV_MS, + }); + }); + emitPickVoteUpdate(sid, space); + space.pickVoteTimer = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.pickVote || sp.pickVote.resolved) return; + resolvePlayerPickVote(sid, sp); + }, PV_MS + 120); + maybeResolvePickVote(sid, space); +} + +function maybeResolvePickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + const ids = pv.candidates.map((c) => c.id); + if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space); +} + +function resolvePlayerPickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + pv.resolved = true; + clearPickVoteTimer(space); + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + let max = 0; + Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); + const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); + /* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */ + const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : null; + const tc = pv.candidates.find((c) => c.id === target); + const targetName = tc ? tc.nickname : ''; + io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts }); + const cb = pv.onResolve; + space.pickVote = null; + if (typeof cb === 'function') cb(target, targetName); +} + +function trialSilencedIsParticipant(space) { + const s = space.silencedPlayerId; + if (!s) return false; + if (space.peers.has(s)) return true; + return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0); +} + +function trialEligibleVoterTotal(space) { + let t = testimonyTotalParticipants(space); + if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1); + return t; +} + +function autoVoteBotsTrial(space) { + if (!space.trialVotes) space.trialVotes = {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3); + const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1; + testimonyBotIds(space).forEach((bid) => { + if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */ + if (space.trialVotes[bid] != null) return; + /* 40% โหวตถูก, 60% โหวตสุ่ม */ + let pick; + if (Math.random() < 0.4) { + pick = culprit; + } else { + pick = Math.floor(Math.random() * 3); + } + /* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */ + if (pick === excluded) pick = culprit; + space.trialVotes[bid] = pick; + }); +} + +function advanceTestimony(sid, space) { + space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; + if (space.testimonyRound >= 3) { + space.trialBailUsed = false; + space.silencedPlayerId = null; + /* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */ + const pend = findPendingSpecialCard(space, 4); + console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space)); + if (pend) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' }); + startPlayerPickVote(sid, space, 'silence', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.silencedPlayerId = targetId || null; + openTrialVotingPhase(sid, sp, { revote: false }); + }); + return; + } + openTrialVotingPhase(sid, space, { revote: false }); + } else { + emitTestimonyOpen(sid, space); + } +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + /* คะแนนสุดท้าย authoritative (ผู้เล่นจริง) — ส่งไปกับ quiz-ended ให้ทุก client ตัดสินผล complete/gameover ตรงกัน + (เดิมไม่ส่ง → client อ่าน playLiveQuizScores local ที่ quiz-result ข้อสุดท้ายอาจมาไม่ทัน → ผล 2 จอไม่ตรง) */ + const quizFinalScores = {}; + const sessF = space.quizSession; + if (sessF && sessF.players) { + Object.keys(sessF.players).forEach((pid) => { + const st = sessF.players[pid]; + quizFinalScores[pid] = (st && typeof st.score === 'number') ? Math.max(0, st.score) : 0; + }); + } + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) + เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" + การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true, scores: quizFinalScores }); + /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ + (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', scores: quizFinalScores }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + /* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */ + const qCur = sess.questions[sess.qIndex]; + if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + /* บอท: server เป็นเจ้าของคะแนน (จากคำตอบที่ตัดสินไว้) + คัดออกถ้าผิด (กฎเดียวกับคน "ตอบผิดถูกคัดออก") */ + if (sess.bots) { + sess.bots.forEach((b, botId) => { + if (!b.eliminated) { + if (b.answerRight) { + b.score += QUIZ_TF_POINTS_PER_CORRECT; + } else { + b.eliminated = true; + /* คัดออก → วาร์ปเข้าโซนผีทันที (เหมือนคนจริง) */ + const gc = quizRandomCellInZone(mdLive.quizGhostArea, mdLive); + if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; } + b.wanderTX = null; b.retargetAt = 0; + } + } + scores[botId] = b.score; + results.push({ + id: botId, nickname: 'บอท', right: !!b.answerRight, choice: b.choice, + eliminated: !!b.eliminated, score: b.score, + }); + b.tx = null; b.ty = null; /* รีเซ็ตเป้า → เดินมั่วจนข้อถัดไป */ + }); + } + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + /** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */ + const playFullQuizRound = + !!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space); + if (allWrong && !playFullQuizRound) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + space.quizSession.bots = createQuizBots(space, md); + scheduleQuizReadPhase(sid, space, md); +} + +/* ===== MG1 quiz bots — server-authoritative (roster + เดิน + ตอบ + คะแนน) ===== + เดิม host เดิน+คิดคะแนนบอทเอง → host หลุด = บอทหยุด + คะแนนเพี้ยน. ย้ายมา server → บอทเดินเอง time-based + คะแนน server เป็นเจ้าของ + id `__pv_bot_` 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; + try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId || '-') + ' 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, + 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) => { + /* ทับ 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; + } + 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); +} + +/* ── 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, + }; +} + +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 = 1500; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป */ +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, + }); +} +function stackAdvanceTurnServer(s) { + if (!s.turnOrder || !s.turnOrder.length) return; + s.turnIndex = (s.turnIndex + 1) % s.turnOrder.length; + s.botThinkUntil = 0; + s.botAimPhase = null; +} +function stackSessionMissionGrade(space) { + const s = space.stackSession; + if (!s) return 'C'; + const scores = []; + s.turnOrder.forEach((e) => { + if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); scores.push(Math.max(0, (b && b.score) | 0)); } + else if (e.id != null) scores.push(Math.max(0, s.humanPts[e.id] | 0)); + }); + const n = scores.length || 1; + const avg = Math.floor(scores.reduce((a, b) => a + b, 0) / n); + let grade = avg >= 80 ? 'A' : avg >= 60 ? 'B' : 'C'; + return grade; +} +function stackSessionEnd(sid, space, outcome) { + const s = space.stackSession; + if (!s || s.ended) return; + s.ended = true; + stopStackTicker(space); + /* คะแนนผู้เล่นจริง → reportedMiniScore (ใช้ทางเดิมของ coin/evidence) */ + s.turnOrder.forEach((e) => { + if (e.kind === 'human' && e.id != null) { + const p = space.peers.get(e.id); + if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; } + } + }); + try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; } + const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' }; + io.to(sid).emit('stack-over', overPayload); + space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() }; +} +function runStackTick(sid, space) { + const s = space.stackSession; + if (!s || !s.active || s.ended) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; } + const now = Date.now(); + /* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */ + if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; } + if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; } + if ((now - s.startMs) >= stackTimeLimitSecServer() * 1000) { stackSessionEnd(sid, space, 'time_up'); return; } + if (!s.turnOrder.length) return; + /* รอแอนิเมชันบล็อกก่อนหน้า (busy) → ไม่แกว่ง/ไม่ดรอป */ + if (now < s.busyUntil) return; + const entry = stackCurrentTurnEntry(s); + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + const bot = s.bots.get(entry.botId); + const tier = bot ? bot.tier : 'avg'; + if (s.botThinkUntil === 0) { + s.botThinkUntil = now + STACK_BOT_THINK_MIN_MS + Math.floor(Math.random() * (STACK_BOT_THINK_MAX_MS - STACK_BOT_THINK_MIN_MS)); + } else { + /* เลื่อนเฟสตามเวลา (ให้ client เห็นแกว่ง) */ + s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2; + /* ส่ง swingAmp+topCenterX ด้วย → client วาดบล็อกที่ห้อยตรงกับโมเดล server เป๊ะ (drop landOffset = swingAmp*sin(phase)) ไม่วาปตอนปล่อย */ + if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX, 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; + const _dbgAim = s.botAimPhase; const _dbgDeadline = (now >= s.botAimDeadline) && !(s.phase >= s.botAimPhase); + s.botAimPhase = null; + const hit = stackComputeDropServer(s, md); + /* [DBG-MG3drop] บอทดรอปที่ s.phase → เทียบ aimPhase/จุดลง/รองรับ/overlap/miss (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'BOT ' + (entry.botId || '') + ' phase=' + s.phase.toFixed(3) + + ' aimPhase=' + (Number.isFinite(_dbgAim) ? _dbgAim.toFixed(3) : 'x') + (_dbgDeadline ? '(DEADLINE!)' : '(reached)') + + ' sinDrop=' + Math.sin(s.phase).toFixed(3) + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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 }); + } + /* [DBG-MG6move] จับสถานะบอททุก ~2.5วิ → STILL=server ไม่ขับ, DEAD=ตาย(client ต้องซ่อน), MOV=ปกติ */ + if (!s._dbgMT || now - s._dbgMT > 2500) { + s._dbgMT = now; + const rows = []; + s.bots.forEach((b, id) => { + const mv = (b._ldx == null) ? '?' : ((Math.abs(b.cx - b._ldx) + Math.abs(b.cy - b._ldy) > 2) ? 'MOV' : 'STILL'); + b._ldx = b.cx; b._ldy = b.cy; + const tag = (id.indexOf(JS_BOT_PREFIX) === 0 ? 'pv' + id.slice(JS_BOT_PREFIX.length) : 'TK:' + String(id).slice(0, 5)); + rows.push(tag + (b.takeover ? 'tk' : '') + '·' + (b.eliminated ? 'DEAD' : mv)); + }); + glog(sid, space, 'mg6-move', '[' + rows.join(' ') + ']'); + } +} + +/* ============================================================ + 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' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true }); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + y0 = Math.max(0, Math.min(h - 1, y0)); + y1 = Math.max(0, Math.min(h - 1, y1)); + if (y1 < y0) { + const t = y0; + y0 = y1; + y1 = t; + } + return { y0, y1 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + /* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */ + if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) { + if (gr.endsAt != null) gr.endsAt += getGauntletTickMs(); + emitGauntletSync(sid, space); + return; + } + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client + เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */ + if (gr.finishPhase) { + if (gr.obstacles.length) { gr.obstacles = []; } + if (gr.finishEndAt == null) gr.finishEndAt = Date.now() + GAUNTLET_FINISH_WALK_MS; + if (Date.now() >= gr.finishEndAt) { + endGauntletGame(sid, space, 'finish'); + } + /* ไม่ emitGauntletSync ระหว่าง grace — ปล่อย client คุมการเดินเข้าเส้นเอง (กัน sync ดึงตำแหน่งกลับ) */ + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* G2: pre-run hold — หลัง 3-2-1 จบ ค้างที่จุด start ให้เห็นเส้นเริ่มก่อน (ยังไม่เลื่อน/เกิด obstacle/ขยับบอท) + client ค้าง bg เองด้วย max(0, serverNow − liveStartMs) → bg+bots/obstacles sync ที่ start พร้อมกัน */ + if (gr.liveStartMs != null && Date.now() < gr.liveStartMs) { + space.peers.forEach((p) => { p.gauntletJumpPending = false; }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* บอท server-authoritative — แถวเลนของบอทที่ยังเล่นอยู่ (ไม่ต้องพึ่ง gauntlet-preview-rows จาก host) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (b.gauntletEliminated) return; + const lr = gauntletLaneGridRowFromPlayerTop(md, b.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + } + /* แถวบนที่ client แจ้ง (บอท preview — editor หรือ client เก่า) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + let _gauntletLaserThisTick = false; + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + _gauntletLaserThisTick = true; + } + /* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */ + if (!_gauntletLaserThisTick) { + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + /* บอท server-authoritative — ตัดสินใจกระโดด + ฟิสิกส์ รอบ tick เดียวกับผู้เล่น (obstacle เลื่อนแล้วข้างบน) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (crownHeist && b.gauntletEliminated) return; + gauntletBotDecideJump(b, gr.obstacles, md, w, h); + gauntletBotStepPhysics(b, gr.obstacles, md, w, h, crownHeist); + }); + } + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; +} + +function mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(md) { + const w = md.width || 20; + const h = md.height || 15; + const out = [null, null, null, null, null, null]; + const raw = md && md.lobbyPlayerSpawns; + if (!Array.isArray(raw)) return out; + for (let i = 0; i < 6 && i < raw.length; i++) { + const cell = raw[i]; + if (!cell || typeof cell !== 'object') continue; + const x = Math.floor(Number(cell.x)); + const y = Math.floor(Number(cell.y)); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (x < 0 || x >= w || y < 0 || y >= h) continue; + out[i] = { x, y }; + } + return out; +} + +/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) { + if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; + const g = md.shooterSpawnSlots; + if (!g) return; + const w = md.width || 20, h = md.height || 15; + for (let slot = 1; slot <= 6; slot++) { + const idx = slot - 1; + if (slots6[idx]) continue; + let found = false; + for (let yy = 0; yy < h && !found; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] === slot) { + slots6[idx] = { x: xx, y: yy }; + found = true; + break; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */ +/** เลือก host คนถัดไปเมื่อ host ปัจจุบันหลุด — คนที่อยู่ในห้องนานสุด (spawnJoinOrder ต่ำสุด) + เสถียรข้ามการ reconnect (spawnJoinOrder ผูกกับ playerKey) — แทน Map-first ที่สลับได้ */ +function pickNextHostPeer(space) { + if (!space || !space.peers || space.peers.size === 0) return null; + let best = null; + let bestOrd = Infinity; + space.peers.forEach((p, id) => { + if (!p) return; + const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999; + if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) { + bestOrd = ord; + best = p; + } + }); + return best; +} + +function resolveSpawnJoinOrder(space, playerKey) { + if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {}; + const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const used = new Set(); + space.peers.forEach((p) => { + if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder); + }); + /* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */ + if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) { + const prev = space.spawnOrderByKey[key]; + if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev; + } + let ord = 0; + while (used.has(ord)) ord++; + if (key) space.spawnOrderByKey[key] = ord; + return ord; +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน < 4) */ +function openSuspectPhaseForRoom(sid, space, disruptorAccepted) { + if (!space) return; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + const phaseBase = { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: !!disruptorAccepted, + cardMinigames: space.suspectCardMinigames || [], + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('suspect-phase-open', { + ...phaseBase, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + /* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */ + const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer); + if (peerIds.length < 4 && !forceOffer) { + /* ไม่เสนอบทตัวป่วน (คน < 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */ + space.troublesomeOfferSent = true; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + openSuspectPhaseForRoom(sid, space, false); + return; + } + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 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) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + /* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */ + const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const prevHostId = space.hostId; + if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) { + space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */ + } else if (!space.hostId) { + space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */ + if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey; + } + /* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers + จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost) + แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */ + if (joinPlayerKey) { + const staleIds = []; + space.peers.forEach((p, pid) => { + if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid); + }); + staleIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + /* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */ + if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) { + delete space.graceReconnect[joinPlayerKey]; + if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; } + io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' }); + } + /* MG1 takeover: ผู้เล่นกลับมากลาง quiz (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (sess.bots) คืนสิทธิ์เล่น + กัน "ตัวซ้ำ" (บอท takeover + ตัวที่ rejoin) */ + const sessJ = space.quizSession; + if (sessJ && sessJ.bots) { + const dropBotIds = []; + sessJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBotIds.push(bid); }); + dropBotIds.forEach((bid) => { + sessJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); /* ลบ avatar บอท takeover เก่า — ตัวจริงจะมาใหม่ผ่าน user-joined */ + }); + } + /* MG2 takeover เช่นกัน: ผู้เล่นกลับมากลาง gauntlet (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (gauntletRun.bots) */ + const grJ = space.gauntletRun; + if (grJ && grJ.bots) { + const dropG = []; + grJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropG.push(bid); }); + dropG.forEach((bid) => { + grJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG3 takeover เช่นกัน: กลับมากลาง stack (playerKey เดิม) → ถอนบอท + คืน seat ให้คนจริง (inherit คะแนนกลับ) */ + const s3J = space.stackSession; + if (s3J && s3J.bots) { + let restored = false; + const dropS = []; + s3J.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropS.push(bid); }); + dropS.forEach((bid) => { + const inherited = Math.max(0, ((s3J.bots.get(bid) || {}).score) | 0); + s3J.bots.delete(bid); + const entry = (s3J.turnOrder || []).find((e) => e.kind === 'bot' && String(e.botId) === String(bid)); + if (entry) { entry.kind = 'human'; entry.id = socket.id; entry.botId = null; s3J.humanPts[socket.id] = inherited; restored = true; } + io.to(spaceId).emit('user-left', { id: bid }); + }); + if (restored) emitStackStateServer(spaceId, space); + } + /* MG4 takeover เช่นกัน: กลับมากลาง quiz_carry (playerKey เดิม) → ถอนบอท + คืน slot ให้คนจริง (inherit ป้าย/คะแนนกลับ) */ + const sCJ = space.quizCarrySession; + if (sCJ && sCJ.bots) { + const dropC = []; + sCJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropC.push(bid); }); + dropC.forEach((bid) => { + const bb = sCJ.bots.get(bid) || {}; + sCJ.bots.delete(bid); + sCJ.players[socket.id] = { held: (bb.held == null ? null : bb.held), score: Math.max(0, Number(bb.score) || 0) }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG5 takeover เช่นกัน: กลับมากลาง jump_survive (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit สถานะตาย) */ + const sJJ = space.jumpSurviveSession; + if (sJJ && sJJ.bots) { + const dropJ = []; + sJJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropJ.push(bid); }); + dropJ.forEach((bid) => { + const bb = sJJ.bots.get(bid) || {}; + sJJ.bots.delete(bid); + sJJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG6 takeover: กลับมากลาง space_shooter (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit คะแนน/ตาย) */ + const sSJ = space.spaceShooterSession; + if (sSJ && sSJ.bots) { + const dropSS = []; + sSJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropSS.push(bid); }); + dropSS.forEach((bid) => { + const bb = sSJ.bots.get(bid) || {}; + sSJ.bots.delete(bid); + sSJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG7 takeover: กลับมากลาง balloon_boss (playerKey เดิม) → ถอนบอท + คืน slot คนจริง */ + const sBJ = space.balloonBossSession; + if (sBJ && sBJ.bots) { + const dropBB = []; + sBJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBB.push(bid); }); + dropBB.forEach((bid) => { sBJ.bots.delete(bid); io.to(spaceId).emit('user-left', { id: bid }); }); + } + } + /* reconnect dedup ชั้น 2: ghost ที่ playerKey block จับไม่ได้ (peer เก่าไม่มี playerKey) + ลบด้วย "ชื่อ custom เดียวกัน" — แต่ **เฉพาะ peer เก่าที่ไม่มี valid playerKey** เท่านั้น + ⚠️ ห้ามลบ peer ที่มี playerKey คนละค่า: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละคน ต้องเก็บทั้งคู่ + (เดิมลบทุกตัวที่ชื่อตรง → client ที่ชื่อซ้ำ host ถูกลบ = "ไม่ได้เล่นเลย") */ + const validPkStr = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : ''; + const newNickNorm = normalizeLobbyNickname(nickname); + const newNickIsCustom = newNickNorm && newNickNorm !== 'ผู้เล่น' + && newNickNorm.indexOf('ผู้เล่น') !== 0 && newNickNorm.indexOf('บอท') !== 0; + if (newNickIsCustom) { + const ghostIds = []; + space.peers.forEach((p, pid) => { + if (pid === socket.id || !p) return; + const ppk = validPkStr(p); + if (ppk && ppk !== joinPlayerKey) return; /* คนละ playerKey = คนละคน (ชื่อซ้ำได้) → ไม่ลบ */ + if (normalizeLobbyNickname(p.nickname) === newNickNorm) ghostIds.push(pid); + }); + ghostIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + } + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + ensurePeerLobbyThemes(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey); + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = balloonBossBalloonsForMap(mdJoin); + /* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */ + let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10); + if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) { + /* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */ + let takenByHuman = false; + space.peers.forEach((p) => { + const ti = parseInt(p.lobbyColorThemeIndex, 10); + if (ti === chosenThemeIdx) takenByHuman = true; + }); + if (takenByHuman) { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + /* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */ + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0; + }); + } + } else { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } + let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10); + if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) { + chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder); + } + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + lobbyColorThemeIndex: chosenThemeIdx, + lobbySkinToneIndex: chosenSkinIdx, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '', + reportedMiniScore: 0, reportedMiniSurvived: true, + bannedSpectator: !!bannedSpectator, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* เก็บกวาด peer ซ้ำทั้งก้อน (ghost) — เผื่อ dedup ต่อ-join ด้านบนพลาด/race จนคนเดิมค้าง 2 ตัว */ + pruneDuplicateSpacePeers(spaceId, space); + glog(spaceId, space, 'join', 'สี#' + (peer.lobbyColorThemeIndex || '?') + ' · คน ' + space.peers.size + ' + บอท ' + effectiveBotSlotCount(space) + (joinPlayerKey ? '' : ' · NO-KEY'), nickname || 'ผู้เล่น'); + /* invariant: คน + บอท ≤ 6 — กัน "เงาสะท้อนของ user": คนหลุดแล้ว grace แปลงเป็นบอท (botSlotCount+1, สีตามคนออก) + พอคนนั้นกลับเข้ามา (reconnect หลัง grace) จะมีทั้งคนจริง + บอทสีเดียวกัน = ตัวซ้ำ. ตัดบอทส่วนเกินออกให้รวมไม่เกิน 6 + (มินิเกม cap ที่ 6-humans อยู่แล้ว ~rebalancePreviewBots แต่ LobbyB ไม่ได้ cap → ตัดที่ server ต้นทาง) */ + { + const humansNow = space.peers.size; + const botsNow = effectiveBotSlotCount(space); + if (humansNow + botsNow > LOBBY_SLOT_TOTAL) { + space.botSlotCount = Math.max(0, LOBBY_SLOT_TOTAL - humansNow); + } + /* re-sync สีบอททุกครั้งที่มีคน (re)join + แจ้ง "ทุกจอ" (รวมคนที่อยู่ในมินิเกม/LobbyB อยู่แล้ว): + เคส "เงาสะท้อนของ user" = คนหลุด→บอทใช้สีคนนั้น (pin)→คนนั้นกลับมา. join handler เคลียร์ pin ที่ชนสีคนแล้ว + (7704) + syncLobbyBotThemeSlots เลือกสีใหม่ที่ไม่ชนคน แต่เดิม emit เฉพาะตอน total>6 → จออื่นไม่อัปเดต = บอทยังสีเดิม. + emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */ + emitLobbyTintSync(spaceId, space); + } + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id); + /* นับช่อง spawn ให้พอกับ "คน + บอท" — เดิมส่งแค่ peers.size: ห้อง 1 คน = 1 ช่อง = pos[0] (เลนบนสุด) เสมอ + แล้วไปทับบอทที่ initGauntletPeersAll วางไว้ที่ pos[0] → คน(host ใหม่)+บอท วิ่งเลนเดียวกันเลนบนสุด (bug ที่เจอซ้ำ) */ + const grJoin = space.gauntletRun; + const botCntJoin = (grJoin && grJoin.bots) ? grJoin.bots.size : 0; + const pos = gauntletSpawnPositions(mdJoin, space.peers.size + botCntJoin); + /* เลี่ยงทับทั้ง "peer อื่น" และ "บอท" ที่อยู่ในเลนแล้ว — ดันไปช่องว่างถัดไป (เดิมเช็คแต่ peer ไม่เช็คบอท) */ + const occupied = new Set(); + space.peers.forEach((q) => { if (q && q.id !== socket.id) occupied.add(Math.floor(q.x) + ',' + Math.floor(q.y)); }); + if (grJoin && grJoin.bots) grJoin.bots.forEach((b) => { if (b) occupied.add(Math.floor(Number(b.x)) + ',' + Math.floor(Number(b.y))); }); + let pidx = Number.isFinite(ord) ? Math.max(0, Math.min(ord, pos.length - 1)) : pos.length - 1; + let pguard = 0; + while (pos[pidx] && occupied.has(Math.floor(pos[pidx].x) + ',' + Math.floor(pos[pidx].y)) && pguard < pos.length) { pidx = (pidx + 1) % pos.length; pguard++; } + const pt = pos[pidx] != null ? pos[pidx] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + botSlotCount: effectiveBotSlotCount(space), + lobbyBotThemes: syncLobbyBotThemeSlots(space), + joinLocked: !!space.joinLocked, + missionLiveBeginsAt: space.missionLiveBeginsAt || null, + }; + if (serverMapIsPostCaseLobbyB(space)) { + joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null; + joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null; + } + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + /* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */ + const sqIcon = specialQuizIconPayload(space); + if (sqIcon) joinCb.specialQuizIcon = sqIcon; + if (typeof cb === 'function') cb(joinCb); + /* กันค้างตอน reconnect: event "จบมินิเกม" (balloon-boss-over/stack-over/space-shooter-over) + host ยิงครั้งเดียวผ่าน socket.to — ถ้า client หลุดตอนนั้นจะค้างถาวร (ไม่มี replay). + เก็บ over ล่าสุดไว้ → resend ให้ socket ที่เพิ่ง (re)join ภายใน 45s ถ้ายังอยู่ในแมปมินิเกม + (gameType !== 'lobby' = ยังไม่ทรานซิชันกลับ). delay เล็กน้อยให้ client applyMapAndStart เสร็จก่อน */ + const lmo = space.lastMinigameOver; + if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') { + const targetSid = socket.id; + setTimeout(() => { + try { + const sp = spaces.get(spaceId); + if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) { + io.to(targetSid).emit(lmo.event, lmo.payload); + } + } catch (_e) { /* ignore */ } + }, 400); + } + if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */ + emitLobbyTintSync(spaceId, space); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + /* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที + (คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */ + if (serverMapIsPostCaseLobbyB(space)) { + /* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */ + emitDetectiveLobbyBPresence(spaceId, space); + console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id); + if (space.pendingBanVoteCard && !space.pickVote) { + const banCard = space.pendingBanVoteCard; + space.pendingBanVoteCard = null; + /* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */ + setTimeout(() => { + const sp = spaces.get(spaceId); + if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) { + console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp))); + return; + } + console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')'); + io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' }); + startPlayerPickVote(spaceId, sp, 'ban', (targetId) => { + const sp2 = spaces.get(spaceId); + console.log('[ban-debug] ban vote RESOLVED target=', targetId); + if (sp2) sp2.bannedPlayerId = targetId || null; + }); + }, 2000); + } else if (space.pickVote && !space.pickVote.resolved) { + const pv = space.pickVote; + socket.emit('player-pick-vote-open', { + purpose: pv.purpose, + candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: pv.endsAt, + durationMs: pickVoteMs(), + }); + } + } + }); + + /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ + /** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */ + socket.on('special-quiz-force', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + space.forceSpecialQuiz = !!(data && data.on); + console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id); + }); + + /** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ + socket.on('special-quiz-testmode', (data) => { + socket.data.testMode = !!(data && data.on); + }); + + /** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */ + socket.on('test-force-wrong-vote', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return; + space.testForceWrongVote = true; + console.log('[test] force-wrong-vote armed by', socket.id); + }); + + /** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */ + socket.on('special-quiz-arm', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return; + sq.armScheduled = true; + scheduleSpecialQuizIconAppearAndExpiry(sid, space); + }); + + socket.on('special-quiz-collide', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return reply({ ok: false }); + const started = startSpecialQuizSession(sid, space); + reply({ ok: !!started }); + }); + + /** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */ + socket.on('special-quiz-answer', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return reply({ ok: false }); + const q = sess.questions[sess.qIndex]; + if (!q) return reply({ ok: false }); + /* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */ + let choice; + if (data && data.debugCorrect && socket.data.testMode) { + choice = q.correctIndex; + } else { + choice = Number(data && data.choiceIndex); + } + if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) { + return reply({ ok: false }); + } + if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' }); + sess.answers[socket.id] = choice; + reply({ ok: true }); + /* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */ + io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true }); + /* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ + maybeResolveSpecialQuizAllAnswered(sid, space); + }); + + /** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */ + socket.on('special-quiz-continue', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + /* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */ + if (!space.specialQuizAwaitContinue) return reply({ ok: true }); + /* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */ + if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set(); + space.specialQuizContinueAcks.add(socket.id); + reply({ ok: true }); + maybeFinishSpecialQuizContinue(sid, space); + }); + + /** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */ + socket.on('lobby-tint-update', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + const peer = space && space.peers.get(socket.id); + if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + let themeIndex = Math.floor(Number(data && data.themeIndex)); + let skinIndex = Math.floor(Number(data && data.skinToneIndex)); + if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) { + return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' }); + } + if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) { + skinIndex = peer.lobbySkinToneIndex || 1; + } + /* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join) + กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */ + let humanTaken = false; + space.peers.forEach((op, oid) => { + if (oid === socket.id) return; + if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true; + }); + if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); + if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */ + }); + } + peer.lobbyColorThemeIndex = themeIndex; + peer.lobbySkinToneIndex = skinIndex; + emitLobbyTintSync(sid, space); + reply({ + ok: true, + lobbyColorThemeIndex: themeIndex, + lobbySkinToneIndex: skinIndex, + lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], + }); + }); + + /* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */ + /* DEPRECATED: server เป็นเจ้าของบอท lobby เองแล้ว (stepLobbyBotsForSpace/lobbyBotSweepTick) + → ไม่ relay ตำแหน่งบอทจาก client อีก (กัน host เวอร์ชันเก่าฉีดตำแหน่งชนกับ server tick = บอทกระตุก/2 แหล่ง) */ + socket.on('lobby-bot-move', () => { /* no-op: server-authoritative */ }); + + /* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */ + socket.on('fill-bot-state', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ + if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; + /* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host" + แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */ + const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined); + socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans }); + }); + + /* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host) + payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */ + socket.on('balloon-boss-shot', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */ + const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); + if (!ok) return; + socket.to(sid).emit('balloon-boss-shot', { + sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy, + ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), + }); + }); + + /* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */ + socket.on('balloon-boss-over', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const reason = (data.reason === 'victory' || data.reason === 'all_dead' || data.reason === 'time') ? data.reason : 'all_dead'; + const scores = Array.isArray(data.scores) + ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated })) + : []; + const overPayload = { reason, scores }; + socket.to(sid).emit('balloon-boss-over', overPayload); + /* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */ + space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() }; + }); + + /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ + → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ + socket.on('balloon-boss-sync', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const bossDmg = Math.max(0, Number(data.bossDmg) || 0); + const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */ + const players = Array.isArray(data.players) + ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ + id: p.id, + d: Math.max(0, Number(p.d) || 0), + b: Math.max(0, Number(p.b) || 0), + e: !!p.e, + })) + : []; + socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor }); + }); + + /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + socket.on('stack-drop', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: client ไม่เป็นเจ้าของดรอป */ + const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; + const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; }; + socket.to(sid).emit('stack-drop', { + placedCx: num(data.placedCx), placedW: num(data.placedW), + miss: !!data.miss, perfect: !!data.perfect, + pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), + overlap: num(data.overlap), + supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), + delta: num(data.delta), + seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, + theme: themeOf(data.theme), /* สี block ที่วาง (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client freeze สีตัวเอง = เพี้ยน */ + actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, + }); + }); + /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ + socket.on('quiz-end-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ + if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'quiz') return; + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); + }); + + /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ + socket.on('stack-swing', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: server แกว่งเอง */ + const ph = Number(data.phase); + if (!Number.isFinite(ph)) return; + socket.to(sid).emit('stack-swing', { phase: ph }); + }); + + /* MG3 stack (server-auth) — รับ "เจตนากดวาง" จากผู้เล่นจริง → server คำนวณ ณ เฟสปัจจุบัน + emit ผลให้ทุกเครื่อง */ + socket.on('stack-drop-req', (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); + /* [DBG-MG3drop] เทียบ client-sent phase vs s.phase + จุดลง/รองรับ/overlap/miss/หัวใจ (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'HUMAN cph=' + (Number.isFinite(cph) ? cph.toFixed(3) : 'x') + + ' sPhase=' + s.phase.toFixed(3) + ' used=' + dropPhase.toFixed(3) + (dropPhase === cph ? '(client)' : '(server)') + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + ' perfect=' + hit.perfect + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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; + emitTrialVoteUpdate(sid, space); + const totalTV = trialEligibleVoterTotal(space); + if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + + /* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */ + socket.on('player-pick-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const pv = space.pickVote; + if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' }); + const tid = String((data && data.targetId) || ''); + if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' }); + pv.votes[socket.id] = tid; + reply({ ok: true, targetId: tid }); + emitPickVoteUpdate(sid, space); + maybeResolvePickVote(sid, space); + }); + + /** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */ + socket.on('host-console-apply', (data, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const humans = space.peers.size; + let total = parseInt(data && data.totalSlots, 10); + if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space); + total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total)); + let bots = parseInt(data && data.botSlotCount, 10); + if (!Number.isFinite(bots)) bots = total - humans; + bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots)); + total = humans + bots; + space.botSlotCount = bots; + space.maxPlayers = Math.max(humans, total - bots); + const lobbyBotThemes = syncLobbyBotThemeSlots(space); + io.to(sid).emit('host-console-settings', { + maxPlayers: space.maxPlayers, + botSlotCount: space.botSlotCount, + totalSlots: total, + lobbyBotThemes, + }); + emitLobbyTintSync(sid, space); + cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes }); + }); + + socket.on('host-console-kick-peer', ({ targetId }, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const tid = targetId != null ? String(targetId) : ''; + if (!tid || tid === socket.id || tid === space.hostId) { + return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' }); + } + if (!space.peers.has(tid)) { + return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' }); + } + const targetSock = io.sockets.sockets.get(tid); + if (targetSock) { + targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' }); + targetSock.leave(sid); + delete targetSock.data.spaceId; + } + space.peers.delete(tid); + io.to(sid).emit('user-left', { id: tid }); + cb && cb({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + /* ล็อก: 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 nick = p && p.nickname ? normalizeLobbyNickname(p.nickname) : ''; + if (!nick) break; + if (!space.storyReadNicks) space.storyReadNicks = new Set(); + const already = space.storyReadNicks.has(nick); + space.storyReadNicks.add(nick); + 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); + 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(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + /* TC156: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story (cutscene) ครบ — reset ที่ initial LobbyA→LobbyB เท่านั้น + (รอบ investigate ถัดไปไม่มี cutscene → storyReadComplete คงเป็น true) · safety timer กัน deadlock ถ้า client ไม่ emit */ + space.storyReadNicks = new Set(); + space.storyReadComplete = false; + 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 */ } + 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-storygate b/www/html/Game/server.js.bak-storygate new file mode 100644 index 0000000..6c36dcd --- /dev/null +++ b/www/html/Game/server.js.bak-storygate @@ -0,0 +1,12787 @@ +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'); + +/* ===== Quiz Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ +const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json'); +/** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */ +let qbLeaderboardStore = {}; +function loadQbLeaderboard() { + try { + if (fs.existsSync(QB_LEADERBOARD_PATH)) { + const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8')); + qbLeaderboardStore = (j && typeof j === 'object') ? j : {}; + } + } catch (e) { qbLeaderboardStore = {}; } +} +function saveQbLeaderboard() { + try { + const dir = path.dirname(QB_LEADERBOARD_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8'); + } catch (e) { /* ignore */ } +} +function qbLeaderboardSubmit(roomId, name, score, characterId) { + roomId = String(roomId || '').trim(); + name = String(name || '').trim().slice(0, 24); + score = Math.max(0, Math.floor(Number(score) || 0)); + if (!roomId || !name) return; + if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = []; + const list = qbLeaderboardStore[roomId]; + const ex = list.find((e) => e && e.name === name); + if (ex) { + if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); } + } else { + list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() }); + } + list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts)); + if (list.length > 500) list.length = 500; + saveQbLeaderboard(); +} +function qbLeaderboardGet(roomId, name) { + roomId = String(roomId || '').trim(); + const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : []; + const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' })); + let me = null; + const nm = String(name || '').trim(); + if (nm) { + const idx = list.findIndex((e) => e && e.name === nm); + if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' }; + } + return { top, me, total: list.length }; +} +loadQbLeaderboard(); + +/* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ +const os = require('os'); +let __prevCpuSample = null; +function __cpuSample() { + const cpus = os.cpus() || []; + let idle = 0, total = 0; + for (const c of cpus) { + for (const k in c.times) total += c.times[k]; + idle += c.times.idle; + } + return { idle, total }; +} +/** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ +function __cpuPercent() { + const cur = __cpuSample(); + const prev = __prevCpuSample; + __prevCpuSample = cur; + if (!prev) return null; + const idleD = cur.idle - prev.idle; + const totalD = cur.total - prev.total; + if (totalD <= 0) return null; + return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); +} +__cpuSample(); // เก็บ baseline แรก +function getDiskStats() { + try { + if (typeof fs.statfsSync !== 'function') return null; + const s = fs.statfsSync(__dirname); + const total = s.blocks * s.bsize; + const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด + const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ + const used = total - free; + if (!(total > 0)) return null; + return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; + } catch (e) { return null; } +} +/* จำนวนผู้เล่น/ห้อง/socket ออนไลน์ตอนนี้ — โชว์ในหน้า Admin เทียบกับเพดานความจุ */ +function getOnlineStats() { + let players = 0, rooms = 0; + try { + for (const [, sp] of spaces) { + if (sp && sp.peers) { players += sp.peers.size; rooms++; } + } + } catch (e) { /* ignore */ } + let sockets = 0; + try { sockets = (io && io.engine && io.engine.clientsCount) || 0; } catch (e) { /* ignore */ } + return { sockets, players, rooms }; +} + +function getSystemStats() { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const load = os.loadavg(); + const cores = (os.cpus() || []).length || 1; + const cpuPct = __cpuPercent(); + const mu = process.memoryUsage(); + return { + ok: true, + cpu: { + percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) + cores, + load1: Math.round(load[0] * 100) / 100, + load5: Math.round(load[1] * 100) / 100, + load15: Math.round(load[2] * 100) / 100, + loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), + }, + mem: { + total: totalMem, + free: freeMem, + used: usedMem, + usedPercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDiskStats(), + node: { + rss: mu.rss, + heapUsed: mu.heapUsed, + rssPercent: Math.round((mu.rss / totalMem) * 100), + version: process.version, + uptime: Math.round(process.uptime()), + }, + uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, + /* ผู้ใช้ออนไลน์ + เพดานความจุ (ประเมินจาก 2 vCPU / socket fan-out): สบาย ≤300 · เริ่มหนัก ≥500 */ + online: getOnlineStats(), + capacity: { comfortable: 300, strain: 500 }, + hostname: os.hostname(), + platform: os.platform(), + ts: Date.now(), + }; +} + +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */ +const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5]; +const SPECIAL_QUIZ_CASES = [1, 2, 3]; +const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary']; +const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; + +function specialCardTxtFilename(id) { + return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`; +} + +function specialCardImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`; +} + +function specialCardTxtImageUrl(id) { + return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`; +} + +function specialQuizSetKey(level, caseId) { + return 'L' + level + '_C' + caseId; +} + +/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */ +function sanitizeSpecialQuizQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + const type = q.type === 'mcq' ? 'mcq' : 'tf'; + if (type === 'mcq') { + /* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */ + const choices = (Array.isArray(q.choices) ? q.choices : []) + .map((c) => String(c == null ? '' : c).trim()) + .slice(0, 8); + while (choices.length && choices[choices.length - 1] === '') choices.pop(); + if (choices.filter((c) => c).length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') { + ci = choices.findIndex((c) => c); + if (ci < 0) ci = 0; + } + return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci }; + } + return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue }; +} + +function sanitizeSpecialCard(c) { + const src = (c && typeof c === 'object') ? c : {}; + const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare'; + let cardId = Number(src.cardId); + if (!(cardId >= 1 && cardId <= 7)) cardId = null; + let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600); + let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600); + if (cardId) { + if (!imageUrl) imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } else if (imageUrl) { + const m = imageUrl.match(/\/card-(\d)\.png/i); + if (m) { + cardId = Number(m[1]); + imageUrl = specialCardImageUrl(cardId); + if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); + } + } + return { + cardId: cardId || null, + th: String(src.th == null ? '' : src.th).trim().slice(0, 160), + en: String(src.en == null ? '' : src.en).trim().slice(0, 160), + rarity, + imageUrl, + txtImageUrl, + }; +} + +/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */ +function sanitizeSpecialQuizSets(obj) { + const out = {}; + SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => { + const key = specialQuizSetKey(L, C); + const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {}; + const questions = (Array.isArray(src.questions) ? src.questions : []) + .map(sanitizeSpecialQuizQuestion) + .filter(Boolean) + .slice(0, 50); + out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) }; + })); + return out; +} + +/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */ +function getSpecialQuizSet(settings, level, caseId) { + const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({}); + /* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่) + key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */ + const cid = Math.max(1, Math.min(15, Number(caseId) || 1)); + const L = Math.ceil(cid / 3); + const C = ((cid - 1) % 3) + 1; + return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; +} + +/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ +/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ +/** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */ +const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7']; +const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; +/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ +/** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */ +const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; +/** หลุดกลางมินิเกม → ให้เวลา reconnect (วิ) ก่อน bot เข้าเล่นแทน (roster คดี/LobbyB) + ⚠️ ห้ามตั้ง 0: เปลี่ยนหน้า lobbyB→play→lobbyB (socket หลุดชั่วคราว) จะถูกถอดจาก roster → rejoin ไม่ได้ ("ห้องนี้เริ่มคดีแล้ว") + หมายเหตุ: bot takeover "ในเกม MG1" (sess.bots) เป็นทันทีอยู่แล้ว แยกจาก grace นี้ */ +const DISCONNECT_GRACE_SEC = 10; + +/** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */ +function normalizeTestSpecialCardByMap(v) { + const out = {}; + if (v && typeof v === 'object') { + SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => { + const c = Math.floor(Number(v[mid])); + if (c >= 1 && c <= 7) out[mid] = c; + }); + } + return out; +} +/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */ +const SPECIAL_QUIZ_ANSWER_MS = 20000; +/** หน่วงโชว์เฉลยก่อนข้อต่อไป */ +const SPECIAL_QUIZ_REVEAL_MS = 2600; + +/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */ +function specialQuizNormalizeQuestion(q) { + if (!q || typeof q !== 'object') return null; + const text = String(q.text == null ? '' : q.text).trim(); + if (!text) return null; + if (q.type === 'mcq') { + const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== ''); + if (choices.length < 2) return null; + let ci = Number(q.correctIndex); + if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0; + return { text, choices, correctIndex: ci }; + } + return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 }; +} + +function specialQuizShuffle(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function specialQuizTileWalkable(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const obj = md.objects && md.objects[y]; + if (obj && obj[x] === 1) return false; + /* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */ + const bp = md.blockPlayer && md.blockPlayer[y]; + if (bp && bp[x] === 1) return false; + return true; +} + +/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */ +function specialQuizTileInGameZone(md, x, y) { + if (quizCellOn(md.quizTrueArea || [], x, y)) return true; + if (quizCellOn(md.quizFalseArea || [], x, y)) return true; + if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true; + const oa = md.quizCarryOptionArea; + if (oa && oa[y] && oa[y][x]) return true; + return false; +} + +/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */ +function pickSpecialQuizSpawnTile(md, occupiedLaneCount) { + const w = md.width || 20; + const h = md.height || 15; + /* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer → + ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้ + → เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */ + if (md.gameType === 'quiz_carry') { + const nearMarkedTable = (x, y) => { + for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) { + if (!Array.isArray(g)) continue; + for (let dy = -2; dy <= 2; dy++) { + const gy = g[y + dy]; + if (!gy) continue; + for (let dx = -2; dx <= 2; dx++) { + if (gy[x + dx]) return true; + } + } + } + return false; + }; + const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length) + ? md.lobbyPlayerSpawns + : [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }]; + const qcCands = []; + seeds.forEach((s) => { + const bx = Math.floor(Number(s.x)); + const by = Math.floor(Number(s.y)); + for (let dy = -5; dy <= 5; dy++) { + for (let dx = -5; dx <= 5; dx++) { + const dist = Math.abs(dx) + Math.abs(dy); + if (dist < 2 || dist > 6) continue; + const xx = bx + dx; + const yy = by + dy; + if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) { + qcCands.push({ x: xx, y: yy }); + } + } + } + }); + if (qcCands.length) { + const p = qcCands[Math.floor(Math.random() * qcCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns + (ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */ + if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) { + /* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */ + const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0) + ? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount)) + : md.gauntletPlayerSpawns.length; + const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes); + const rows = Array.from(new Set(usableSpawns + .map((s) => Math.floor(Number(s.y))) + .filter((n) => Number.isFinite(n) && n >= 0 && n < h))); + const laneCands = []; + const xMin = Math.max(4, Math.floor(w * 0.30)); + const xMax = Math.min(w - 2, Math.floor(w * 0.72)); + rows.forEach((ry) => { + for (let x = xMin; x <= xMax; x++) { + if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry }); + } + }); + if (laneCands.length) { + const p = laneCands[Math.floor(Math.random() * laneCands.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง + (ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */ + if (md.gameType === 'stack') { + const zc = []; + [md.stackReleaseArea, md.stackLandArea].forEach((g) => { + if (Array.isArray(g)) { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y }); + } + }); + if (zc.length) { + let cx2 = 0; + let cy2 = 0; + zc.forEach((c) => { cx2 += c.x; cy2 += c.y; }); + cx2 = Math.round(cx2 / zc.length); + cy2 = Math.round(cy2 / zc.length); + for (let rad = 0; rad <= 6; rad++) { + for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) { + for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 }; + } + } + } + /* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */ + return { x: cx2 + 0.5, y: cy2 + 0.5 }; + } + } + /* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack + (สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */ + if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) { + const painted = []; + const paintedWalkable = []; + for (let yy = 0; yy < h; yy++) { + const row = md.specialQuizSpawnArea[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (!row[xx]) continue; + painted.push({ x: xx, y: yy }); + if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy }); + } + } + const pickFrom = paintedWalkable.length ? paintedWalkable : painted; + if (pickFrom.length) { + const p = pickFrom[Math.floor(Math.random() * pickFrom.length)]; + return { x: p.x + 0.5, y: p.y + 0.5 }; + } + } + /* jump_survive (กระโดด mnptfts2): ผู้เล่นยืนบน "แพลตฟอร์ม" — พื้นที่ว่างนับ walkable ผิด (ลอยกลางอากาศเก็บไม่ได้) + ไอคอนคลิก/แตะเก็บได้ทุกมินิเกม (trySpecialQuizClickAt) → วางบนแพลตฟอร์มใกล้จุดเกิด (อยู่ในจอตั้งแต่ต้น คลิกได้) + = แก้ #44 อีเวนต์พิเศษ (ทนาย/เทพ) ไม่ขึ้นเพราะ BFS หา walkable ไม่เจอ → return null */ + if (md.gameType === 'jump_survive' && Array.isArray(md.jumpSurvivePlatformArea) && md.jumpSurvivePlatformArea.length) { + const sp = md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }; + const bx = Math.floor(Number(sp.x)); + const by = Math.floor(Number(sp.y)); + const plats = []; + for (let y = 0; y < h; y++) { + const row = md.jumpSurvivePlatformArea[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x]) plats.push({ x, y }); + } + } + if (plats.length) { + plats.sort((a, b) => (Math.abs(a.x - bx) + Math.abs(a.y - by)) - (Math.abs(b.x - bx) + Math.abs(b.y - by))); + const near = plats.slice(0, Math.min(8, plats.length)); + const p = near[Math.floor(Math.random() * near.length)]; + const iy = (p.y - 1 >= 0) ? p.y - 1 : p.y; /* เหนือแพลตฟอร์ม 1 ช่อง = ระดับตัวผู้เล่น (ไม่จมในแพลตฟอร์ม) */ + return { x: p.x + 0.5, y: iy + 0.5 }; + } + } + /* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้ + fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */ + let sx = -1; + let sy = -1; + const spawnGrid = md.spawnArea; + if (Array.isArray(spawnGrid)) { + for (let y = 0; y < h && sx < 0; y++) { + const row = spawnGrid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) { + const sp = md.spawn || { x: 1, y: 1 }; + const fx = Math.floor(Number(sp.x)); + const fy = Math.floor(Number(sp.y)); + if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; } + } + if (sx < 0) { + /* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก + เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */ + const ccx = Math.floor(w / 2); + const ccy = Math.floor(h / 2); + const maxRad = Math.max(w, h); + for (let rad = 0; rad <= maxRad && sx < 0; rad++) { + for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) { + for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) { + if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; } + } + } + } + } + if (sx < 0) { + for (let y = 0; y < h && sx < 0; y++) { + for (let x = 0; x < w; x++) { + if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } + } + } + } + if (sx < 0) return null; + const q = [[sx, sy]]; + const seen = new Set([sx + ',' + sy]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + const cands = []; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = nx + ',' + ny; + if (seen.has(k)) continue; + if (!specialQuizTileWalkable(md, nx, ny)) continue; + seen.add(k); + q.push([nx, ny]); + if (!specialQuizTileInGameZone(md, nx, ny)) { + const dist = Math.abs(nx - sx) + Math.abs(ny - sy); + if (dist >= 2) cands.push({ x: nx, y: ny }); + } + } + } + if (!cands.length) return null; + /* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ · + ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */ + const near = cands.filter((c) => { + const d = Math.abs(c.x - sx) + Math.abs(c.y - sy); + return d >= 2 && d <= 8; + }); + const pool = near.length ? near : cands; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x + 0.5, y: pick.y + 0.5 }; +} + +function clearSpecialQuizTimers(space) { + if (Array.isArray(space.specialQuizTimers)) { + space.specialQuizTimers.forEach((t) => clearTimeout(t)); + } + space.specialQuizTimers = []; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + space.specialQuizAwaitContinue = false; +} + +/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */ +function specialQuizIconPayload(space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done || !sq.appeared) return null; + return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 }; +} + +/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง (1 ใบต่อครั้งที่ติด — ดีไซน์ "1 ใน 3") + Test Mode (testSpecialCardByMap / forceSpecialQuiz) → ข้าม roll ขึ้นเสมอ */ +function maybeSpawnSpecialQuizForRun(space) { + clearSpecialQuizTimers(space); + space.specialQuiz = null; + space.specialCardAwardedThisRun = null; + space.timeDilationPendingSec = 0; + /* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้ + หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */ + if (!space || !space.detectiveMinigameActive) return; + const mapId = String(space.mapId || '').trim(); + if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return; + const level = Number(space.detectiveLobbyLevel) || 1; + const caseId = Number(space.detectiveLobbyCaseId) || 1; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, level, caseId); + const questions = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + let card = set && set.specialCard; + /* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */ + const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId]; + const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7); + if (testForcedCard) { + const def = specialCardDef(testCardId) || {}; + card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th }); + } + const hasCard = !!(card && (card.cardId || card.imageUrl)); + if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; } + const forced = !!space.forceSpecialQuiz || testForcedCard; + const roll = Math.random(); + if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; } + if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; } + /* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */ + const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); + const tile = pickSpecialQuizSpawnTile(md, occupiedLanes); + if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; } + const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)]; + console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y); + space.specialQuiz = { + iconType, + x: tile.x, + y: tile.y, + level, + caseId, + card, + triggered: false, + done: false, + session: null, + }; +} + +function emitSpecialQuizIcon(sid, space) { + const payload = specialQuizIconPayload(space); + if (payload) io.to(sid).emit('special-quiz-icon', payload); +} + +/** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */ +const SPECIAL_QUIZ_ICON_DELAY_MS = 2000; +function scheduleSpecialQuizIconAppearAndExpiry(sid, space) { + const sq = space && space.specialQuiz; + if (!sq || sq.triggered || sq.done) return; + const tAppear = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return; + const cur = sp.specialQuiz; + const sec = readSpecialQuizIconExpireSec(); + cur.appeared = true; + cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0; + emitSpecialQuizIcon(sid, sp); + if (sec > 0) { + const tExpire = setTimeout(() => { + const sp2 = spaces.get(sid); + if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return; + sp2.specialQuiz = null; + io.to(sid).emit('special-quiz-icon-clear', {}); + console.log('[special-quiz] icon EXPIRED after', sec + 's'); + }, sec * 1000); + if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = []; + sp.specialQuizTimers.push(tExpire); + } + }, SPECIAL_QUIZ_ICON_DELAY_MS); + if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = []; + space.specialQuizTimers.push(tAppear); +} + +/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */ +function buildSpecialQuizRoster(space) { + const roster = []; + space.peers.forEach((p, id) => { + roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }); + }); + const botN = effectiveBotSlotCount(space); + for (let i = 0; i < botN; i++) { + roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true }); + } + return roster; +} + +function specialQuizRosterPayload(sess) { + return (sess && Array.isArray(sess.roster) ? sess.roster : []) + .map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot })); +} + +/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */ +function specialQuizExpectedIds(space, sess) { + const realAlive = new Set(space.peers.keys()); + const out = []; + (sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => { + if (m.isBot) out.push(m.id); + else if (realAlive.has(m.id)) out.push(m.id); + }); + return out; +} + +/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ +function maybeResolveSpecialQuizAllAnswered(sid, space) { + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return; + const expected = specialQuizExpectedIds(space, sess); + const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid])); + if (allAnswered) resolveSpecialQuizQuestion(sid, space, false); +} + +/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */ +function scheduleSpecialQuizBotAnswers(sid, space, sess, q) { + const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot); + if (!bots.length) return; + const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500); + bots.forEach((b) => { + const delay = 900 + Math.floor(Math.random() * windowMs); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session; + if (!spNow || sNow !== sess || sess.resolving) return; + if (Number.isInteger(sess.answers[b.id])) return; + sess.answers[b.id] = q.correctIndex; + io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true }); + maybeResolveSpecialQuizAllAnswered(sid, spNow); + }, delay); + space.specialQuizTimers.push(t); + }); +} + +function sendSpecialQuizQuestion(sid, space) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess) return; + const q = sess.questions[sess.qIndex]; + if (!q) { endSpecialQuizSession(sid, space, false); return; } + sess.answers = {}; + sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS; + io.to(sid).emit('special-quiz-question', { + index: sess.qIndex, + total: sess.questions.length, + text: q.text, + choices: q.choices, + endsAt: sess.phaseEndsAt, + iconType: sq.iconType, + roster: specialQuizRosterPayload(sess), + }); + scheduleSpecialQuizBotAnswers(sid, space, sess, q); + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + resolveSpecialQuizQuestion(sid, spNow, true); + }, SPECIAL_QUIZ_ANSWER_MS + 80); + space.specialQuizTimers.push(t); +} + +/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */ +function resolveSpecialQuizQuestion(sid, space, fromTimeout) { + const sq = space.specialQuiz; + const sess = sq && sq.session; + if (!sess || sess.resolving) return; + sess.resolving = true; + clearSpecialQuizTimers(space); + const q = sess.questions[sess.qIndex]; + const expected = specialQuizExpectedIds(space, sess); + let allCorrect = expected.length > 0; + expected.forEach((pid) => { + const a = sess.answers[pid]; + if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false; + }); + io.to(sid).emit('special-quiz-result', { + index: sess.qIndex, + correctIndex: q.correctIndex, + answers: { ...sess.answers }, + roster: specialQuizRosterPayload(sess), + allCorrect, + timeout: !!fromTimeout, + }); + if (!allCorrect) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, false); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq) return; + endSpecialQuizSession(sid, spNow, true); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); + return; + } + const t = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; + sess.resolving = false; + sendSpecialQuizQuestion(sid, spNow); + }, SPECIAL_QUIZ_REVEAL_MS); + space.specialQuizTimers.push(t); +} + +/** + * นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน: + * minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation) + * now = ใช้ทันทีตอนได้รับ (Fund coins) + * after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence) + * pre_trial = ก่อนโหวตพิจารณาคดี (Silence) + * trial_vote = ระหว่างโหวตพิจารณาคดี (Extension) + * trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin) + * lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban) + */ +const SPECIAL_CARD_DEFS = { + 1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' }, + 2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' }, + 3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' }, + 4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' }, + 5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' }, + 6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' }, + 7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, +}; + +function specialCardDef(cardId) { + return SPECIAL_CARD_DEFS[Number(cardId)] || null; +} + +/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */ +function specialCardClientPayload(card) { + if (!card) return null; + const def = specialCardDef(card.cardId) || {}; + return { + cardId: card.cardId || null, + th: card.th || def.th || '', + en: card.en || '', + rarity: card.rarity || 'common', + imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''), + txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''), + effectKey: def.key || '', + effectWhen: def.when || '', + addSec: def.addSec || 0, + desc: def.th || '', + }; +} + +/** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */ +const TIME_DILATION_MAX_PENDING_SEC = 30; + +/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */ +function applySpecialCardImmediate(sid, space, card) { + const def = specialCardDef(card && card.cardId); + if (!def) return; + if (def.when === 'minigame' && def.key === 'time_dilation') { + /* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง + cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */ + space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (space.timeDilationPendingSec || 0) + (def.addSec || 10)); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + addSec: def.addSec || 10, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + if (def.when === 'now' && def.key === 'fund') { + /* +COINS — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม + noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */ + const coins = def.coins || 10; + const awards = [...space.peers.values()] + .filter((p) => p.playerKey) + .map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true })); + submitGameAwards(awards); + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(card), + coins: coins, + coinsServerAwarded: true, + }); + consumePendingSpecialCard(space, card.cardId); + return; + } + /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) + — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ +} + +/* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */ +function dbgCards(space) { + return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : ''))); +} +function pendingSpecialCardList(space) { + if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = []; + return space.pendingSpecialCards; +} +/** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */ +function addPendingSpecialCard(space, card) { + if (!card || !card.cardId) return null; + const list = pendingSpecialCardList(space); + const cid = Number(card.cardId); + if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; } + const entry = { cardId: cid, card: card, consumed: false }; + list.push(entry); + console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space)); + return entry; +} +/** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */ +function findPendingSpecialCard(space, cardId) { + const cid = Number(cardId); + return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null; +} +function consumePendingSpecialCard(space, cardId) { + const e = findPendingSpecialCard(space, cardId); + if (e) e.consumed = true; +} + +function endSpecialQuizSession(sid, space, success) { + const sq = space.specialQuiz; + if (!sq) return; + clearSpecialQuizTimers(space); + sq.done = true; + sq.session = null; + let cardOut = null; + console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId)); + if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { + space.specialCardAwardedThisRun = sq.card; + cardOut = specialCardClientPayload(sq.card); + addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */ + applySpecialCardImmediate(sid, space, sq.card); + } + io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); + /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) + — มี safety timeout กันค้างถ้าไม่มีใครกด */ + space.specialQuizAwaitContinue = true; + space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */ + /* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */ + io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size }); + if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = setTimeout(() => { + space.specialQuizContinueTimer = null; + finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */ + }, 32000); +} + +/** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */ +function maybeFinishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set()); + const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */ + const ready = expected.filter((pid) => acks.has(pid)).length; + io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length }); + if (expected.length === 0 || expected.every((pid) => acks.has(pid))) { + finishSpecialQuizContinue(sid, space); + } +} + +/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ +function finishSpecialQuizContinue(sid, space) { + if (!space || !space.specialQuizAwaitContinue) return; + space.specialQuizAwaitContinue = false; + space.specialQuizContinueAcks = null; + if (space.specialQuizContinueTimer) { + clearTimeout(space.specialQuizContinueTimer); + space.specialQuizContinueTimer = null; + } + /* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */ + io.to(sid).emit('special-quiz-resume-all', {}); + resumeQuizAfterSpecialQuiz(sid, space); +} + +/* เกมหยุดชั่วคราวระหว่างคำถามพิเศษ (กำลังตอบ หรือรอกด «เล่นต่อ») + ใช้ร่วมกันทุก minigame ฝั่ง server — เดิมมีแต่ gauntlet เช็ค ทำให้ MG6/MG7 บอทยังเดิน/ยิงตอน pause */ +function isSpecialQuizPausedServer(space) { + if (!space) return false; + return !!(space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)); +} + +function startSpecialQuizSession(sid, space) { + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return false; + const settings = loadQuizSettings(); + const set = getSpecialQuizSet(settings, sq.level, sq.caseId); + const all = (set && Array.isArray(set.questions) ? set.questions : []) + .map(specialQuizNormalizeQuestion) + .filter(Boolean); + if (!all.length) return false; + /* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */ + const picked = specialQuizShuffle(all).slice(0, 1); + sq.triggered = true; + sq.session = { + questions: picked, + qIndex: 0, + answers: {}, + phaseEndsAt: 0, + resolving: false, + roster: buildSpecialQuizRoster(space), + }; + clearSpecialQuizTimers(space); + pauseQuizForSpecialQuiz(sid, space); + io.to(sid).emit('special-quiz-icon-clear', {}); + sendSpecialQuizQuestion(sid, space); + return true; +} + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 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, + }; +} + +/** อ่านเวลา 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, + }; + } + } 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), + /* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */ + lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud') + ? !!d.lobbyDebugHud + : !!prev.lobbyDebugHud, + lobbyDebugHudNames: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHudNames') + ? (Array.isArray(d.lobbyDebugHudNames) ? d.lobbyDebugHudNames.slice(0, 30).map((s) => String(s).slice(0, 40)) : []) + : (Array.isArray(prev.lobbyDebugHudNames) ? prev.lobbyDebugHudNames : []), + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const specialQuizSets = d.specialQuizSets != null + ? sanitizeSpecialQuizSets(d.specialQuizSets) + : sanitizeSpecialQuizSets(prev.specialQuizSets); + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + specialQuizSets, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + specialQuizSets: out.specialQuizSets, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + let cw = Math.floor(Number(md.characterCellsW)); + let ch = Math.floor(Number(md.characterCellsH)); + const hasW = Number.isFinite(cw) && cw >= 1; + const hasH = Number.isFinite(ch) && ch >= 1; + if (hasW && hasH) { + return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; + } + const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); + return { cw: leg, ch: leg }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(md); + let colW = Math.floor(Number(md.characterCollisionW)); + let colH = Math.floor(Number(md.characterCollisionH)); + if (!Number.isFinite(colW) || colW < 1) colW = cw; + if (!Number.isFinite(colH) || colH < 1) colH = ch; + colW = Math.max(1, Math.min(cw, colW)); + colH = Math.max(1, Math.min(ch, colH)); + return { cw, ch, colW, colH }; +} + +const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; +const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; + +function quizBattleSpriteBoundsTileKeys(md, px, py) { + const out = []; + if (!md || md.gameType !== 'quiz_battle') return out; + const x = Number(px), y = Number(py); + if (!Number.isFinite(x) || !Number.isFinite(y)) return out; + const w = md.width || 20, h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const cx = x + cw * 0.5; + const feetY = y + ch; + const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; + const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; + const bottom = feetY; + if (left < 0 || right > w || top < 0 || bottom > h) return out; + const minTx = Math.floor(left); + const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); + const minTy = Math.floor(top); + const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`); + } + return out; +} + +function serverWallCollisionTileKeys(md, px, py) { + if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(md, px, py); + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + for (let ty = offY; ty < offY + colH; ty++) { + for (let tx = offX; tx < offX + colW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; +} + +function validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */ +function pauseQuizForSpecialQuiz(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.active || space.quizFrozenForSpecial) return; + const phase = sess.phase; + if (phase !== 'read' && phase !== 'answer') return; + const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now()); + space.quizFrozenForSpecial = true; + space.quizFreezePhase = phase; + space.quizFreezeRemainingMs = remaining; + clearSpaceQuizTimers(space); + io.to(sid).emit('quiz-paused', { phase }); +} + +/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */ +function resumeQuizAfterSpecialQuiz(sid, space) { + if (!space.quizFrozenForSpecial) return; + space.quizFrozenForSpecial = false; + const sess = space.quizSession; + const phase = space.quizFreezePhase; + const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0); + space.quizFreezePhase = null; + space.quizFreezeRemainingMs = 0; + if (!sess || !sess.active) return; + const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space); + if (!md) return; + /* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */ + let extraMs = 0; + if (space.timeDilationPendingSec > 0) { + extraMs = space.timeDilationPendingSec * 1000; + space.timeDilationPendingSec = 0; + } + sess.phaseEndsAt = Date.now() + remaining + extraMs; + if (phase === 'read') { + sess.phase = 'read'; + const q = sess.questions[sess.qIndex]; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + text: String((q && q.text) || '').trim(), + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining); + space.quizTimers.push(t); + } else { + sess.phase = 'answer'; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + resumed: true, + }); + const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining); + space.quizTimers.push(t); + } +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(arr) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function ensurePeerLobbyThemes(space) { + if (!space || !space.peers) return; + const seen = new Set(); + space.peers.forEach((p, id) => { + let ti = parseInt(p.lobbyColorThemeIndex, 10); + if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) { + ti = pickFreeLobbyThemeIndex(space, id); + p.lobbyColorThemeIndex = ti; + } + seen.add(ti); + if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) { + p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0); + } + }); +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null, + lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, + lobbySkinToneIndex: p.lobbySkinToneIndex || null, + bannedSpectator: !!p.bannedSpectator, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ + → client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client + เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */ +/* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */ +function isBannedPeerForRun(space, id) { + if (!space) return false; + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true; + const pp = space.peers && space.peers.get(id); + return !!(pp && pp.bannedSpectator); +} + +/* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */ +function readyMapWithoutBanned(space, srcMap) { + const out = {}; + if (srcMap && typeof srcMap === 'object') { + Object.keys(srcMap).forEach((id) => { + if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id]; + }); + } + return out; +} + +function quizCarryLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady), + peers: buildPeersSnapForSpace(space), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + }; +} + +/* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน + แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */ +function crownLobbySyncPayload(space) { + return { + readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady), + expectedActive: (space && space.minigameExpectedActiveCount) || 0, + peers: buildPeersSnapForSpace(space), + }; +} + +/* เช็คว่าพร้อมเริ่มมินิเกม crown-lobby ไหม: ยังไม่เริ่ม + เข้าครบ + ทุกคน (ไม่นับ banned) กด Ready */ +function crownLobbyAllReadyToStart(space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)); + if (humanIds.length === 0) return false; + const expectedActive = space.minigameExpectedActiveCount || 0; + if (expectedActive > 0 && humanIds.length < expectedActive) return false; + return humanIds.every((id) => rm[id]); +} + +/* เริ่มมินิเกม crown-lobby (server เป็นคนสั่ง) — idempotent ด้วย gr.lobbyStarted (รีเซ็ตเองทุกมินิเกมเพราะ gr สร้างใหม่) + ใช้ทั้ง host กด START และ auto-start เมื่อทุกคน ready (Track B: MG1 ไม่ต้องพึ่ง host) */ +function performCrownLobbyStart(sid, space) { + const gr = space && space.gauntletRun; + if (!gr || !gr.crownRunHeld || gr.lobbyStarted) return false; + gr.lobbyStarted = true; + const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; + space.missionLiveBeginsAt = liveBeginsAt; + if (isQuizQuestionMissionShellSpace(space)) { + /* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ตรงกับตอน client จบ 3-2-1) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'quiz') { + clearSpaceQuizTimers(sp); + startQuizGame(sid, sp, md2); + } + }, LIVE_COUNTDOWN_MS); + } + if (isStackTowerMissionShellSpace(space)) { + /* MG3 stack: เริ่ม server engine ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'stack') startStackTicker(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isJumpSurviveMissionShellSpace(space)) { + /* MG5 jump_survive: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'jump_survive') startJumpSurviveGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isSpaceShooterMissionShellSpace(space)) { + /* MG6 space_shooter: เริ่ม server engine (บอท+รอบ) ตอน client จบ countdown (ตรง liveBeginsAt) */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'space_shooter') startSpaceShooterGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + if (isBalloonBossMissionShellSpace(space)) { + /* MG7 balloon_boss: เริ่ม server engine (บอท+รอบ+HP บอส) ตอน client จบ countdown */ + setTimeout(() => { + const sp = spaces.get(sid); + if (!sp) return; + const md2 = maps.get(sp.mapId) || sp.mapData; + if (md2 && md2.gameType === 'balloon_boss') startBalloonBossGame(sid, sp); + }, LIVE_COUNTDOWN_MS); + } + io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt }); + return true; +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +/* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด + ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */ +function detectiveLobbyBMissingParticipants(space) { + const roster = space && space.caseParticipantNicknames; + if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 }; + const present = new Set(); + space.peers.forEach((p) => { + if (!p || !p.nickname) return; + const n = normalizeLobbyNickname(p.nickname); + if (roster.has(n)) present.add(n); + }); + const total = roster.size; + return { missing: Math.max(0, total - present.size), present: present.size, total }; +} + +/* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */ +function emitDetectiveLobbyBPresence(sid, space) { + if (!sid || !space) return; + if (!serverMapIsPostCaseLobbyB(space)) return; + const info = detectiveLobbyBMissingParticipants(space); + if (info.total <= 0) return; + io.to(sid).emit('detective-lobbyb-presence', { + present: info.present, + total: info.total, + allHere: info.missing === 0, + }); +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + /** โหมดเทสต์ (admin game-timing): เลือกเกมต่อการ์ด 3 ใบ — ช่องที่ตั้ง key = เจาะจง, ช่องว่าง = สุ่ม */ + const forcedKeys = normalizeForcedMinigameKeys(runtimeGameTiming && runtimeGameTiming.forcedMinigameKeys); + const randomPool = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + let ri = 0; + const pick3 = []; + for (let i = 0; i < 3; i++) { + const fk = forcedKeys[i]; + let entry = fk ? available.find((e) => e.key === fk) : null; + if (!entry) { entry = randomPool[ri % randomPool.length]; ri += 1; } + pick3.push(entry); + } + space.suspectCardMinigames = pick3.map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); + // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ + space.playerEvidence = {}; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; +} + +function ensureSpacePlayerEvidence(space) { + if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {}; + if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {}; +} + +/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */ +function normalizeEvidenceSlotList(cards) { + if (!Array.isArray(cards)) return []; + const out = []; + for (let i = 0; i < cards.length; i++) { + const c = sanitizeStoredEvidenceCard(cards[i]); + if (c) out.push(c); + } + return out; +} + +/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */ +function peerNickKey(space, playerId) { + if (!space || !playerId) return ''; + const p = space.peers && space.peers.get ? space.peers.get(playerId) : null; + if (!p || !p.nickname) return ''; + return normalizeLobbyNickname(p.nickname); +} + +/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */ +function clonePlayerEvidence(space, playerId) { + ensureSpacePlayerEvidence(space); + let row = space.playerEvidence[playerId]; + if (!row || !row.length) { + const nk = peerNickKey(space, playerId); + if (nk && space.evidenceByNick[nk]) { + row = space.evidenceByNick[nk]; + space.playerEvidence[playerId] = row; + } + } + if (!row) row = [[], [], []]; + return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i])); +} + +/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */ +function syncEvidenceByNick(space, playerId) { + ensureSpacePlayerEvidence(space); + const nk = peerNickKey(space, playerId); + if (!nk) return; + const row = space.playerEvidence[playerId]; + if (Array.isArray(row)) space.evidenceByNick[nk] = row; +} + +/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */ +function playerSuspectProgressFromEvidence(space, playerId) { + const row = clonePlayerEvidence(space, playerId); + return row.map((arr) => arr.length); +} + +/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */ +function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) { + if (suspectIndex < 0 || suspectIndex > 2) return null; + ensureSpacePlayerEvidence(space); + /* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */ + if (!space.playerEvidence[playerId]) { + const nk = peerNickKey(space, playerId); + space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk]) + ? space.evidenceByNick[nk] + : [[], [], []]; + } + const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]); + /* กันเกิน 3 ใบ/ผู้ต้องสงสัย (#1) — defensive ถ้ามีรอบที่ 4 หลุดมา */ + if (list.length >= 3) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + const card = pickEvidenceCardForSuspect(space, suspectIndex, grade); + if (!card) { + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return null; + } + list.push(card); + space.playerEvidence[playerId][suspectIndex] = list; + syncEvidenceByNick(space, playerId); + return card; +} + +function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) { + const out = {}; + (playerIds || []).forEach((pid) => { + if (!space.peers.has(pid)) return; + const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade); + if (card != null) out[pid] = card; + }); + return out; +} + +function resolveDetectiveSuspectAwardIndex(space) { + if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) { + return space.suspectActiveIndex; + } + if (typeof space.lastDetectiveInvestigatedSuspect === 'number' + && space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) { + return space.lastDetectiveInvestigatedSuspect; + } + if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) { + return space.suspectPickIndex; + } + return -1; +} + +/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */ +function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { + const suspectIdx = resolveDetectiveSuspectAwardIndex(space); + if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} }; + if (space.detectiveAwardDoneForRun) { + return { suspectIdx, awarded: {}, alreadyGranted: true }; + } + const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()]; + const grade = computeRunGradeForEvidence(space); + const awardedList = {}; + ids.forEach((pid) => { awardedList[pid] = []; }); + const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade); + ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); }); + /* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */ + let freeEvidenceCount = 0; + const pend = findPendingSpecialCard(space, 7); + if (pend) { + const def = specialCardDef(7); + freeEvidenceCount = (def && def.evidence) || 1; + for (let n = 0; n < freeEvidenceCount; n++) { + ids.forEach((pid) => { + if (!space.peers.has(pid)) return; + const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade); + if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; } + }); + } + pend.consumed = true; + } + space.detectiveAwardDoneForRun = true; + space.lastDetectiveInvestigatedSuspect = suspectIdx; + space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount }; + emitLobbyEvidenceSync(sid, space); + return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount }; +} + +function emitLobbyEvidenceSync(sid, space) { + if (!sid || !space) return; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('lobby-evidence-sync', { + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) { + const msg = message || 'จบมินิเกม — กลับ LobbyB'; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + const personal = { + ...basePayload, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }; + sock.emit('quiz-ended', { message: msg, returnToLobbyB: true }); + sock.emit('detective-minigame-ended', personal); + sock.emit('game-start', personal); + }); +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveAwardDoneForRun = false; + space.coinAwardDoneForRun = false; + space.lastCrownMissionGrade = null; + /* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */ + space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; }); + /* เริ่มรอบใหม่ → ล้าง over event เก่า (กัน resume ส่ง over ของรอบก่อนให้คน reconnect) */ + space.lastMinigameOver = null; + /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ + space.bannedThisRunPlayerId = space.bannedPlayerId || null; + console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId); + space.bannedPlayerId = null; + /* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน) + สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */ + space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + glog(sid, space, 'game-start', 'เริ่มมินิเกม: ' + ((md && md.name) || mapId) + ' (การ์ด ' + ((selectedIndex != null ? selectedIndex + 1 : '?')) + ')'); + /* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */ + maybeSpawnSpecialQuizForRun(space); + /* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */ + + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */ + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + /** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */ + const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID; + if (!isQuizMissionShell) { + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + /* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */ + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + /* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client + กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */ + if (md.gameType === 'quiz_carry') { + space.quizCarryLobbyReady = {}; + space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; }); + io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + bannedPlayerId: space.bannedThisRunPlayerId || null, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) { + ensurePostCaseLobbyMapLoaded(); + /* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */ + awardMinigameRankCoins(sid, space); + clearSpaceQuizTimers(space); + clearSpecialQuizTimers(space); + space.specialQuiz = null; + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + /* Last Light (MG2): เก็บเกรด mission (รวมบอท server-authoritative) ก่อนล้าง gauntletRun + → ใช้เป็น "แหล่งเดียว" ของเกรดหลักฐาน ให้ตรงกับสรุปภารกิจที่ผู้เล่นเห็น */ + if (space.gauntletRun && isGauntletCrownHeistMapBySpace(space)) { + try { space.lastCrownMissionGrade = buildGauntletCrownMissionPayload(space).grade; } + catch (_e) { space.lastCrownMissionGrade = null; } + } + stopGauntletTicker(space); + space.gauntletRun = null; + /* MG3 stack (server-auth): หยุด engine + ล้าง session (เกรดถูกเก็บไว้ใน lastStackMissionGrade ตอนจบแล้ว) */ + stopStackTicker(space); + if (space.stackSession) space.stackSession.active = false; + space.stackSession = null; + /* MG4 quiz_carry (server-auth): ล้าง session (คะแนนถูกอ่านไปแจกเหรียญใน awardMinigameRankCoins ด้านบนแล้ว) */ + if (space.quizCarrySession) space.quizCarrySession.active = false; + space.quizCarrySession = null; + 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; + let awardIds = []; + if (awardOptions && awardOptions.allPlayers) { + awardIds = [...space.peers.keys()]; + } else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) { + awardIds = [awardOptions.playerId]; + } else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) { + awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id)); + } + /* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน + (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */ + awardIds = awardIds.filter((id) => { + if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false; + const pp = space.peers.get(id); + if (pp && pp.bannedSpectator) return false; + return true; + }); + /* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */ + if (!space.detectiveAwardDoneForRun && awardIds.length) { + /* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */ + grantDetectiveEvidenceForCurrentRun(sid, space, awardIds); + } + } + space.suspectActiveIndex = null; + space.bannedThisRunPlayerId = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const basePayload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + detectiveReturn: true, + }; + emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload); + /* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ" + ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */ + const banPend = findPendingSpecialCard(space, 3); + console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space)); + if (banPend && !space.pickVote) { + banPend.consumed = true; + /* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */ + space.pendingBanVoteCard = banPend.card; + } +} + +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: trialEligibleVoterTotal(space), + }); +} + +function computeAndEmitTrialResult(sid, space) { + /* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */ + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + /* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */ + { + /* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */ + const forceWrong = !!space.testForceWrongVote; + space.testForceWrongVote = false; + const c2 = [0, 0, 0]; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; }); + let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; } + const anyVotes = (c2[0] + c2[1] + c2[2]) > 0; + const wrong = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit); + const pend = findPendingSpecialCard(space, 5); + if (wrong && pend && !space.trialBailUsed) { + pend.consumed = true; + space.trialBailUsed = true; + space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */ + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); + openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted }); + return; + } + } + space.trialPhase = 'revealed'; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + if (p && p.nickname) return p.nickname; + if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) { + const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10); + return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท'; + } + return String(id).slice(0, 6); + }); + /* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */ + let disruptorId = null; + if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) { + for (const [pid, p] of space.peers) { + const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey; + const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname; + if (keyMatch || nickMatch) { disruptorId = pid; break; } + } + } + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + disruptorId, + hasDisruptor: !!disruptorId, + }); + + /* ===== Achievement auto-track ===== */ + try { + const achv = []; + /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */ + winners.forEach((id) => { + const p = space.peers.get(id); + if (p && p.playerKey) { + achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 }); + achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 }); + } + }); + /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */ + if (disruptorId && space.disruptorPlayerKey) { + const dKey = space.disruptorPlayerKey; + achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */ + /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */ + let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; } + const anyV = (counts[0] + counts[1] + counts[2]) > 0; + if (anyV && mv !== culprit) { + achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */ + achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */ + } + } + submitAchievementProgress(achv); + } catch (e) { console.error('[achv] trial-result', e && e.message); } + + space.silencedPlayerId = null; +} + +/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ +/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */ +const TESTIMONY_BOT_PREFIX = '__lobby_bot_'; + +function testimonyBotIds(space) { + const n = effectiveBotSlotCount(space); + const out = []; + for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i); + return out; +} + +function buildTestimonyMembers(space) { + const arr = []; + space.peers.forEach((p, id) => { + arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false }); + }); + testimonyBotIds(space).forEach((bid, i) => { + arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true }); + }); + return arr; +} + +/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */ +function testimonyTotalParticipants(space) { + return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); +} + +/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */ +function botTestimonyPickCount(space) { + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + let maxOwned = 0; + space.peers.forEach((_p, pid) => { + const ev = clonePlayerEvidence(space, pid); + const len = Array.isArray(ev[round]) ? ev[round].length : 0; + if (len > maxOwned) maxOwned = len; + }); + /* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */ + if (maxOwned === 0) return 1; + return Math.max(1, Math.min(2, maxOwned)); +} + +/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */ +function autoSubmitBotTestimonyPicks(space) { + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; + const want = botTestimonyPickCount(space); + testimonyBotIds(space).forEach((bid) => { + if (space.testimonySubmitted[bid] != null) return; + const picks = []; + for (let n = 0; n < want; n++) { + const c = pickEvidenceCardForSuspect(space, round, 'C'); + if (c) picks.push(c); + } + space.testimonySubmitted[bid] = picks; + }); +} + +function emitTestimonyOpen(sid, space) { + space.testimonySubmitted = {}; + space.testimonyReadyNext = {}; + space.testimonyRevealed = false; + /* บอทส่งหลักฐานทันทีเมื่อเปิด round */ + autoSubmitBotTestimonyPicks(space); + const submittedIds = Object.keys(space.testimonySubmitted || {}); + const base = { + round: space.testimonyRound, + suspectIndex: space.testimonyRound, + members: buildTestimonyMembers(space), + totalPlayers: testimonyTotalParticipants(space), + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + submitted: submittedIds, + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('testimony-open', { + ...base, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + /* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */ + emitTestimonyStatus(sid, space); +} + +function emitTestimonyStatus(sid, space) { + io.to(sid).emit('testimony-status', { + round: space.testimonyRound, + submitted: Object.keys(space.testimonySubmitted || {}), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +function doTestimonyReveal(sid, space) { + space.testimonyRevealed = true; + /* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */ + autoSubmitBotTestimonyPicks(space); + /* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */ + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); + io.to(sid).emit('testimony-reveal', { + round: space.testimonyRound, + picks: space.testimonySubmitted || {}, + members: buildTestimonyMembers(space), + ready: Object.keys(space.testimonyReadyNext), + totalPlayers: testimonyTotalParticipants(space), + }); +} + +/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */ +/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */ +/* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */ +function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; } +const TRIAL_VOTE_EXTENSION_MS = 10000; + +function clearTrialVoteTimer(space) { + if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; } +} + +/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */ +function openTrialVotingPhase(sid, space, opts) { + opts = opts || {}; + space.testimonyActive = false; + space.trialPhase = 'voting'; + space.trialVotes = {}; + /* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */ + const excludedSuspect = opts.revote + ? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect + : (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null)) + : null; + if (!opts.revote) space.trialRevoteExcluded = null; + autoVoteBotsTrial(space); + clearTrialVoteTimer(space); + /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ + let extraMs = 0; + let extensionCard = false; + const pend = findPendingSpecialCard(space, 2); + if (pend) { + extraMs = TRIAL_VOTE_EXTENSION_MS; + extensionCard = true; + pend.consumed = true; + } + const durMs = trialVoteMs() + extraMs; + space.trialVoteEndsAt = Date.now() + durMs; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + voteEndsAt: space.trialVoteEndsAt, + voteDurationMs: durMs, + extensionCard, + extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0, + revote: !!opts.revote, + excludedSuspect: excludedSuspect, + silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId), + }); + }); + if (extensionCard) { + io.to(sid).emit('special-card-applied', { + card: specialCardClientPayload(pend.card), + addSec: TRIAL_VOTE_EXTENSION_MS / 1000, + context: 'trial', + }); + } + emitTrialVoteUpdate(sid, space); + space.trialVoteTimer = setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || spNow.trialPhase !== 'voting') return; + computeAndEmitTrialResult(sid, spNow); + }, durMs + 120); +} + +/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */ +/* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */ +function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; } + +function clearPickVoteTimer(space) { + if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; } +} + +function pickVoteCandidates(space) { + const arr = []; + space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false })); + testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true })); + return arr; +} + +function autoVotePickBots(space) { + const pv = space.pickVote; + if (!pv) return; + testimonyBotIds(space).forEach((bid) => { + if (pv.votes[bid] != null) return; + const choices = pv.candidates.filter((c) => c.id !== bid); + if (!choices.length) return; + pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id; + }); +} + +function emitPickVoteUpdate(sid, space) { + const pv = space.pickVote; + if (!pv) return; + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + io.to(sid).emit('player-pick-vote-update', { + voted: Object.keys(pv.votes).length, + total: pv.candidates.length, + counts, + }); +} + +function startPlayerPickVote(sid, space, purpose, onResolve) { + clearPickVoteTimer(space); + const PV_MS = pickVoteMs(); + const candidates = pickVoteCandidates(space); + space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_MS, resolved: false, onResolve }; + autoVotePickBots(space); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('player-pick-vote-open', { + purpose, + candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: space.pickVote.endsAt, + durationMs: PV_MS, + }); + }); + emitPickVoteUpdate(sid, space); + space.pickVoteTimer = setTimeout(() => { + const sp = spaces.get(sid); + if (!sp || !sp.pickVote || sp.pickVote.resolved) return; + resolvePlayerPickVote(sid, sp); + }, PV_MS + 120); + maybeResolvePickVote(sid, space); +} + +function maybeResolvePickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + const ids = pv.candidates.map((c) => c.id); + if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space); +} + +function resolvePlayerPickVote(sid, space) { + const pv = space.pickVote; + if (!pv || pv.resolved) return; + pv.resolved = true; + clearPickVoteTimer(space); + const counts = {}; + Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); + let max = 0; + Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); + const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); + /* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */ + const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : null; + const tc = pv.candidates.find((c) => c.id === target); + const targetName = tc ? tc.nickname : ''; + io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts }); + const cb = pv.onResolve; + space.pickVote = null; + if (typeof cb === 'function') cb(target, targetName); +} + +function trialSilencedIsParticipant(space) { + const s = space.silencedPlayerId; + if (!s) return false; + if (space.peers.has(s)) return true; + return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0); +} + +function trialEligibleVoterTotal(space) { + let t = testimonyTotalParticipants(space); + if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1); + return t; +} + +function autoVoteBotsTrial(space) { + if (!space.trialVotes) space.trialVotes = {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3); + const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1; + testimonyBotIds(space).forEach((bid) => { + if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */ + if (space.trialVotes[bid] != null) return; + /* 40% โหวตถูก, 60% โหวตสุ่ม */ + let pick; + if (Math.random() < 0.4) { + pick = culprit; + } else { + pick = Math.floor(Math.random() * 3); + } + /* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */ + if (pick === excluded) pick = culprit; + space.trialVotes[bid] = pick; + }); +} + +function advanceTestimony(sid, space) { + space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; + if (space.testimonyRound >= 3) { + space.trialBailUsed = false; + space.silencedPlayerId = null; + /* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */ + const pend = findPendingSpecialCard(space, 4); + console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space)); + if (pend) { + pend.consumed = true; + io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' }); + startPlayerPickVote(sid, space, 'silence', (targetId) => { + const sp = spaces.get(sid); + if (!sp) return; + sp.silencedPlayerId = targetId || null; + openTrialVotingPhase(sid, sp, { revote: false }); + }); + return; + } + openTrialVotingPhase(sid, space, { revote: false }); + } else { + emitTestimonyOpen(sid, space); + } +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + /* คะแนนสุดท้าย authoritative (ผู้เล่นจริง) — ส่งไปกับ quiz-ended ให้ทุก client ตัดสินผล complete/gameover ตรงกัน + (เดิมไม่ส่ง → client อ่าน playLiveQuizScores local ที่ quiz-result ข้อสุดท้ายอาจมาไม่ทัน → ผล 2 จอไม่ตรง) */ + const quizFinalScores = {}; + const sessF = space.quizSession; + if (sessF && sessF.players) { + Object.keys(sessF.players).forEach((pid) => { + const st = sessF.players[pid]; + quizFinalScores[pid] = (st && typeof st.score === 'number') ? Math.max(0, st.score) : 0; + }); + } + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) + เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" + การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true, scores: quizFinalScores }); + /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ + (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', scores: quizFinalScores }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + /* บอท: server ตัดสินคำตอบข้อนี้ + ตั้งเป้าเดินไปโซน (server tick เดินให้ในช่วง answer) */ + const qCur = sess.questions[sess.qIndex]; + if (qCur) quizBotsDecideAnswers(space, sess.quizMapMd || md, !!qCur.answerTrue); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + /* บอท: server เป็นเจ้าของคะแนน (จากคำตอบที่ตัดสินไว้) + คัดออกถ้าผิด (กฎเดียวกับคน "ตอบผิดถูกคัดออก") */ + if (sess.bots) { + sess.bots.forEach((b, botId) => { + if (!b.eliminated) { + if (b.answerRight) { + b.score += QUIZ_TF_POINTS_PER_CORRECT; + } else { + b.eliminated = true; + /* คัดออก → วาร์ปเข้าโซนผีทันที (เหมือนคนจริง) */ + const gc = quizRandomCellInZone(mdLive.quizGhostArea, mdLive); + if (gc) { b.x = gc.x + 0.5; b.y = gc.y + 0.5; } + b.wanderTX = null; b.retargetAt = 0; + } + } + scores[botId] = b.score; + results.push({ + id: botId, nickname: 'บอท', right: !!b.answerRight, choice: b.choice, + eliminated: !!b.eliminated, score: b.score, + }); + b.tx = null; b.ty = null; /* รีเซ็ตเป้า → เดินมั่วจนข้อถัดไป */ + }); + } + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + /** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */ + const playFullQuizRound = + !!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space); + if (allWrong && !playFullQuizRound) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + space.quizSession.bots = createQuizBots(space, md); + scheduleQuizReadPhase(sid, space, md); +} + +/* ===== MG1 quiz bots — server-authoritative (roster + เดิน + ตอบ + คะแนน) ===== + เดิม host เดิน+คิดคะแนนบอทเอง → host หลุด = บอทหยุด + คะแนนเพี้ยน. ย้ายมา server → บอทเดินเอง time-based + คะแนน server เป็นเจ้าของ + id `__pv_bot_` 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; + try { glog(sid, space, 'mg4-round', 'resolve ' + s.roundsCompleted + '/' + s.sessionLength + ' q=' + s.qIndex + ' winner=' + (winnerId || '-') + ' 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, + 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) => { + /* ทับ 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; + } + 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); +} + +/* ── 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, + }; +} + +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 = 1500; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป */ +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, + }); +} +function stackAdvanceTurnServer(s) { + if (!s.turnOrder || !s.turnOrder.length) return; + s.turnIndex = (s.turnIndex + 1) % s.turnOrder.length; + s.botThinkUntil = 0; + s.botAimPhase = null; +} +function stackSessionMissionGrade(space) { + const s = space.stackSession; + if (!s) return 'C'; + const scores = []; + s.turnOrder.forEach((e) => { + if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); scores.push(Math.max(0, (b && b.score) | 0)); } + else if (e.id != null) scores.push(Math.max(0, s.humanPts[e.id] | 0)); + }); + const n = scores.length || 1; + const avg = Math.floor(scores.reduce((a, b) => a + b, 0) / n); + let grade = avg >= 80 ? 'A' : avg >= 60 ? 'B' : 'C'; + return grade; +} +function stackSessionEnd(sid, space, outcome) { + const s = space.stackSession; + if (!s || s.ended) return; + s.ended = true; + stopStackTicker(space); + /* คะแนนผู้เล่นจริง → reportedMiniScore (ใช้ทางเดิมของ coin/evidence) */ + s.turnOrder.forEach((e) => { + if (e.kind === 'human' && e.id != null) { + const p = space.peers.get(e.id); + if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; } + } + }); + try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; } + const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' }; + io.to(sid).emit('stack-over', overPayload); + space.lastMinigameOver = { event: 'stack-over', payload: overPayload, atMs: Date.now() }; +} +function runStackTick(sid, space) { + const s = space.stackSession; + if (!s || !s.active || s.ended) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'stack') { stopStackTicker(space); return; } + const now = Date.now(); + /* เงื่อนไขจบเกม (server ตัดสินแหล่งเดียว) */ + if (s.progressPct >= 100) { stackSessionEnd(sid, space, 'decrypt_complete'); return; } + if (s.teamMissesLeft != null && s.teamMissesLeft <= 0) { stackSessionEnd(sid, space, 'misses_exhausted'); return; } + if ((now - s.startMs) >= stackTimeLimitSecServer() * 1000) { stackSessionEnd(sid, space, 'time_up'); return; } + if (!s.turnOrder.length) return; + /* รอแอนิเมชันบล็อกก่อนหน้า (busy) → ไม่แกว่ง/ไม่ดรอป */ + if (now < s.busyUntil) return; + const entry = stackCurrentTurnEntry(s); + if (!entry) return; + if (entry.kind === 'bot' && entry.botId) { + const bot = s.bots.get(entry.botId); + const tier = bot ? bot.tier : 'avg'; + if (s.botThinkUntil === 0) { + s.botThinkUntil = now + STACK_BOT_THINK_MIN_MS + Math.floor(Math.random() * (STACK_BOT_THINK_MAX_MS - STACK_BOT_THINK_MIN_MS)); + } else { + /* เลื่อนเฟสตามเวลา (ให้ client เห็นแกว่ง) */ + s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2; + /* ส่ง swingAmp+topCenterX ด้วย → client วาดบล็อกที่ห้อยตรงกับโมเดล server เป๊ะ (drop landOffset = swingAmp*sin(phase)) ไม่วาปตอนปล่อย */ + if (now - s.lastSwingEmit > 50) { s.lastSwingEmit = now; io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX, 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; + const _dbgAim = s.botAimPhase; const _dbgDeadline = (now >= s.botAimDeadline) && !(s.phase >= s.botAimPhase); + s.botAimPhase = null; + const hit = stackComputeDropServer(s, md); + /* [DBG-MG3drop] บอทดรอปที่ s.phase → เทียบ aimPhase/จุดลง/รองรับ/overlap/miss (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'BOT ' + (entry.botId || '') + ' phase=' + s.phase.toFixed(3) + + ' aimPhase=' + (Number.isFinite(_dbgAim) ? _dbgAim.toFixed(3) : 'x') + (_dbgDeadline ? '(DEADLINE!)' : '(reached)') + + ' sinDrop=' + Math.sin(s.phase).toFixed(3) + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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 }); + } + /* [DBG-MG6move] จับสถานะบอททุก ~2.5วิ → STILL=server ไม่ขับ, DEAD=ตาย(client ต้องซ่อน), MOV=ปกติ */ + if (!s._dbgMT || now - s._dbgMT > 2500) { + s._dbgMT = now; + const rows = []; + s.bots.forEach((b, id) => { + const mv = (b._ldx == null) ? '?' : ((Math.abs(b.cx - b._ldx) + Math.abs(b.cy - b._ldy) > 2) ? 'MOV' : 'STILL'); + b._ldx = b.cx; b._ldy = b.cy; + const tag = (id.indexOf(JS_BOT_PREFIX) === 0 ? 'pv' + id.slice(JS_BOT_PREFIX.length) : 'TK:' + String(id).slice(0, 5)); + rows.push(tag + (b.takeover ? 'tk' : '') + '·' + (b.eliminated ? 'DEAD' : mv)); + }); + glog(sid, space, 'mg6-move', '[' + rows.join(' ') + ']'); + } +} + +/* ============================================================ + 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' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true }); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + y0 = Math.max(0, Math.min(h - 1, y0)); + y1 = Math.max(0, Math.min(h - 1, y1)); + if (y1 < y0) { + const t = y0; + y0 = y1; + y1 = t; + } + return { y0, y1 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + /* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */ + if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) { + if (gr.endsAt != null) gr.endsAt += getGauntletTickMs(); + emitGauntletSync(sid, space); + return; + } + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + /* เข้าโซนเส้นชัยแล้ว (client แจ้ง) — หยุด obstacle/collision ทันที แล้วรอ grace ให้ client + เดินทุกคน "ค่อยๆ" เข้าเส้นชัยจนครบ ค่อยสรุปผล (ไม่ snap กระพริบ / ไม่จบทันที) */ + if (gr.finishPhase) { + if (gr.obstacles.length) { gr.obstacles = []; } + if (gr.finishEndAt == null) gr.finishEndAt = Date.now() + GAUNTLET_FINISH_WALK_MS; + if (Date.now() >= gr.finishEndAt) { + endGauntletGame(sid, space, 'finish'); + } + /* ไม่ emitGauntletSync ระหว่าง grace — ปล่อย client คุมการเดินเข้าเส้นเอง (กัน sync ดึงตำแหน่งกลับ) */ + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* G2: pre-run hold — หลัง 3-2-1 จบ ค้างที่จุด start ให้เห็นเส้นเริ่มก่อน (ยังไม่เลื่อน/เกิด obstacle/ขยับบอท) + client ค้าง bg เองด้วย max(0, serverNow − liveStartMs) → bg+bots/obstacles sync ที่ start พร้อมกัน */ + if (gr.liveStartMs != null && Date.now() < gr.liveStartMs) { + space.peers.forEach((p) => { p.gauntletJumpPending = false; }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* บอท server-authoritative — แถวเลนของบอทที่ยังเล่นอยู่ (ไม่ต้องพึ่ง gauntlet-preview-rows จาก host) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (b.gauntletEliminated) return; + const lr = gauntletLaneGridRowFromPlayerTop(md, b.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + } + /* แถวบนที่ client แจ้ง (บอท preview — editor หรือ client เก่า) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + let _gauntletLaserThisTick = false; + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + _gauntletLaserThisTick = true; + } + /* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */ + if (!_gauntletLaserThisTick) { + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + /* บอท server-authoritative — ตัดสินใจกระโดด + ฟิสิกส์ รอบ tick เดียวกับผู้เล่น (obstacle เลื่อนแล้วข้างบน) */ + if (gr.bots && gr.bots.size) { + gr.bots.forEach((b) => { + if (crownHeist && b.gauntletEliminated) return; + gauntletBotDecideJump(b, gr.obstacles, md, w, h); + gauntletBotStepPhysics(b, gr.obstacles, md, w, h, crownHeist); + }); + } + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; +} + +function mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(md) { + const w = md.width || 20; + const h = md.height || 15; + const out = [null, null, null, null, null, null]; + const raw = md && md.lobbyPlayerSpawns; + if (!Array.isArray(raw)) return out; + for (let i = 0; i < 6 && i < raw.length; i++) { + const cell = raw[i]; + if (!cell || typeof cell !== 'object') continue; + const x = Math.floor(Number(cell.x)); + const y = Math.floor(Number(cell.y)); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (x < 0 || x >= w || y < 0 || y >= h) continue; + out[i] = { x, y }; + } + return out; +} + +/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) { + if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; + const g = md.shooterSpawnSlots; + if (!g) return; + const w = md.width || 20, h = md.height || 15; + for (let slot = 1; slot <= 6; slot++) { + const idx = slot - 1; + if (slots6[idx]) continue; + let found = false; + for (let yy = 0; yy < h && !found; yy++) { + const row = g[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (row[xx] === slot) { + slots6[idx] = { x: xx, y: yy }; + found = true; + break; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex); + if (qb) return qb; + } + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && md.spawn) { + const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; + const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; + const w = md.width || 20; + const h = md.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */ +/** เลือก host คนถัดไปเมื่อ host ปัจจุบันหลุด — คนที่อยู่ในห้องนานสุด (spawnJoinOrder ต่ำสุด) + เสถียรข้ามการ reconnect (spawnJoinOrder ผูกกับ playerKey) — แทน Map-first ที่สลับได้ */ +function pickNextHostPeer(space) { + if (!space || !space.peers || space.peers.size === 0) return null; + let best = null; + let bestOrd = Infinity; + space.peers.forEach((p, id) => { + if (!p) return; + const ord = (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) ? p.spawnJoinOrder : 9999; + if (ord < bestOrd || (ord === bestOrd && (best == null || String(id) < String(best.id)))) { + bestOrd = ord; + best = p; + } + }); + return best; +} + +function resolveSpawnJoinOrder(space, playerKey) { + if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {}; + const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const used = new Set(); + space.peers.forEach((p) => { + if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder); + }); + /* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */ + if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) { + const prev = space.spawnOrderByKey[key]; + if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev; + } + let ord = 0; + while (used.has(ord)) ord++; + if (key) space.spawnOrderByKey[key] = ord; + return ord; +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +/** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน < 4) */ +function openSuspectPhaseForRoom(sid, space, disruptorAccepted) { + if (!space) return; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + const phaseBase = { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: !!disruptorAccepted, + cardMinigames: space.suspectCardMinigames || [], + }; + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('suspect-phase-open', { + ...phaseBase, + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + /* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง ≥ 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */ + const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer); + if (peerIds.length < 4 && !forceOffer) { + /* ไม่เสนอบทตัวป่วน (คน < 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */ + space.troublesomeOfferSent = true; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + openSuspectPhaseForRoom(sid, space, false); + return; + } + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 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) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + /* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser) + ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */ + const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; + const prevHostId = space.hostId; + if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) { + space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */ + } else if (!space.hostId) { + space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */ + if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey; + } + /* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers + จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost) + แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */ + if (joinPlayerKey) { + const staleIds = []; + space.peers.forEach((p, pid) => { + if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid); + }); + staleIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + /* กลับมาทัน grace 10 วิ → ยกเลิก bot takeover + ปิด banner countdown */ + if (space.graceReconnect && space.graceReconnect[joinPlayerKey]) { + delete space.graceReconnect[joinPlayerKey]; + if (space.graceTimers && space.graceTimers[joinPlayerKey]) { clearTimeout(space.graceTimers[joinPlayerKey]); delete space.graceTimers[joinPlayerKey]; } + io.to(spaceId).emit('peer-grace-cancel', { nickname: nickname || 'ผู้เล่น' }); + } + /* MG1 takeover: ผู้เล่นกลับมากลาง quiz (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (sess.bots) คืนสิทธิ์เล่น + กัน "ตัวซ้ำ" (บอท takeover + ตัวที่ rejoin) */ + const sessJ = space.quizSession; + if (sessJ && sessJ.bots) { + const dropBotIds = []; + sessJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBotIds.push(bid); }); + dropBotIds.forEach((bid) => { + sessJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); /* ลบ avatar บอท takeover เก่า — ตัวจริงจะมาใหม่ผ่าน user-joined */ + }); + } + /* MG2 takeover เช่นกัน: ผู้เล่นกลับมากลาง gauntlet (playerKey เดิม) → ถอนบอทที่แปลงจากเขา (gauntletRun.bots) */ + const grJ = space.gauntletRun; + if (grJ && grJ.bots) { + const dropG = []; + grJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropG.push(bid); }); + dropG.forEach((bid) => { + grJ.bots.delete(bid); + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG3 takeover เช่นกัน: กลับมากลาง stack (playerKey เดิม) → ถอนบอท + คืน seat ให้คนจริง (inherit คะแนนกลับ) */ + const s3J = space.stackSession; + if (s3J && s3J.bots) { + let restored = false; + const dropS = []; + s3J.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropS.push(bid); }); + dropS.forEach((bid) => { + const inherited = Math.max(0, ((s3J.bots.get(bid) || {}).score) | 0); + s3J.bots.delete(bid); + const entry = (s3J.turnOrder || []).find((e) => e.kind === 'bot' && String(e.botId) === String(bid)); + if (entry) { entry.kind = 'human'; entry.id = socket.id; entry.botId = null; s3J.humanPts[socket.id] = inherited; restored = true; } + io.to(spaceId).emit('user-left', { id: bid }); + }); + if (restored) emitStackStateServer(spaceId, space); + } + /* MG4 takeover เช่นกัน: กลับมากลาง quiz_carry (playerKey เดิม) → ถอนบอท + คืน slot ให้คนจริง (inherit ป้าย/คะแนนกลับ) */ + const sCJ = space.quizCarrySession; + if (sCJ && sCJ.bots) { + const dropC = []; + sCJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropC.push(bid); }); + dropC.forEach((bid) => { + const bb = sCJ.bots.get(bid) || {}; + sCJ.bots.delete(bid); + sCJ.players[socket.id] = { held: (bb.held == null ? null : bb.held), score: Math.max(0, Number(bb.score) || 0) }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG5 takeover เช่นกัน: กลับมากลาง jump_survive (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit สถานะตาย) */ + const sJJ = space.jumpSurviveSession; + if (sJJ && sJJ.bots) { + const dropJ = []; + sJJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropJ.push(bid); }); + dropJ.forEach((bid) => { + const bb = sJJ.bots.get(bid) || {}; + sJJ.bots.delete(bid); + sJJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG6 takeover: กลับมากลาง space_shooter (playerKey เดิม) → ถอนบอท + คืน slot คนจริง (inherit คะแนน/ตาย) */ + const sSJ = space.spaceShooterSession; + if (sSJ && sSJ.bots) { + const dropSS = []; + sSJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropSS.push(bid); }); + dropSS.forEach((bid) => { + const bb = sSJ.bots.get(bid) || {}; + sSJ.bots.delete(bid); + sSJ.players[socket.id] = { eliminated: !!bb.eliminated }; + io.to(spaceId).emit('user-left', { id: bid }); + }); + } + /* MG7 takeover: กลับมากลาง balloon_boss (playerKey เดิม) → ถอนบอท + คืน slot คนจริง */ + const sBJ = space.balloonBossSession; + if (sBJ && sBJ.bots) { + const dropBB = []; + sBJ.bots.forEach((b, bid) => { if (b && b.takeover && b.takeoverPlayerKey && b.takeoverPlayerKey === joinPlayerKey) dropBB.push(bid); }); + dropBB.forEach((bid) => { sBJ.bots.delete(bid); io.to(spaceId).emit('user-left', { id: bid }); }); + } + } + /* reconnect dedup ชั้น 2: ghost ที่ playerKey block จับไม่ได้ (peer เก่าไม่มี playerKey) + ลบด้วย "ชื่อ custom เดียวกัน" — แต่ **เฉพาะ peer เก่าที่ไม่มี valid playerKey** เท่านั้น + ⚠️ ห้ามลบ peer ที่มี playerKey คนละค่า: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละคน ต้องเก็บทั้งคู่ + (เดิมลบทุกตัวที่ชื่อตรง → client ที่ชื่อซ้ำ host ถูกลบ = "ไม่ได้เล่นเลย") */ + const validPkStr = (p) => (p && typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) ? p.playerKey : ''; + const newNickNorm = normalizeLobbyNickname(nickname); + const newNickIsCustom = newNickNorm && newNickNorm !== 'ผู้เล่น' + && newNickNorm.indexOf('ผู้เล่น') !== 0 && newNickNorm.indexOf('บอท') !== 0; + if (newNickIsCustom) { + const ghostIds = []; + space.peers.forEach((p, pid) => { + if (pid === socket.id || !p) return; + const ppk = validPkStr(p); + if (ppk && ppk !== joinPlayerKey) return; /* คนละ playerKey = คนละคน (ชื่อซ้ำได้) → ไม่ลบ */ + if (normalizeLobbyNickname(p.nickname) === newNickNorm) ghostIds.push(pid); + }); + ghostIds.forEach((pid) => { + space.peers.delete(pid); + if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; + if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; + io.to(spaceId).emit('user-left', { id: pid }); + }); + } + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + ensurePeerLobbyThemes(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey); + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = balloonBossBalloonsForMap(mdJoin); + /* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */ + let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10); + if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) { + /* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */ + let takenByHuman = false; + space.peers.forEach((p) => { + const ti = parseInt(p.lobbyColorThemeIndex, 10); + if (ti === chosenThemeIdx) takenByHuman = true; + }); + if (takenByHuman) { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + /* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */ + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0; + }); + } + } else { + chosenThemeIdx = pickFreeLobbyThemeIndex(space); + } + let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10); + if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) { + chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder); + } + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + lobbyColorThemeIndex: chosenThemeIdx, + lobbySkinToneIndex: chosenSkinIdx, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '', + reportedMiniScore: 0, reportedMiniSurvived: true, + bannedSpectator: !!bannedSpectator, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* เก็บกวาด peer ซ้ำทั้งก้อน (ghost) — เผื่อ dedup ต่อ-join ด้านบนพลาด/race จนคนเดิมค้าง 2 ตัว */ + pruneDuplicateSpacePeers(spaceId, space); + glog(spaceId, space, 'join', 'สี#' + (peer.lobbyColorThemeIndex || '?') + ' · คน ' + space.peers.size + ' + บอท ' + effectiveBotSlotCount(space) + (joinPlayerKey ? '' : ' · NO-KEY'), nickname || 'ผู้เล่น'); + /* invariant: คน + บอท ≤ 6 — กัน "เงาสะท้อนของ user": คนหลุดแล้ว grace แปลงเป็นบอท (botSlotCount+1, สีตามคนออก) + พอคนนั้นกลับเข้ามา (reconnect หลัง grace) จะมีทั้งคนจริง + บอทสีเดียวกัน = ตัวซ้ำ. ตัดบอทส่วนเกินออกให้รวมไม่เกิน 6 + (มินิเกม cap ที่ 6-humans อยู่แล้ว ~rebalancePreviewBots แต่ LobbyB ไม่ได้ cap → ตัดที่ server ต้นทาง) */ + { + const humansNow = space.peers.size; + const botsNow = effectiveBotSlotCount(space); + if (humansNow + botsNow > LOBBY_SLOT_TOTAL) { + space.botSlotCount = Math.max(0, LOBBY_SLOT_TOTAL - humansNow); + } + /* re-sync สีบอททุกครั้งที่มีคน (re)join + แจ้ง "ทุกจอ" (รวมคนที่อยู่ในมินิเกม/LobbyB อยู่แล้ว): + เคส "เงาสะท้อนของ user" = คนหลุด→บอทใช้สีคนนั้น (pin)→คนนั้นกลับมา. join handler เคลียร์ pin ที่ชนสีคนแล้ว + (7704) + syncLobbyBotThemeSlots เลือกสีใหม่ที่ไม่ชนคน แต่เดิม emit เฉพาะตอน total>6 → จออื่นไม่อัปเดต = บอทยังสีเดิม. + emit ทุก join → บอทเปลี่ยนสีพ้นจากสี user ทันทีทุกจอ */ + emitLobbyTintSync(spaceId, space); + } + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...space.peers.keys()].indexOf(socket.id); + /* นับช่อง spawn ให้พอกับ "คน + บอท" — เดิมส่งแค่ peers.size: ห้อง 1 คน = 1 ช่อง = pos[0] (เลนบนสุด) เสมอ + แล้วไปทับบอทที่ initGauntletPeersAll วางไว้ที่ pos[0] → คน(host ใหม่)+บอท วิ่งเลนเดียวกันเลนบนสุด (bug ที่เจอซ้ำ) */ + const grJoin = space.gauntletRun; + const botCntJoin = (grJoin && grJoin.bots) ? grJoin.bots.size : 0; + const pos = gauntletSpawnPositions(mdJoin, space.peers.size + botCntJoin); + /* เลี่ยงทับทั้ง "peer อื่น" และ "บอท" ที่อยู่ในเลนแล้ว — ดันไปช่องว่างถัดไป (เดิมเช็คแต่ peer ไม่เช็คบอท) */ + const occupied = new Set(); + space.peers.forEach((q) => { if (q && q.id !== socket.id) occupied.add(Math.floor(q.x) + ',' + Math.floor(q.y)); }); + if (grJoin && grJoin.bots) grJoin.bots.forEach((b) => { if (b) occupied.add(Math.floor(Number(b.x)) + ',' + Math.floor(Number(b.y))); }); + let pidx = Number.isFinite(ord) ? Math.max(0, Math.min(ord, pos.length - 1)) : pos.length - 1; + let pguard = 0; + while (pos[pidx] && occupied.has(Math.floor(pos[pidx].x) + ',' + Math.floor(pos[pidx].y)) && pguard < pos.length) { pidx = (pidx + 1) % pos.length; pguard++; } + const pt = pos[pidx] != null ? pos[pidx] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, socket.id), + suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), + botSlotCount: effectiveBotSlotCount(space), + lobbyBotThemes: syncLobbyBotThemeSlots(space), + joinLocked: !!space.joinLocked, + missionLiveBeginsAt: space.missionLiveBeginsAt || null, + }; + if (serverMapIsPostCaseLobbyB(space)) { + joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null; + joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null; + } + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + /* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */ + const sqIcon = specialQuizIconPayload(space); + if (sqIcon) joinCb.specialQuizIcon = sqIcon; + if (typeof cb === 'function') cb(joinCb); + /* กันค้างตอน reconnect: event "จบมินิเกม" (balloon-boss-over/stack-over/space-shooter-over) + host ยิงครั้งเดียวผ่าน socket.to — ถ้า client หลุดตอนนั้นจะค้างถาวร (ไม่มี replay). + เก็บ over ล่าสุดไว้ → resend ให้ socket ที่เพิ่ง (re)join ภายใน 45s ถ้ายังอยู่ในแมปมินิเกม + (gameType !== 'lobby' = ยังไม่ทรานซิชันกลับ). delay เล็กน้อยให้ client applyMapAndStart เสร็จก่อน */ + const lmo = space.lastMinigameOver; + if (lmo && lmo.event && (Date.now() - lmo.atMs) < 45000 && mapDataOut && mapDataOut.gameType !== 'lobby') { + const targetSid = socket.id; + setTimeout(() => { + try { + const sp = spaces.get(spaceId); + if (sp && sp.lastMinigameOver === lmo && io.sockets.sockets.get(targetSid)) { + io.to(targetSid).emit(lmo.event, lmo.payload); + } + } catch (_e) { /* ignore */ } + }, 400); + } + if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */ + emitLobbyTintSync(spaceId, space); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + /* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที + (คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */ + if (serverMapIsPostCaseLobbyB(space)) { + /* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */ + emitDetectiveLobbyBPresence(spaceId, space); + console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id); + if (space.pendingBanVoteCard && !space.pickVote) { + const banCard = space.pendingBanVoteCard; + space.pendingBanVoteCard = null; + /* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */ + setTimeout(() => { + const sp = spaces.get(spaceId); + if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) { + console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp))); + return; + } + console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')'); + io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' }); + startPlayerPickVote(spaceId, sp, 'ban', (targetId) => { + const sp2 = spaces.get(spaceId); + console.log('[ban-debug] ban vote RESOLVED target=', targetId); + if (sp2) sp2.bannedPlayerId = targetId || null; + }); + }, 2000); + } else if (space.pickVote && !space.pickVote.resolved) { + const pv = space.pickVote; + socket.emit('player-pick-vote-open', { + purpose: pv.purpose, + candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), + endsAt: pv.endsAt, + durationMs: pickVoteMs(), + }); + } + } + }); + + /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ + /** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */ + socket.on('special-quiz-force', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + space.forceSpecialQuiz = !!(data && data.on); + console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id); + }); + + /** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ + socket.on('special-quiz-testmode', (data) => { + socket.data.testMode = !!(data && data.on); + }); + + /** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */ + socket.on('test-force-wrong-vote', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return; + space.testForceWrongVote = true; + console.log('[test] force-wrong-vote armed by', socket.id); + }); + + /** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */ + socket.on('special-quiz-arm', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return; + sq.armScheduled = true; + scheduleSpecialQuizIconAppearAndExpiry(sid, space); + }); + + socket.on('special-quiz-collide', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sq = space.specialQuiz; + if (!sq || sq.triggered || sq.done) return reply({ ok: false }); + const started = startSpecialQuizSession(sid, space); + reply({ ok: !!started }); + }); + + /** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */ + socket.on('special-quiz-answer', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const sess = space.specialQuiz && space.specialQuiz.session; + if (!sess || sess.resolving) return reply({ ok: false }); + const q = sess.questions[sess.qIndex]; + if (!q) return reply({ ok: false }); + /* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */ + let choice; + if (data && data.debugCorrect && socket.data.testMode) { + choice = q.correctIndex; + } else { + choice = Number(data && data.choiceIndex); + } + if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) { + return reply({ ok: false }); + } + if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' }); + sess.answers[socket.id] = choice; + reply({ ok: true }); + /* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */ + io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true }); + /* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ + maybeResolveSpecialQuizAllAnswered(sid, space); + }); + + /** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */ + socket.on('special-quiz-continue', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + /* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */ + if (!space.specialQuizAwaitContinue) return reply({ ok: true }); + /* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */ + if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set(); + space.specialQuizContinueAcks.add(socket.id); + reply({ ok: true }); + maybeFinishSpecialQuizContinue(sid, space); + }); + + /** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */ + socket.on('lobby-tint-update', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + const peer = space && space.peers.get(socket.id); + if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + let themeIndex = Math.floor(Number(data && data.themeIndex)); + let skinIndex = Math.floor(Number(data && data.skinToneIndex)); + if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) { + return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' }); + } + if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) { + skinIndex = peer.lobbySkinToneIndex || 1; + } + /* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join) + กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */ + let humanTaken = false; + space.peers.forEach((op, oid) => { + if (oid === socket.id) return; + if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true; + }); + if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); + if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { + Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { + const slot = space.lobbyBotThemeBySlot[k]; + if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */ + }); + } + peer.lobbyColorThemeIndex = themeIndex; + peer.lobbySkinToneIndex = skinIndex; + emitLobbyTintSync(sid, space); + reply({ + ok: true, + lobbyColorThemeIndex: themeIndex, + lobbySkinToneIndex: skinIndex, + lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], + }); + }); + + /* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */ + /* DEPRECATED: server เป็นเจ้าของบอท lobby เองแล้ว (stepLobbyBotsForSpace/lobbyBotSweepTick) + → ไม่ relay ตำแหน่งบอทจาก client อีก (กัน host เวอร์ชันเก่าฉีดตำแหน่งชนกับ server tick = บอทกระตุก/2 แหล่ง) */ + socket.on('lobby-bot-move', () => { /* no-op: server-authoritative */ }); + + /* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง + (กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */ + socket.on('fill-bot-state', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ + if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; + /* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host" + แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */ + const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined); + socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans }); + }); + + /* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host) + payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */ + socket.on('balloon-boss-shot', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (isSpecialQuizPausedServer(space)) return; /* คำถามพิเศษ → ไม่ relay กระสุน (กันค้างยิงรัวหลังตอบ) */ + const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); + if (!ok) return; + socket.to(sid).emit('balloon-boss-shot', { + sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy, + ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), + }); + }); + + /* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */ + socket.on('balloon-boss-over', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + const reason = (data.reason === 'victory' || data.reason === 'all_dead' || data.reason === 'time') ? data.reason : 'all_dead'; + const scores = Array.isArray(data.scores) + ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated })) + : []; + const overPayload = { reason, scores }; + socket.to(sid).emit('balloon-boss-over', overPayload); + /* เก็บไว้ resend ให้คน reconnect (host ยิงครั้งเดียว — หลุดตอนนั้นจะค้างถาวร) */ + space.lastMinigameOver = { event: 'balloon-boss-over', payload: overPayload, atMs: Date.now() }; + }); + + /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ + → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ + socket.on('balloon-boss-sync', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ + const bossDmg = Math.max(0, Number(data.bossDmg) || 0); + const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */ + const players = Array.isArray(data.players) + ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ + id: p.id, + d: Math.max(0, Number(p.d) || 0), + b: Math.max(0, Number(p.b) || 0), + e: !!p.e, + })) + : []; + socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor }); + }); + + /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ + socket.on('stack-drop', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: client ไม่เป็นเจ้าของดรอป */ + const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; + const themeOf = (v) => { const n = Math.floor(Number(v)); return (n >= 1 && n <= 8) ? n : null; }; + socket.to(sid).emit('stack-drop', { + placedCx: num(data.placedCx), placedW: num(data.placedW), + miss: !!data.miss, perfect: !!data.perfect, + pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), + overlap: num(data.overlap), + supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), + delta: num(data.delta), + seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, + theme: themeOf(data.theme), /* สี block ที่วาง (host เป็นเจ้าของ) — เดิมถูกตัดทิ้ง → client freeze สีตัวเอง = เพี้ยน */ + actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, + }); + }); + /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ + socket.on('quiz-end-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ + if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'quiz') return; + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); + }); + + /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ + socket.on('stack-swing', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !data) return; + if (space.stackSession && space.stackSession.active) return; /* server-auth: server แกว่งเอง */ + const ph = Number(data.phase); + if (!Number.isFinite(ph)) return; + socket.to(sid).emit('stack-swing', { phase: ph }); + }); + + /* MG3 stack (server-auth) — รับ "เจตนากดวาง" จากผู้เล่นจริง → server คำนวณ ณ เฟสปัจจุบัน + emit ผลให้ทุกเครื่อง */ + socket.on('stack-drop-req', (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); + /* [DBG-MG3drop] เทียบ client-sent phase vs s.phase + จุดลง/รองรับ/overlap/miss/หัวใจ (ถอดออกหลังวินิจฉัย) */ + try { + const supportCx = hit.placedCx - (hit.delta || 0); + glog(sid, space, 'mg3-drop', 'HUMAN cph=' + (Number.isFinite(cph) ? cph.toFixed(3) : 'x') + + ' sPhase=' + s.phase.toFixed(3) + ' used=' + dropPhase.toFixed(3) + (dropPhase === cph ? '(client)' : '(server)') + + ' amp=' + s.swingAmp.toFixed(3) + ' topCx=' + s.topCenterX.toFixed(3) + + ' placedCx=' + hit.placedCx.toFixed(3) + ' supportCx=' + supportCx.toFixed(3) + + ' overlap=' + (hit.overlap != null ? hit.overlap.toFixed(3) : '?') + ' miss=' + hit.miss + ' perfect=' + hit.perfect + + ' layers=' + s.layers.length + ' hearts=' + s.teamMissesLeft); + } catch (e) { /* ignore */ } + 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 + ')', + }); + } + 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; + emitTrialVoteUpdate(sid, space); + const totalTV = trialEligibleVoterTotal(space); + if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + + /* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */ + socket.on('player-pick-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const pv = space.pickVote; + if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' }); + const tid = String((data && data.targetId) || ''); + if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' }); + pv.votes[socket.id] = tid; + reply({ ok: true, targetId: tid }); + emitPickVoteUpdate(sid, space); + maybeResolvePickVote(sid, space); + }); + + /** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */ + socket.on('host-console-apply', (data, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const humans = space.peers.size; + let total = parseInt(data && data.totalSlots, 10); + if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space); + total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total)); + let bots = parseInt(data && data.botSlotCount, 10); + if (!Number.isFinite(bots)) bots = total - humans; + bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots)); + total = humans + bots; + space.botSlotCount = bots; + space.maxPlayers = Math.max(humans, total - bots); + const lobbyBotThemes = syncLobbyBotThemeSlots(space); + io.to(sid).emit('host-console-settings', { + maxPlayers: space.maxPlayers, + botSlotCount: space.botSlotCount, + totalSlots: total, + lobbyBotThemes, + }); + emitLobbyTintSync(sid, space); + cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes }); + }); + + socket.on('host-console-kick-peer', ({ targetId }, cb) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || space.hostId !== socket.id) { + return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); + } + const tid = targetId != null ? String(targetId) : ''; + if (!tid || tid === socket.id || tid === space.hostId) { + return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' }); + } + if (!space.peers.has(tid)) { + return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' }); + } + const targetSock = io.sockets.sockets.get(tid); + if (targetSock) { + targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' }); + targetSock.leave(sid); + delete targetSock.data.spaceId; + } + space.peers.delete(tid); + io.to(sid).emit('user-left', { id: tid }); + cb && cb({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + /* ล็อก: 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; + } + } + }); + + 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(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.troublesomeAccept = false; + space.disruptorPlayerKey = ''; + space.disruptorNickname = ''; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + space.detectiveLobbyLevel = Number(lobbyLevel) || 1; + space.detectiveLobbyCaseId = Number(caseId) || 1; + space.caseId = Number(caseId) || 1; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + space.peers.forEach((_p, pid) => { + const sock = io.sockets.sockets.get(pid); + if (!sock) return; + sock.emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + myPlayerEvidence: clonePlayerEvidence(space, pid), + suspectProgress: playerSuspectProgressFromEvidence(space, pid), + }); + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space) + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + /* เก็บข้อมูลก่อนลบ peer — ใช้ทำ grace 10 วิ (รอ reconnect) + bot takeover ระหว่างมินิเกม */ + const leftPeerInfo = space.peers.get(socket.id) || {}; + const leftPlayerKey = (typeof leftPeerInfo.playerKey === 'string') ? leftPeerInfo.playerKey : ''; + const leftNick = (leftPeerInfo.nickname || '').trim() || 'ผู้เล่น'; + /* คนที่หลุดตาย/ตกรอบไปแล้วหรือยัง (ตอนหลุด) — ใช้ตัดสินว่า bot ที่มาแทนควร spawn ใหม่ไหม (คนตายแล้วไม่ควรฟื้น) */ + let leftWasEliminated = false; + try { + const md8 = (space.mapId && maps.get(space.mapId)) || space.mapData; + const gt8 = md8 && md8.gameType; + leftWasEliminated = minigamePlayerScoreSurvived(space, leftPeerInfo, gt8).survived === false; + } catch (e) { /* ignore */ } + /* สี (ธีม/สกิน) ของคนที่ออก — ใช้ให้ bot ที่มาแทนใช้สีเดียวกัน (b) */ + const leftThemeIndex = parseInt(leftPeerInfo.lobbyColorThemeIndex, 10); + const leftSkinIndex = parseInt(leftPeerInfo.lobbySkinToneIndex, 10); + /* MG1 (quiz live): หลุด → แปลง entity เดิม (socket.id) เป็นบอท server "ในเกมทันที" + เก็บ avatar เดิมไว้ = สี+ตำแหน่งเดิมอัตโนมัติ + inherit คะแนน/สถานะตกรอบ (ผีถ้าตอบผิด) */ + let mg1Takeover = false; + try { + const md9 = (space.mapId && maps.get(space.mapId)) || space.mapData; + const sessT = space.quizSession; + if (sessT && sessT.active && sessT.bots && md9 && md9.gameType === 'quiz') { + const st = (sessT.players && sessT.players[socket.id]) || {}; + const px = Number(leftPeerInfo.x), py = Number(leftPeerInfo.y); + sessT.bots.set(socket.id, { + tier: quizBotTierForSlot(sessT.bots.size), + x: Number.isFinite(px) ? px : 1, y: Number.isFinite(py) ? py : 1, + dir: leftPeerInfo.direction || 'down', isWalking: false, + score: Math.max(0, Number(st.score) || 0), + eliminated: !!(st.cannotTrue && st.cannotFalse), + choice: null, answerRight: false, tx: null, ty: null, wanderTX: null, wanderTY: null, retargetAt: 0, + takeover: true, takeoverPlayerKey: leftPlayerKey || '', + }); + mg1Takeover = true; + } + } catch (e) { /* ignore */ } + /* MG2 (gauntlet live): หลุดกลาง run → แปลง peer เป็นบอท server "ในที่เดิม" (ตำแหน่ง/คะแนน/สถานะเดิม) + เก็บ avatar เดิมไว้ + เล่นต่อเป็นบอท (host หลุดก็ไม่หยุด race) */ + let mg2Takeover = false; + try { + const md9b = (space.mapId && maps.get(space.mapId)) || space.mapData; + const grT = space.gauntletRun; + /* takeover ตราบที่ run ยังมีอยู่ — รวมช่วง 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 */ } + 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/Main-Lobby/index.html b/www/html/Main-Lobby/index.html index 6511b0e..237ef1a 100644 --- a/www/html/Main-Lobby/index.html +++ b/www/html/Main-Lobby/index.html @@ -230,7 +230,16 @@ - + + + diff --git a/www/html/gamelog/data/log.jsonl b/www/html/gamelog/data/log.jsonl index 0e6ad28..71c261a 100644 --- a/www/html/gamelog/data/log.jsonl +++ b/www/html/gamelog/data/log.jsonl @@ -3808,3 +3808,511 @@ {"t":1782916711791,"cat":"LobbyB","room":"a-1782916565382","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:24.0,16.4->18.2,13.9 b1:13.6,5.2->15.7,13.5 b2:12.0,13.4->14.6,10.8] [+58532ms]","src":"client"} {"t":1782916715835,"cat":"LobbyB","room":"a-1782916565382","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:24.0,16.4->11.8,16.3 b1:13.6,5.2->15.3,13.5 b2:12.0,13.4->14.6,9.1] [+62575ms]","src":"client"} {"t":1782916719874,"cat":"LobbyB","room":"a-1782916565382","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:24.0,16.4->10.9,16.8 b1:13.6,5.2->20.4,16.9 b2:12.0,13.4->19.3,10.6] [+66616ms]","src":"client"} +{"t":1782919071786,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"lobby-open","detail":"space=a-1782919068596 [+0ms]","src":"client"} +{"t":1782919071833,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+20ms]","src":"client"} +{"t":1782919071852,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"preload-start","detail":" [+22ms]","src":"client"} +{"t":1782919071902,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"manifest-done","detail":" [+113ms]","src":"client"} +{"t":1782919071911,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"colors-resolved","detail":" [+114ms]","src":"client"} +{"t":1782919071942,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"socket-connect","detail":" [+153ms]","src":"client"} +{"t":1782919071945,"cat":"LobbyA","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782919072616,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+802ms]","src":"client"} +{"t":1782919072657,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"preload-occupants-done","detail":" [+870ms]","src":"client"} +{"t":1782919072661,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+871ms]","src":"client"} +{"t":1782919072707,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=3 [+901ms]","src":"client"} +{"t":1782919072744,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+901ms]","src":"client"} +{"t":1782919072808,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=a-1782919068596 mapName=LobbyA [+922ms]","src":"client"} +{"t":1782919072813,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,14.0->10.7,13.5 b1:16.0,14.0->15.3,13.5 b2:22.0,13.0->21.2,13.4] [+926ms]","src":"client"} +{"t":1782919076823,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:20.8,6.6->21.3,6.3 b1:8.3,5.9->8.8,5.6 b2:22.4,8.9->22.7,8.4] [+4954ms]","src":"client"} +{"t":1782919079658,"cat":"LobbyA","room":"a-1782919068596","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+7872ms]","src":"client"} +{"t":1782919079725,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+7936ms]","src":"client"} +{"t":1782919080227,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+8438ms]","src":"client"} +{"t":1782919080831,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:26.9,16.7->27.1,16.7 b1:7.4,12.3->7.4,12.3 b2:17.6,5.2->17.6,5.2] [+9029ms]","src":"client"} +{"t":1782919084191,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+12408ms]","src":"client"} +{"t":1782919084997,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:23.6,15.4->23.0,15.2 b1:15.4,13.3->16.0,13.3 b2:16.5,5.0->16.5,5.0] [+13216ms]","src":"client"} +{"t":1782919089041,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->12.9,9.4 b1:26.2,15.1->19.2,15.9 b2:14.1,12.1->8.0,4.2] [+17257ms]","src":"client"} +{"t":1782919093067,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->16.4,13.4 b1:26.2,15.1->14.8,12.9 b2:14.1,12.1->8.0,6.3] [+21289ms]","src":"client"} +{"t":1782919097110,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->16.3,13.1 b1:26.2,15.1->7.4,16.9 b2:14.1,12.1->8.7,10.0] [+25329ms]","src":"client"} +{"t":1782919101148,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->27.9,10.4 b1:26.2,15.1->5.4,15.1 b2:14.1,12.1->8.6,4.0] [+29367ms]","src":"client"} +{"t":1782919105185,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->26.5,11.9 b1:26.2,15.1->18.3,13.3 b2:14.1,12.1->8.6,4.0] [+33407ms]","src":"client"} +{"t":1782919109225,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->11.2,8.5 b1:26.2,15.1->5.4,16.8 b2:14.1,12.1->9.7,5.2] [+37444ms]","src":"client"} +{"t":1782919113264,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->12.2,8.5 b1:26.2,15.1->14.8,9.7 b2:14.1,12.1->27.9,14.9] [+41483ms]","src":"client"} +{"t":1782919117292,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->12.4,5.1 b1:26.2,15.1->19.4,13.5 b2:14.1,12.1->27.9,16.7] [+45514ms]","src":"client"} +{"t":1782919121330,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->9.7,6.0 b1:26.2,15.1->24.5,5.3 b2:14.1,12.1->19.1,10.7] [+49549ms]","src":"client"} +{"t":1782919125402,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->19.7,9.5 b1:26.2,15.1->8.3,7.7 b2:14.1,12.1->27.8,5.0] [+53623ms]","src":"client"} +{"t":1782919129459,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->28.0,5.2 b1:26.2,15.1->14.8,7.3 b2:14.1,12.1->28.9,12.2] [+57678ms]","src":"client"} +{"t":1782919133495,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.6,9.9->19.2,10.7 b1:26.2,15.1->9.7,5.8 b2:14.1,12.1->15.7,8.5] [+61715ms]","src":"client"} +{"t":1782919137529,"cat":"LobbyB","room":"a-1782919068596","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:19.2,9.1->18.6,9.0 b1:20.5,9.5->19.5,9.6 b2:10.4,12.2->9.6,12.8] [+65752ms]","src":"client"} +{"t":1782919145875,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"lobby-open","detail":"space=s-1782919142863 [+0ms]","src":"client"} +{"t":1782919145925,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+39ms]","src":"client"} +{"t":1782919145930,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"preload-start","detail":" [+40ms]","src":"client"} +{"t":1782919145995,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"manifest-done","detail":" [+138ms]","src":"client"} +{"t":1782919146007,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"colors-resolved","detail":" [+138ms]","src":"client"} +{"t":1782919146074,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"socket-connect","detail":" [+184ms]","src":"client"} +{"t":1782919146051,"cat":"LobbyA","room":"s","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782919146719,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=3 [+862ms]","src":"client"} +{"t":1782919146740,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=s-1782919142863 mapName=LobbyA [+884ms]","src":"client"} +{"t":1782919147403,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=true [+1542ms]","src":"client"} +{"t":1782919147471,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,14.0->10.1,13.3 b1:16.0,14.0->15.3,13.7 b2:22.0,13.0->22.0,13.7] [+1612ms]","src":"client"} +{"t":1782919147843,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"preload-occupants-done","detail":" [+1978ms]","src":"client"} +{"t":1782919147845,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+1979ms]","src":"client"} +{"t":1782919147848,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+1979ms]","src":"client"} +{"t":1782919153632,"cat":"LobbyA","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:11.7,4.2->11.7,4.2 b1:12.8,12.4->13.4,12.2 b2:28.0,13.4->28.0,13.3] [+7771ms]","src":"client"} +{"t":1782919154196,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+8333ms]","src":"client"} +{"t":1782919154260,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+8399ms]","src":"client"} +{"t":1782919154690,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+8828ms]","src":"client"} +{"t":1782919157683,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:26.9,16.9->26.9,16.9 b1:11.9,14.4->12.8,14.1 b2:25.4,16.8->24.8,16.4] [+11824ms]","src":"client"} +{"t":1782919161716,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->28.5,12.5 b1:27.5,12.0->27.5,12.0 b2:8.1,5.6->8.1,5.6] [+15856ms]","src":"client"} +{"t":1782919165753,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->23.5,6.2 b1:27.5,12.0->27.5,13.8 b2:13.6,8.4->28.0,16.1] [+19894ms]","src":"client"} +{"t":1782919169785,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->20.6,7.6 b1:27.5,12.0->17.9,8.7 b2:13.6,8.4->16.3,17.0] [+23927ms]","src":"client"} +{"t":1782919173828,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->9.2,13.0 b1:27.5,12.0->12.4,16.6 b2:13.6,8.4->13.0,17.0] [+27968ms]","src":"client"} +{"t":1782919177862,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->23.5,7.1 b1:27.5,12.0->13.4,15.9 b2:13.6,8.4->17.7,13.1] [+32002ms]","src":"client"} +{"t":1782919181898,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->24.3,11.3 b1:27.5,12.0->15.2,16.8 b2:13.6,8.4->23.3,15.2] [+36038ms]","src":"client"} +{"t":1782919185928,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->7.3,15.0 b1:27.5,12.0->14.9,11.8 b2:13.6,8.4->17.3,13.0] [+40070ms]","src":"client"} +{"t":1782919189971,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->6.2,15.3 b1:27.5,12.0->23.5,14.2 b2:13.6,8.4->26.6,10.1] [+44116ms]","src":"client"} +{"t":1782919193997,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->8.0,4.5 b1:27.5,12.0->27.7,15.7 b2:13.6,8.4->15.7,7.9] [+48137ms]","src":"client"} +{"t":1782919198029,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->15.6,6.3 b1:27.5,12.0->7.0,15.7 b2:13.6,8.4->20.1,11.1] [+52172ms]","src":"client"} +{"t":1782919202073,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->15.6,9.0 b1:27.5,12.0->7.7,10.4 b2:13.6,8.4->13.5,5.8] [+56214ms]","src":"client"} +{"t":1782919206116,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->27.5,9.7 b1:27.5,12.0->26.7,13.0 b2:13.6,8.4->24.2,9.6] [+60257ms]","src":"client"} +{"t":1782919210142,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->24.8,16.9 b1:27.5,12.0->7.2,14.4 b2:13.6,8.4->28.9,9.0] [+64283ms]","src":"client"} +{"t":1782919214180,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->23.7,10.4 b1:27.5,12.0->19.1,6.8 b2:13.6,8.4->19.9,12.6] [+68321ms]","src":"client"} +{"t":1782919218212,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->5.1,16.2 b1:27.5,12.0->28.4,12.7 b2:13.6,8.4->11.6,6.0] [+72357ms]","src":"client"} +{"t":1782919222258,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->25.7,6.3 b1:27.5,12.0->12.9,16.9 b2:13.6,8.4->29.0,12.7] [+76396ms]","src":"client"} +{"t":1782919226297,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->27.8,5.3 b1:27.5,12.0->12.1,16.5 b2:13.6,8.4->11.5,14.8] [+80437ms]","src":"client"} +{"t":1782919230355,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->24.0,5.3 b1:27.5,12.0->28.6,8.7 b2:13.6,8.4->28.0,10.3] [+84492ms]","src":"client"} +{"t":1782919234375,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->11.3,6.3 b1:27.5,12.0->20.6,13.2 b2:13.6,8.4->15.4,7.3] [+88513ms]","src":"client"} +{"t":1782919238409,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->12.9,7.6 b1:27.5,12.0->18.6,14.3 b2:13.6,8.4->27.3,12.8] [+92552ms]","src":"client"} +{"t":1782919242448,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->15.6,7.0 b1:27.5,12.0->27.6,9.2 b2:13.6,8.4->13.5,16.9] [+96587ms]","src":"client"} +{"t":1782919246483,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->22.5,7.2 b1:27.5,12.0->23.5,9.1 b2:13.6,8.4->13.7,16.9] [+100623ms]","src":"client"} +{"t":1782919250517,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->28.3,12.9 b1:27.5,12.0->19.2,11.7 b2:13.6,8.4->28.9,8.3] [+104662ms]","src":"client"} +{"t":1782919254557,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->24.1,7.3 b1:27.5,12.0->26.6,10.0 b2:13.6,8.4->28.9,7.6] [+108697ms]","src":"client"} +{"t":1782919258591,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->18.4,8.4 b1:27.5,12.0->28.0,9.5 b2:13.6,8.4->15.4,6.2] [+112731ms]","src":"client"} +{"t":1782919262627,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->7.1,14.0 b1:27.5,12.0->14.9,13.9 b2:13.6,8.4->28.7,8.8] [+116767ms]","src":"client"} +{"t":1782919266659,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->19.4,17.0 b1:27.5,12.0->14.3,9.7 b2:13.6,8.4->26.6,7.3] [+120800ms]","src":"client"} +{"t":1782919270697,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->18.4,14.5 b1:27.5,12.0->17.9,7.8 b2:13.6,8.4->24.7,12.2] [+124840ms]","src":"client"} +{"t":1782919274738,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->14.8,6.4 b1:27.5,12.0->8.3,6.7 b2:13.6,8.4->27.9,11.5] [+128877ms]","src":"client"} +{"t":1782919278771,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->25.5,15.3 b1:27.5,12.0->8.3,5.6 b2:13.6,8.4->25.2,11.6] [+132909ms]","src":"client"} +{"t":1782919282889,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->11.5,7.4 b1:27.5,12.0->10.0,5.4 b2:13.6,8.4->24.7,14.5] [+137033ms]","src":"client"} +{"t":1782919286900,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->21.6,7.8 b1:27.5,12.0->12.5,8.6 b2:13.6,8.4->19.3,13.8] [+141039ms]","src":"client"} +{"t":1782919290932,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->28.9,8.8 b1:27.5,12.0->26.4,9.1 b2:13.6,8.4->28.0,14.4] [+145074ms]","src":"client"} +{"t":1782919294963,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->27.7,12.6 b1:27.5,12.0->15.4,16.8 b2:13.6,8.4->13.4,16.8] [+149103ms]","src":"client"} +{"t":1782919298998,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->26.1,11.8 b1:27.5,12.0->9.0,4.3 b2:13.6,8.4->7.0,13.4] [+153139ms]","src":"client"} +{"t":1782919303033,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->14.7,8.1 b1:27.5,12.0->9.5,5.4 b2:13.6,8.4->15.7,6.3] [+157173ms]","src":"client"} +{"t":1782919307066,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->7.1,12.7 b1:27.5,12.0->16.5,6.6 b2:13.6,8.4->8.0,6.8] [+161206ms]","src":"client"} +{"t":1782919311099,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->23.2,15.9 b1:27.5,12.0->23.2,13.5 b2:13.6,8.4->7.9,10.6] [+165240ms]","src":"client"} +{"t":1782919315131,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->12.8,5.4 b1:27.5,12.0->18.7,13.2 b2:13.6,8.4->18.6,8.2] [+169277ms]","src":"client"} +{"t":1782919319175,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->5.4,9.1 b1:27.5,12.0->25.3,4.5 b2:13.6,8.4->10.4,6.5] [+173318ms]","src":"client"} +{"t":1782919323214,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->22.0,8.8 b1:27.5,12.0->19.4,8.4 b2:13.6,8.4->10.9,6.5] [+177354ms]","src":"client"} +{"t":1782919327250,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->27.8,10.9 b1:27.5,12.0->28.9,12.7 b2:13.6,8.4->10.9,6.5] [+181391ms]","src":"client"} +{"t":1782919331284,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->11.8,16.0 b1:27.5,12.0->22.6,16.7 b2:13.6,8.4->19.8,13.1] [+185424ms]","src":"client"} +{"t":1782919335311,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->23.2,8.9 b1:27.5,12.0->22.5,16.7 b2:13.6,8.4->26.7,5.4] [+189452ms]","src":"client"} +{"t":1782919339345,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->17.5,13.2 b1:27.5,12.0->6.3,9.1 b2:13.6,8.4->21.6,14.6] [+193491ms]","src":"client"} +{"t":1782919343380,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,12.5->18.2,13.2 b1:27.5,12.0->14.4,8.7 b2:13.6,8.4->15.7,6.0] [+197526ms]","src":"client"} +{"t":1782919347418,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.9,4.4->25.4,4.1 b1:28.5,7.9->19.4,9.8 b2:18.4,8.9->19.2,8.9] [+201563ms]","src":"client"} +{"t":1782919351454,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->25.4,4.2 b1:26.6,7.9->20.5,9.9 b2:19.1,9.5->19.1,9.5] [+205596ms]","src":"client"} +{"t":1782919355491,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->15.1,13.0 b1:26.6,7.9->15.7,6.4 b2:19.1,9.5->22.9,14.1] [+209634ms]","src":"client"} +{"t":1782919359519,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->7.0,10.6 b1:26.6,7.9->5.5,16.0 b2:19.1,9.5->16.7,16.8] [+213663ms]","src":"client"} +{"t":1782919363558,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->7.7,16.5 b1:26.6,7.9->14.6,11.2 b2:19.1,9.5->9.7,16.9] [+217701ms]","src":"client"} +{"t":1782919367591,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->25.6,3.9 b1:26.6,7.9->23.4,16.8 b2:19.1,9.5->13.7,5.5] [+221733ms]","src":"client"} +{"t":1782919371623,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->19.8,13.9 b1:26.6,7.9->15.8,13.2 b2:19.1,9.5->19.4,15.4] [+225764ms]","src":"client"} +{"t":1782919375652,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->11.5,16.7 b1:26.6,7.9->23.0,10.0 b2:19.1,9.5->18.8,6.5] [+229794ms]","src":"client"} +{"t":1782919379681,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->11.6,16.7 b1:26.6,7.9->20.9,14.4 b2:19.1,9.5->13.3,15.1] [+233823ms]","src":"client"} +{"t":1782919383716,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->11.2,16.7 b1:26.6,7.9->23.8,9.1 b2:19.1,9.5->12.6,16.7] [+237858ms]","src":"client"} +{"t":1782919387756,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->22.1,16.7 b1:26.6,7.9->6.6,15.7 b2:19.1,9.5->6.9,9.2] [+241897ms]","src":"client"} +{"t":1782919391981,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->7.4,11.9 b1:26.6,7.9->20.9,9.0 b2:19.1,9.5->8.1,13.7] [+246122ms]","src":"client"} +{"t":1782919396004,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->9.9,12.9 b1:26.6,7.9->8.2,8.8 b2:19.1,9.5->21.8,6.4] [+250146ms]","src":"client"} +{"t":1782919400051,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->9.8,16.9 b1:26.6,7.9->15.1,7.6 b2:19.1,9.5->23.9,13.8] [+254193ms]","src":"client"} +{"t":1782919404076,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->7.5,14.4 b1:26.6,7.9->8.0,7.6 b2:19.1,9.5->19.5,6.2] [+258216ms]","src":"client"} +{"t":1782919408106,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->16.1,14.8 b1:26.6,7.9->9.3,8.6 b2:19.1,9.5->15.9,8.8] [+262249ms]","src":"client"} +{"t":1782919412142,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->12.3,5.4 b1:26.6,7.9->13.2,16.7 b2:19.1,9.5->8.1,4.9] [+266283ms]","src":"client"} +{"t":1782919416176,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->27.8,10.7 b1:26.6,7.9->7.8,14.9 b2:19.1,9.5->13.8,8.9] [+270317ms]","src":"client"} +{"t":1782919420217,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->28.7,12.4 b1:26.6,7.9->16.0,8.8 b2:19.1,9.5->25.3,17.0] [+274359ms]","src":"client"} +{"t":1782919424252,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->28.9,13.0 b1:26.6,7.9->15.9,8.8 b2:19.1,9.5->23.2,10.9] [+278392ms]","src":"client"} +{"t":1782919428283,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->22.7,16.9 b1:26.6,7.9->14.2,6.2 b2:19.1,9.5->25.6,12.6] [+282419ms]","src":"client"} +{"t":1782919432328,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->21.9,16.9 b1:26.6,7.9->8.7,9.7 b2:19.1,9.5->7.3,11.0] [+286470ms]","src":"client"} +{"t":1782919436364,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->22.7,12.0 b1:26.6,7.9->17.2,6.4 b2:19.1,9.5->22.5,6.1] [+290506ms]","src":"client"} +{"t":1782919440404,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->8.2,5.8 b1:26.6,7.9->16.3,5.7 b2:19.1,9.5->27.2,5.0] [+294546ms]","src":"client"} +{"t":1782919444449,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->19.7,16.5 b1:26.6,7.9->17.8,5.8 b2:19.1,9.5->20.9,16.9] [+298586ms]","src":"client"} +{"t":1782919448481,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->19.5,6.1 b1:26.6,7.9->21.7,10.3 b2:19.1,9.5->20.9,16.9] [+302624ms]","src":"client"} +{"t":1782919452509,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->17.5,7.3 b1:26.6,7.9->8.3,16.8 b2:19.1,9.5->12.5,5.4] [+306654ms]","src":"client"} +{"t":1782919456541,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->17.9,5.5 b1:26.6,7.9->10.6,16.8 b2:19.1,9.5->24.3,11.3] [+310687ms]","src":"client"} +{"t":1782919460575,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->28.8,6.2 b1:26.6,7.9->13.1,12.7 b2:19.1,9.5->19.5,12.7] [+314719ms]","src":"client"} +{"t":1782919464614,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->21.8,7.2 b1:26.6,7.9->14.7,11.9 b2:19.1,9.5->11.0,16.8] [+318762ms]","src":"client"} +{"t":1782919468650,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->26.1,14.5 b1:26.6,7.9->14.6,11.3 b2:19.1,9.5->11.9,6.5] [+322794ms]","src":"client"} +{"t":1782919472685,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->11.1,17.0 b1:26.6,7.9->20.4,14.0 b2:19.1,9.5->12.6,16.6] [+326829ms]","src":"client"} +{"t":1782919476720,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->7.1,11.0 b1:26.6,7.9->20.6,16.8 b2:19.1,9.5->7.0,9.8] [+330863ms]","src":"client"} +{"t":1782919480874,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.4,4.2->15.3,16.8 b1:26.6,7.9->22.3,15.5 b2:19.1,9.5->21.4,11.2] [+335020ms]","src":"client"} +{"t":1782919484905,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:26.6,6.0->25.7,6.5 b1:14.5,8.2->13.4,8.0 b2:27.5,16.9->27.5,16.9] [+339049ms]","src":"client"} +{"t":1782919488943,"cat":"LobbyB","room":"s-1782919142863","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:19.2,11.3->19.2,11.9 b1:8.1,6.6->20.7,5.5 b2:27.5,16.9->21.7,5.0] [+343086ms]","src":"client"} +{"t":1782922304648,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"lobby-open","detail":"space=a-1782922301574 [+1ms]","src":"client"} +{"t":1782922304692,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"preload-start","detail":" [+34ms]","src":"client"} +{"t":1782922304692,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+32ms]","src":"client"} +{"t":1782922304791,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"manifest-done","detail":" [+134ms]","src":"client"} +{"t":1782922304793,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"colors-resolved","detail":" [+135ms]","src":"client"} +{"t":1782922304804,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"socket-connect","detail":" [+162ms]","src":"client"} +{"t":1782922304809,"cat":"LobbyA","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782922305623,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=3 [+994ms]","src":"client"} +{"t":1782922305644,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=a-1782922301574 mapName=LobbyA [+1016ms]","src":"client"} +{"t":1782922305646,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,14.0->10.5,13.8 b1:16.0,14.0->16.2,13.5 b2:22.0,13.0->22.5,13.1] [+1021ms]","src":"client"} +{"t":1782922306027,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=true [+1400ms]","src":"client"} +{"t":1782922307000,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"preload-occupants-done","detail":" [+2374ms]","src":"client"} +{"t":1782922307001,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+2374ms]","src":"client"} +{"t":1782922307013,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+2374ms]","src":"client"} +{"t":1782922310702,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:15.7,9.8->16.2,9.8 b1:18.8,13.0->18.9,13.0 b2:24.6,13.7->24.6,13.7] [+6072ms]","src":"client"} +{"t":1782922314732,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.4,10.5->29.3,10.6 b1:20.8,5.5->20.8,5.5 b2:27.0,14.8->27.0,14.8] [+10102ms]","src":"client"} +{"t":1782922318769,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:20.1,11.9->20.1,11.9 b1:22.5,8.0->22.4,7.0 b2:22.3,13.6->22.3,13.6] [+14141ms]","src":"client"} +{"t":1782922322820,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.4,7.3->7.4,7.1 b1:21.0,8.6->20.7,9.5 b2:19.8,8.4->18.8,8.2] [+18180ms]","src":"client"} +{"t":1782922326837,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->21.5,12.0 b1:29.9,11.1->23.4,11.7 b2:12.7,11.6->14.8,10.6] [+22209ms]","src":"client"} +{"t":1782922330870,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.0,13.5 b1:29.9,11.1->20.2,12.1 b2:12.7,11.6->7.4,17.8] [+26241ms]","src":"client"} +{"t":1782922334909,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.0,14.0 b1:29.9,11.1->7.6,16.9 b2:12.7,11.6->21.5,14.4] [+30278ms]","src":"client"} +{"t":1782922338947,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->17.9,15.3 b1:29.9,11.1->22.8,17.0 b2:12.7,11.6->25.8,16.2] [+34318ms]","src":"client"} +{"t":1782922342984,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->13.8,12.1 b1:29.9,11.1->21.7,17.3 b2:12.7,11.6->27.0,13.4] [+38355ms]","src":"client"} +{"t":1782922347020,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->10.0,4.1 b1:29.9,11.1->6.4,10.2 b2:12.7,11.6->6.5,7.1] [+42388ms]","src":"client"} +{"t":1782922351046,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->23.3,6.7 b1:29.9,11.1->7.3,10.6 b2:12.7,11.6->11.1,13.8] [+46416ms]","src":"client"} +{"t":1782922355086,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->29.8,7.9 b1:29.9,11.1->12.7,10.6 b2:12.7,11.6->6.3,16.2] [+50453ms]","src":"client"} +{"t":1782922359123,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.0,4.3 b1:29.9,11.1->18.8,8.1 b2:12.7,11.6->16.9,8.8] [+54491ms]","src":"client"} +{"t":1782922363152,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.0,16.3 b1:29.9,11.1->15.0,14.5 b2:12.7,11.6->26.5,5.1] [+58521ms]","src":"client"} +{"t":1782922367188,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.1,5.4 b1:29.9,11.1->14.9,11.2 b2:12.7,11.6->16.3,4.0] [+62556ms]","src":"client"} +{"t":1782922371218,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->22.3,18.0 b1:29.9,11.1->13.5,6.3 b2:12.7,11.6->17.0,10.0] [+66586ms]","src":"client"} +{"t":1782922375261,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->22.3,18.0 b1:29.9,11.1->6.1,4.5 b2:12.7,11.6->11.0,6.4] [+70625ms]","src":"client"} +{"t":1782922379291,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->22.9,14.7 b1:29.9,11.1->29.8,7.7 b2:12.7,11.6->9.3,4.1] [+74659ms]","src":"client"} +{"t":1782922383319,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->13.7,8.5 b1:29.9,11.1->28.6,12.5 b2:12.7,11.6->28.3,8.9] [+78691ms]","src":"client"} +{"t":1782922387356,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->17.4,9.6 b1:29.9,11.1->28.6,12.5 b2:12.7,11.6->12.8,10.6] [+82724ms]","src":"client"} +{"t":1782922391391,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.0,8.4 b1:29.9,11.1->27.7,5.4 b2:12.7,11.6->10.7,6.2] [+86760ms]","src":"client"} +{"t":1782922395425,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.0,8.4 b1:29.9,11.1->18.4,4.0 b2:12.7,11.6->20.3,10.6] [+90793ms]","src":"client"} +{"t":1782922399460,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->16.7,5.6 b1:29.9,11.1->24.0,5.1 b2:12.7,11.6->17.6,9.6] [+94828ms]","src":"client"} +{"t":1782922403499,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->25.7,16.8 b1:29.9,11.1->20.1,11.6 b2:12.7,11.6->28.4,5.5] [+98866ms]","src":"client"} +{"t":1782922407526,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.0,9.9 b1:29.9,11.1->16.4,13.6 b2:12.7,11.6->21.6,13.7] [+102894ms]","src":"client"} +{"t":1782922411558,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->10.3,17.8 b1:29.9,11.1->9.2,14.5 b2:12.7,11.6->29.9,9.2] [+106925ms]","src":"client"} +{"t":1782922415596,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->12.8,5.4 b1:29.9,11.1->10.6,4.3 b2:12.7,11.6->10.4,6.5] [+110965ms]","src":"client"} +{"t":1782922419639,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->23.6,5.3 b1:29.9,11.1->8.9,15.0 b2:12.7,11.6->20.3,13.0] [+115005ms]","src":"client"} +{"t":1782922423670,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.2,14.3 b1:29.9,11.1->6.1,11.9 b2:12.7,11.6->6.3,12.1] [+119037ms]","src":"client"} +{"t":1782922427704,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->19.1,4.4 b1:29.9,11.1->6.1,16.1 b2:12.7,11.6->11.9,7.9] [+123070ms]","src":"client"} +{"t":1782922431736,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.4,9.9 b1:29.9,11.1->12.5,11.9 b2:12.7,11.6->16.0,9.5] [+127103ms]","src":"client"} +{"t":1782922435806,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.5,11.6 b1:29.9,11.1->16.3,4.1 b2:12.7,11.6->21.0,6.3] [+131178ms]","src":"client"} +{"t":1782922439870,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->24.1,17.0 b1:29.9,11.1->6.1,5.3 b2:12.7,11.6->29.7,6.6] [+135238ms]","src":"client"} +{"t":1782922443904,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->24.1,17.0 b1:29.9,11.1->14.7,8.2 b2:12.7,11.6->12.6,9.6] [+139275ms]","src":"client"} +{"t":1782922447950,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->20.2,11.0 b1:29.9,11.1->11.4,4.1 b2:12.7,11.6->7.7,17.7] [+143317ms]","src":"client"} +{"t":1782922451984,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->20.2,10.8 b1:29.9,11.1->11.9,7.1 b2:12.7,11.6->6.2,17.9] [+147352ms]","src":"client"} +{"t":1782922456019,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->20.2,12.2 b1:29.9,11.1->26.0,7.9 b2:12.7,11.6->25.6,15.7] [+151385ms]","src":"client"} +{"t":1782922460044,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->17.7,8.9 b1:29.9,11.1->7.7,4.2 b2:12.7,11.6->17.4,13.1] [+155411ms]","src":"client"} +{"t":1782922464074,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->28.5,12.0 b1:29.9,11.1->13.2,10.7 b2:12.7,11.6->19.9,13.1] [+159442ms]","src":"client"} +{"t":1782922468111,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.6,5.1 b1:29.9,11.1->21.3,6.6 b2:12.7,11.6->19.9,13.1] [+163479ms]","src":"client"} +{"t":1782922472155,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->7.8,4.2 b1:29.9,11.1->6.2,7.0 b2:12.7,11.6->8.8,7.4] [+167523ms]","src":"client"} +{"t":1782922476179,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->7.8,4.2 b1:29.9,11.1->17.8,9.9 b2:12.7,11.6->6.0,12.4] [+171545ms]","src":"client"} +{"t":1782922480212,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.4,5.8 b1:29.9,11.1->29.6,7.5 b2:12.7,11.6->6.3,12.5] [+175580ms]","src":"client"} +{"t":1782922484246,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->22.1,6.3 b1:29.9,11.1->29.6,7.4 b2:12.7,11.6->11.0,11.2] [+179614ms]","src":"client"} +{"t":1782922488303,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->9.7,16.9 b1:29.9,11.1->14.8,7.5 b2:12.7,11.6->14.6,11.3] [+183655ms]","src":"client"} +{"t":1782922492323,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.3,5.2 b1:29.9,11.1->22.6,17.6 b2:12.7,11.6->14.6,11.3] [+187690ms]","src":"client"} +{"t":1782922496358,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->29.6,5.4 b1:29.9,11.1->29.6,10.9 b2:12.7,11.6->15.0,5.3] [+191725ms]","src":"client"} +{"t":1782922500392,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->29.6,5.4 b1:29.9,11.1->22.5,10.2 b2:12.7,11.6->20.8,10.1] [+195760ms]","src":"client"} +{"t":1782922504445,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->17.0,13.2 b1:29.9,11.1->6.3,11.1 b2:12.7,11.6->6.0,6.5] [+199804ms]","src":"client"} +{"t":1782922508464,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.6,13.3 b1:29.9,11.1->6.3,11.8 b2:12.7,11.6->7.7,6.8] [+203830ms]","src":"client"} +{"t":1782922512500,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.5,16.8 b1:29.9,11.1->23.1,8.4 b2:12.7,11.6->22.2,16.6] [+207866ms]","src":"client"} +{"t":1782922516532,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.8,7.6 b1:29.9,11.1->27.6,5.2 b2:12.7,11.6->25.2,16.8] [+211903ms]","src":"client"} +{"t":1782922520577,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->17.6,13.3 b1:29.9,11.1->8.3,10.6 b2:12.7,11.6->29.6,5.2] [+215942ms]","src":"client"} +{"t":1782922524606,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->7.0,4.4 b1:29.9,11.1->6.0,8.6 b2:12.7,11.6->23.4,14.8] [+219973ms]","src":"client"} +{"t":1782922528641,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->8.8,14.3 b1:29.9,11.1->13.9,14.4 b2:12.7,11.6->27.6,13.1] [+224007ms]","src":"client"} +{"t":1782922532683,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->26.7,14.6 b1:29.9,11.1->29.8,6.1 b2:12.7,11.6->20.3,10.4] [+228048ms]","src":"client"} +{"t":1782922536719,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->21.0,14.6 b1:29.9,11.1->10.8,12.3 b2:12.7,11.6->6.3,7.6] [+232084ms]","src":"client"} +{"t":1782922540755,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->27.0,14.0 b1:29.9,11.1->14.6,10.6 b2:12.7,11.6->6.3,7.2] [+236121ms]","src":"client"} +{"t":1782922544784,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.7,17.8 b1:29.9,11.1->6.2,4.5 b2:12.7,11.6->19.0,13.3] [+240150ms]","src":"client"} +{"t":1782922548815,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->18.7,17.8 b1:29.9,11.1->20.1,8.7 b2:12.7,11.6->9.7,13.0] [+244181ms]","src":"client"} +{"t":1782922552850,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.6,5.1 b1:29.9,11.1->16.8,9.6 b2:12.7,11.6->6.4,9.4] [+248216ms]","src":"client"} +{"t":1782922556888,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.6,11.3 b1:29.9,11.1->6.3,12.7 b2:12.7,11.6->7.9,6.1] [+252253ms]","src":"client"} +{"t":1782922560933,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.7,7.4 b1:29.9,11.1->6.0,6.6 b2:12.7,11.6->7.9,6.4] [+256298ms]","src":"client"} +{"t":1782922564971,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.4,17.6 b1:29.9,11.1->7.7,6.7 b2:12.7,11.6->14.3,6.8] [+260335ms]","src":"client"} +{"t":1782922569004,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->13.8,14.2 b1:29.9,11.1->16.7,6.2 b2:12.7,11.6->23.5,15.7] [+264369ms]","src":"client"} +{"t":1782922573044,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->19.5,5.4 b1:29.9,11.1->6.0,7.8 b2:12.7,11.6->8.9,9.7] [+268407ms]","src":"client"} +{"t":1782922577072,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->29.9,7.3 b1:29.9,11.1->11.9,12.5 b2:12.7,11.6->25.6,15.2] [+272435ms]","src":"client"} +{"t":1782922581101,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->10.5,17.9 b1:29.9,11.1->19.9,4.4 b2:12.7,11.6->21.8,6.1] [+276465ms]","src":"client"} +{"t":1782922585139,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->10.8,16.9 b1:29.9,11.1->23.4,14.8 b2:12.7,11.6->24.8,13.9] [+280503ms]","src":"client"} +{"t":1782922589177,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->19.8,9.9 b1:29.9,11.1->10.9,4.3 b2:12.7,11.6->29.5,6.6] [+284541ms]","src":"client"} +{"t":1782922593211,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.3,5.8 b1:29.9,11.1->19.0,14.2 b2:12.7,11.6->12.7,7.7] [+288576ms]","src":"client"} +{"t":1782922597250,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->11.4,9.1 b1:29.9,11.1->20.2,11.5 b2:12.7,11.6->28.5,10.0] [+292613ms]","src":"client"} +{"t":1782922601286,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.8,16.5 b1:29.9,11.1->16.0,7.7 b2:12.7,11.6->21.2,5.3] [+296649ms]","src":"client"} +{"t":1782922605316,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->23.0,17.4 b1:29.9,11.1->6.7,7.3 b2:12.7,11.6->29.8,9.2] [+300678ms]","src":"client"} +{"t":1782922609347,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->22.7,13.2 b1:29.9,11.1->11.6,9.2 b2:12.7,11.6->12.8,12.5] [+304711ms]","src":"client"} +{"t":1782922613383,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->21.5,5.8 b1:29.9,11.1->23.5,16.5 b2:12.7,11.6->6.1,8.5] [+308746ms]","src":"client"} +{"t":1782922617609,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->29.0,7.3 b1:29.9,11.1->27.1,7.3 b2:12.7,11.6->14.7,11.2] [+312913ms]","src":"client"} +{"t":1782922621583,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->8.6,4.0 b1:29.9,11.1->29.8,8.0 b2:12.7,11.6->15.7,13.2] [+316948ms]","src":"client"} +{"t":1782922625625,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->8.2,16.0 b1:29.9,11.1->19.7,15.4 b2:12.7,11.6->20.0,12.4] [+320987ms]","src":"client"} +{"t":1782922629667,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->6.3,6.6 b1:29.9,11.1->22.7,5.3 b2:12.7,11.6->19.6,17.9] [+325030ms]","src":"client"} +{"t":1782922633709,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->8.4,4.2 b1:29.9,11.1->15.2,7.0 b2:12.7,11.6->10.5,9.2] [+329070ms]","src":"client"} +{"t":1782922637748,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->11.7,4.8 b1:29.9,11.1->21.9,16.5 b2:12.7,11.6->16.9,5.0] [+333109ms]","src":"client"} +{"t":1782922641782,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->10.0,13.3 b1:29.9,11.1->15.1,9.9 b2:12.7,11.6->18.0,8.1] [+337143ms]","src":"client"} +{"t":1782922645815,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->15.8,5.3 b1:29.9,11.1->6.3,10.7 b2:12.7,11.6->11.2,4.9] [+341177ms]","src":"client"} +{"t":1782922649851,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->15.8,5.3 b1:29.9,11.1->6.3,10.7 b2:12.7,11.6->22.7,5.1] [+345211ms]","src":"client"} +{"t":1782922653883,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->26.2,14.1 b1:29.9,11.1->11.6,4.2 b2:12.7,11.6->18.8,4.1] [+349244ms]","src":"client"} +{"t":1782922657914,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.0,5.1 b1:29.9,11.1->15.6,15.3 b2:12.7,11.6->12.7,15.5] [+353275ms]","src":"client"} +{"t":1782922661948,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->23.1,5.3 b1:29.9,11.1->10.0,10.6 b2:12.7,11.6->25.9,11.9] [+357310ms]","src":"client"} +{"t":1782922665980,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.9,10.4 b1:29.9,11.1->19.3,6.7 b2:12.7,11.6->6.2,14.9] [+361341ms]","src":"client"} +{"t":1782922670020,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->9.2,6.8 b1:29.9,11.1->29.6,5.2 b2:12.7,11.6->14.6,12.2] [+365381ms]","src":"client"} +{"t":1782922674054,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->14.7,12.5 b1:29.9,11.1->28.8,12.8 b2:12.7,11.6->30.0,10.4] [+369416ms]","src":"client"} +{"t":1782922678088,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->28.1,5.1 b1:29.9,11.1->25.3,11.6 b2:12.7,11.6->13.1,5.3] [+373449ms]","src":"client"} +{"t":1782922682130,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->28.2,5.1 b1:29.9,11.1->9.8,9.6 b2:12.7,11.6->15.6,5.3] [+377492ms]","src":"client"} +{"t":1782922686167,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->22.4,12.6 b1:29.9,11.1->15.8,5.0 b2:12.7,11.6->11.1,17.6] [+381530ms]","src":"client"} +{"t":1782922690202,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->17.7,13.8 b1:29.9,11.1->16.1,4.5 b2:12.7,11.6->6.4,13.0] [+385564ms]","src":"client"} +{"t":1782922694234,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->7.4,4.2 b1:29.9,11.1->16.1,4.2 b2:12.7,11.6->27.0,14.1] [+389597ms]","src":"client"} +{"t":1782922698274,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->19.9,9.5 b1:29.9,11.1->18.9,8.1 b2:12.7,11.6->14.0,13.0] [+393636ms]","src":"client"} +{"t":1782922702308,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->9.1,17.8 b1:29.9,11.1->25.3,16.6 b2:12.7,11.6->6.4,12.3] [+397675ms]","src":"client"} +{"t":1782922706353,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.8,6.9->16.0,5.2 b1:29.9,11.1->26.9,14.7 b2:12.7,11.6->14.7,10.8] [+401716ms]","src":"client"} +{"t":1782922710401,"cat":"LobbyA","room":"a-1782922301574","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:14.9,8.0->13.9,8.5 b1:21.2,12.9->21.2,12.9 b2:14.7,10.8->14.7,10.8] [+405762ms]","src":"client"} +{"t":1782932303827,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"lobby-open","detail":"space=a-1782932300753 [+0ms]","src":"client"} +{"t":1782932303872,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+17ms]","src":"client"} +{"t":1782932303875,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"preload-start","detail":" [+19ms]","src":"client"} +{"t":1782932303928,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"manifest-done","detail":" [+109ms]","src":"client"} +{"t":1782932303933,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"colors-resolved","detail":" [+110ms]","src":"client"} +{"t":1782932303962,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"socket-connect","detail":" [+134ms]","src":"client"} +{"t":1782932303965,"cat":"LobbyA","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 0","src":"server"} +{"t":1782932304524,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=0 [+688ms]","src":"client"} +{"t":1782932304531,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=a-1782932300753 mapName=LobbyA [+710ms]","src":"client"} +{"t":1782932305089,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=true [+1276ms]","src":"client"} +{"t":1782932305334,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"preload-occupants-done","detail":" [+1504ms]","src":"client"} +{"t":1782932305335,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+1504ms]","src":"client"} +{"t":1782932305335,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+1504ms]","src":"client"} +{"t":1782932312869,"cat":"LobbyA","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง (1 คนในห้อง)","src":"server"} +{"t":1782932324475,"cat":"LobbyB","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782932324929,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+21113ms]","src":"client"} +{"t":1782932325050,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+21233ms]","src":"client"} +{"t":1782932325644,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+21812ms]","src":"client"} +{"t":1782932326728,"cat":"LobbyB","room":"a","who":"บอท7","ev":"story-gate","detail":"[TC156] อ่าน story จบ 1/1","src":"server"} +{"t":1782932326728,"cat":"LobbyB","room":"a","who":"","ev":"story-gate","detail":"[TC156] ทุกคนอ่าน story ครบ (1) → ปลดล็อกเลือกผู้ต้องสงสัย (pass)","src":"server"} +{"t":1782932327182,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+23365ms]","src":"client"} +{"t":1782932350834,"cat":"MG2","room":"a","who":"","ev":"game-start","detail":"เริ่มมินิเกม: Minigame-2 วิ่งหลบสิ่งกีดขวาง (การ์ด 1)","src":"server"} +{"t":1782932350844,"cat":"MG2","room":"a","who":"บอท7","ev":"lobbyb-gate","detail":"[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ 1 คน · ผู้ต้องสงสัย #1","src":"server"} +{"t":1782932350889,"cat":"MG2","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782932351381,"cat":"MG2","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 0","src":"server"} +{"t":1782932383445,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"lobby-open","detail":"space=a-1782932300753 [+0ms]","src":"client"} +{"t":1782932383502,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+54ms]","src":"client"} +{"t":1782932383502,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"preload-start","detail":" [+56ms]","src":"client"} +{"t":1782932383547,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=true [+60ms]","src":"client"} +{"t":1782932383661,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"manifest-done","detail":" [+218ms]","src":"client"} +{"t":1782932383661,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"socket-connect","detail":" [+213ms]","src":"client"} +{"t":1782932383663,"cat":"LobbyA","room":"a-1782932300753","who":"บอท7","ev":"colors-resolved","detail":" [+219ms]","src":"client"} +{"t":1782932383810,"cat":"MG2","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 0","src":"server"} +{"t":1782932384806,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+1363ms]","src":"client"} +{"t":1782932384867,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+1414ms]","src":"client"} +{"t":1782932384944,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"preload-occupants-done","detail":" [+1490ms]","src":"client"} +{"t":1782932384944,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+1491ms]","src":"client"} +{"t":1782932385142,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mn8nx46h bots=0 [+1670ms]","src":"client"} +{"t":1782932385144,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+1670ms]","src":"client"} +{"t":1782932385144,"cat":"LobbyB","room":"a-1782932300753","who":"บอท7","ev":"suspect-join","detail":"phaseActive=true pickIdx=0 postCase=true clientMapId=mn8nx46h spaceId=a-1782932300753 mapName=LobbyB [+1680ms]","src":"client"} +{"t":1782932393634,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"lobby-open","detail":"space=aad-1782932390649 [+0ms]","src":"client"} +{"t":1782932393680,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+26ms]","src":"client"} +{"t":1782932393682,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"preload-start","detail":" [+27ms]","src":"client"} +{"t":1782932393737,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"manifest-done","detail":" [+128ms]","src":"client"} +{"t":1782932393741,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"colors-resolved","detail":" [+129ms]","src":"client"} +{"t":1782932393772,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"socket-connect","detail":" [+139ms]","src":"client"} +{"t":1782932393775,"cat":"LobbyA","room":"aad","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782932394456,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+847ms]","src":"client"} +{"t":1782932394563,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"preload-occupants-done","detail":" [+950ms]","src":"client"} +{"t":1782932394574,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+950ms]","src":"client"} +{"t":1782932394599,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=3 [+979ms]","src":"client"} +{"t":1782932394609,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+980ms]","src":"client"} +{"t":1782932394621,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=aad-1782932390649 mapName=LobbyA [+1000ms]","src":"client"} +{"t":1782932395321,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,14.0->10.2,13.3 b1:16.0,14.0->16.1,14.7 b2:22.0,13.0->21.4,12.5] [+1679ms]","src":"client"} +{"t":1782932397778,"cat":"LobbyA","room":"aad","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง (1 คนในห้อง)","src":"server"} +{"t":1782932400207,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.7,4.3->10.7,4.3 b1:22.7,17.5->22.7,17.5 b2:9.0,4.1->9.0,4.1] [+6598ms]","src":"client"} +{"t":1782932401161,"cat":"LobbyB","room":"aad","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782932401612,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+8006ms]","src":"client"} +{"t":1782932401703,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+8091ms]","src":"client"} +{"t":1782932402197,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+8590ms]","src":"client"} +{"t":1782932403160,"cat":"LobbyB","room":"aad","who":"บอท7","ev":"story-gate","detail":"[TC156] อ่าน story จบ 1/1","src":"server"} +{"t":1782932403160,"cat":"LobbyB","room":"aad","who":"","ev":"story-gate","detail":"[TC156] ทุกคนอ่าน story ครบ (1) → ปลดล็อกเลือกผู้ต้องสงสัย (pass)","src":"server"} +{"t":1782932403736,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+10129ms]","src":"client"} +{"t":1782932404378,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:9.8,10.2->9.1,9.9 b1:15.0,9.3->15.0,9.3 b2:14.6,12.4->14.6,12.4] [+10762ms]","src":"client"} +{"t":1782932405228,"cat":"MG2","room":"aad","who":"","ev":"game-start","detail":"เริ่มมินิเกม: Minigame-2 วิ่งหลบสิ่งกีดขวาง (การ์ด 1)","src":"server"} +{"t":1782932405240,"cat":"MG2","room":"aad","who":"บอท7","ev":"lobbyb-gate","detail":"[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ 1 คน · ผู้ต้องสงสัย #1","src":"server"} +{"t":1782932405299,"cat":"MG2","room":"aad","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782932405913,"cat":"MG2","room":"aad","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782932467036,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"lobby-open","detail":"space=aad-1782932390649 [+0ms]","src":"client"} +{"t":1782932467081,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+22ms]","src":"client"} +{"t":1782932467083,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"preload-start","detail":" [+23ms]","src":"client"} +{"t":1782932467084,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=true [+27ms]","src":"client"} +{"t":1782932467131,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"manifest-done","detail":" [+139ms]","src":"client"} +{"t":1782932467136,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"colors-resolved","detail":" [+139ms]","src":"client"} +{"t":1782932467178,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"socket-connect","detail":" [+180ms]","src":"client"} +{"t":1782932467267,"cat":"LobbyB","room":"aad","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782932467802,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+774ms]","src":"client"} +{"t":1782932467854,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"preload-occupants-done","detail":" [+858ms]","src":"client"} +{"t":1782932467859,"cat":"LobbyA","room":"aad-1782932390649","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+858ms]","src":"client"} +{"t":1782932468486,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mn8nx46h bots=3 [+1450ms]","src":"client"} +{"t":1782932468489,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+1450ms]","src":"client"} +{"t":1782932468490,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"suspect-join","detail":"phaseActive=true pickIdx=0 postCase=true clientMapId=mn8nx46h spaceId=aad-1782932390649 mapName=LobbyB [+1473ms]","src":"client"} +{"t":1782932468535,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,15.0->9.8,14.3 b1:15.0,15.0->15.6,15.4 b2:21.0,15.0->21.8,14.8] [+1537ms]","src":"client"} +{"t":1782932474638,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.4,9.8->13.4,9.8 b1:15.1,13.0->15.6,13.3 b2:7.1,14.7->7.1,14.7] [+7641ms]","src":"client"} +{"t":1782932480186,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:14.9,11.2->14.9,11.2 b1:25.7,13.2->26.1,13.5 b2:7.1,14.7->7.1,14.7] [+13188ms]","src":"client"} +{"t":1782932484200,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:9.4,16.6->9.4,16.6 b1:23.6,8.5->24.3,7.8 b2:19.9,11.1->20.6,11.6] [+17204ms]","src":"client"} +{"t":1782932488235,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:14.8,16.9->14.8,16.9 b1:8.4,4.3->8.4,4.3 b2:19.2,10.0->19.2,10.0] [+21239ms]","src":"client"} +{"t":1782932492274,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:15.6,13.2->15.7,13.1 b1:15.0,11.3->15.0,11.8 b2:15.8,13.3->14.8,13.3] [+25276ms]","src":"client"} +{"t":1782932496308,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:15.6,13.1->15.1,13.1 b1:15.8,7.1->15.0,6.5 b2:11.0,13.7->11.8,14.3] [+29313ms]","src":"client"} +{"t":1782932500342,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.2,5.7->8.2,5.7 b1:23.9,14.4->23.9,14.4 b2:16.8,16.9->16.8,16.9] [+33345ms]","src":"client"} +{"t":1782932504385,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:21.8,6.4->21.8,6.4 b1:13.5,8.4->12.5,8.3 b2:6.2,10.2->6.2,10.2] [+37387ms]","src":"client"} +{"t":1782932508415,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:7.1,12.4->7.1,12.4 b1:8.2,7.8->8.2,7.8 b2:5.4,9.1->5.4,9.1] [+41419ms]","src":"client"} +{"t":1782932512451,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.2,6.1->12.3,6.1 b1:8.2,6.3->8.2,6.3 b2:5.4,9.1->5.4,9.1] [+45453ms]","src":"client"} +{"t":1782932516481,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:9.0,6.4->9.0,6.4 b1:8.2,6.3->8.2,6.3 b2:14.6,10.1->14.6,10.1] [+49489ms]","src":"client"} +{"t":1782932520522,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:19.7,12.6->20.6,12.2 b1:12.7,16.8->12.7,16.8 b2:9.3,15.6->9.3,15.6] [+53526ms]","src":"client"} +{"t":1782932524563,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:24.0,5.6->24.0,5.6 b1:6.6,15.2->5.7,15.7 b2:9.2,5.3->9.2,5.3] [+57566ms]","src":"client"} +{"t":1782932528597,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.5,8.7->27.8,8.9 b1:27.4,5.0->27.7,5.0 b2:25.8,11.3->26.7,11.6] [+61600ms]","src":"client"} +{"t":1782932532630,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:5.0,15.9->5.0,15.9 b1:21.1,6.0->20.2,6.0 b2:14.7,8.7->13.7,8.6] [+65634ms]","src":"client"} +{"t":1782932536662,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:19.8,16.9->19.8,16.9 b1:14.0,7.8->15.0,8.1 b2:20.2,5.4->20.1,5.2] [+69665ms]","src":"client"} +{"t":1782932540692,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:20.4,14.8->20.7,13.8 b1:22.7,6.4->22.7,6.4 b2:21.7,5.0->21.7,5.0] [+73695ms]","src":"client"} +{"t":1782932544729,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:25.8,15.3->25.8,15.3 b1:27.4,12.4->28.0,12.8 b2:15.3,8.6->15.3,8.6] [+77731ms]","src":"client"} +{"t":1782932548771,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.7,12.0->28.7,12.0 b1:18.8,9.1->18.1,8.9 b2:16.0,8.6->16.1,8.6] [+81773ms]","src":"client"} +{"t":1782932552805,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:16.9,8.6->17.4,8.6 b1:14.3,13.0->14.3,13.0 b2:16.8,8.6->16.8,8.6] [+85808ms]","src":"client"} +{"t":1782932556832,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->13.8,16.9 b1:24.4,5.2->24.4,5.2 b2:8.2,8.3->8.2,8.3] [+89836ms]","src":"client"} +{"t":1782932560863,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->13.8,16.9 b1:24.4,5.8->24.4,5.8 b2:8.4,8.0->10.6,6.2] [+93868ms]","src":"client"} +{"t":1782932564894,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->11.8,15.9 b1:24.4,5.8->14.8,16.9 b2:8.4,8.0->13.0,5.1] [+97898ms]","src":"client"} +{"t":1782932568932,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->10.7,6.2 b1:24.4,5.8->11.8,16.9 b2:8.4,8.0->28.9,7.2] [+101935ms]","src":"client"} +{"t":1782932572983,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->21.0,16.9 b1:24.4,5.8->9.0,5.2 b2:8.4,8.0->19.4,9.6] [+105974ms]","src":"client"} +{"t":1782932577000,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->21.0,16.9 b1:24.4,5.8->12.7,6.5 b2:8.4,8.0->21.1,6.3] [+110004ms]","src":"client"} +{"t":1782932581035,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->24.5,5.5 b1:24.4,5.8->24.5,8.8 b2:8.4,8.0->18.6,16.7] [+114039ms]","src":"client"} +{"t":1782932585065,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->24.4,5.6 b1:24.4,5.8->25.4,5.5 b2:8.4,8.0->16.3,17.0] [+118070ms]","src":"client"} +{"t":1782932589097,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->5.1,9.3 b1:24.4,5.8->19.2,11.2 b2:8.4,8.0->23.5,7.5] [+122102ms]","src":"client"} +{"t":1782932593135,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->17.7,5.3 b1:24.4,5.8->15.1,17.0 b2:8.4,8.0->19.1,11.3] [+126140ms]","src":"client"} +{"t":1782932597179,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->14.1,13.1 b1:24.4,5.8->14.1,6.3 b2:8.4,8.0->12.8,13.1] [+130183ms]","src":"client"} +{"t":1782932601215,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->23.7,6.1 b1:24.4,5.8->25.8,16.9 b2:8.4,8.0->25.4,10.9] [+134219ms]","src":"client"} +{"t":1782932605251,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->11.0,11.8 b1:24.4,5.8->6.8,9.0 b2:8.4,8.0->27.8,16.4] [+138254ms]","src":"client"} +{"t":1782932609285,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->19.6,6.0 b1:24.4,5.8->26.5,16.5 b2:8.4,8.0->7.3,17.0] [+142289ms]","src":"client"} +{"t":1782932613320,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->19.3,12.6 b1:24.4,5.8->15.7,15.0 b2:8.4,8.0->12.6,12.5] [+146324ms]","src":"client"} +{"t":1782932617355,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->8.3,4.6 b1:24.4,5.8->25.3,12.2 b2:8.4,8.0->25.8,4.9] [+150360ms]","src":"client"} +{"t":1782932621392,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->14.7,12.9 b1:24.4,5.8->19.3,11.8 b2:8.4,8.0->25.1,3.3] [+154396ms]","src":"client"} +{"t":1782932625419,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->5.9,16.3 b1:24.4,5.8->28.2,6.7 b2:8.4,8.0->13.9,5.3] [+158424ms]","src":"client"} +{"t":1782932629453,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->20.5,15.5 b1:24.4,5.8->19.2,10.3 b2:8.4,8.0->16.2,5.7] [+162456ms]","src":"client"} +{"t":1782932633486,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->22.5,15.2 b1:24.4,5.8->19.0,12.2 b2:8.4,8.0->9.6,16.9] [+166490ms]","src":"client"} +{"t":1782932637516,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->27.8,9.1 b1:24.4,5.8->7.2,14.2 b2:8.4,8.0->14.8,11.2] [+170521ms]","src":"client"} +{"t":1782932641551,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->16.3,6.4 b1:24.4,5.8->14.9,11.3 b2:8.4,8.0->14.8,9.1] [+174556ms]","src":"client"} +{"t":1782932645583,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->14.4,16.8 b1:24.4,5.8->28.9,8.3 b2:8.4,8.0->21.0,10.2] [+178586ms]","src":"client"} +{"t":1782932649613,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->6.4,10.3 b1:24.4,5.8->28.9,6.9 b2:8.4,8.0->13.4,13.4] [+182617ms]","src":"client"} +{"t":1782932653643,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->22.8,10.7 b1:24.4,5.8->28.9,6.8 b2:8.4,8.0->15.0,9.2] [+186646ms]","src":"client"} +{"t":1782932657672,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->18.1,6.3 b1:24.4,5.8->19.8,9.0 b2:8.4,8.0->28.9,6.9] [+190676ms]","src":"client"} +{"t":1782932661705,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->21.9,5.4 b1:24.4,5.8->5.9,15.1 b2:8.4,8.0->28.9,6.9] [+194709ms]","src":"client"} +{"t":1782932665742,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->20.1,5.2 b1:24.4,5.8->6.6,15.1 b2:8.4,8.0->8.1,9.6] [+198744ms]","src":"client"} +{"t":1782932669774,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->14.4,15.9 b1:24.4,5.8->18.9,6.2 b2:8.4,8.0->23.2,16.7] [+202778ms]","src":"client"} +{"t":1782932673809,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->16.4,13.1 b1:24.4,5.8->14.8,11.9 b2:8.4,8.0->10.2,16.3] [+206814ms]","src":"client"} +{"t":1782932677850,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->17.2,13.1 b1:24.4,5.8->14.8,10.8 b2:8.4,8.0->23.1,14.0] [+210854ms]","src":"client"} +{"t":1782932681885,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->6.6,15.2 b1:24.4,5.8->15.0,6.4 b2:8.4,8.0->14.7,16.9] [+214889ms]","src":"client"} +{"t":1782932685918,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->5.2,16.6 b1:24.4,5.8->9.4,7.1 b2:8.4,8.0->8.8,4.3] [+218921ms]","src":"client"} +{"t":1782932689949,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->19.8,7.8 b1:24.4,5.8->16.9,14.1 b2:8.4,8.0->17.3,8.8] [+222954ms]","src":"client"} +{"t":1782932693985,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->21.0,9.4 b1:24.4,5.8->19.9,6.3 b2:8.4,8.0->27.3,5.1] [+226989ms]","src":"client"} +{"t":1782932698018,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->22.1,10.9 b1:24.4,5.8->8.3,6.1 b2:8.4,8.0->18.8,7.1] [+231022ms]","src":"client"} +{"t":1782932702064,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->8.4,4.7 b1:24.4,5.8->8.3,6.1 b2:8.4,8.0->5.8,9.3] [+235067ms]","src":"client"} +{"t":1782932706095,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->9.9,5.8 b1:24.4,5.8->16.7,16.8 b2:8.4,8.0->14.7,10.4] [+239101ms]","src":"client"} +{"t":1782932710133,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->22.7,6.8 b1:24.4,5.8->27.5,15.8 b2:8.4,8.0->8.4,4.9] [+243138ms]","src":"client"} +{"t":1782932714169,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.8,16.9->15.5,8.9 b1:24.4,5.8->27.5,15.8 b2:8.4,8.0->16.3,17.0] [+247173ms]","src":"client"} +{"t":1782932718200,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:22.8,16.6->27.6,17.0 b1:17.8,10.5->14.2,13.1 b2:21.1,11.6->27.9,13.5] [+251204ms]","src":"client"} +{"t":1782932722227,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->11.3,6.5 b1:8.3,4.7->14.0,7.8 b2:27.9,13.5->19.3,12.8] [+255232ms]","src":"client"} +{"t":1782932726251,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->27.7,16.1 b1:8.3,4.7->25.2,8.3 b2:27.9,13.5->19.3,12.4] [+259256ms]","src":"client"} +{"t":1782932730278,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->19.1,9.5 b1:8.3,4.7->28.6,12.2 b2:27.9,13.5->28.9,8.3] [+263282ms]","src":"client"} +{"t":1782932734312,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->19.1,9.2 b1:8.3,4.7->17.9,5.2 b2:27.9,13.5->12.4,5.5] [+267317ms]","src":"client"} +{"t":1782932738351,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->16.6,8.8 b1:8.3,4.7->17.9,5.3 b2:27.9,13.5->27.5,16.8] [+271356ms]","src":"client"} +{"t":1782932742393,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->9.1,12.1 b1:8.3,4.7->24.3,12.9 b2:27.9,13.5->19.4,11.6] [+275398ms]","src":"client"} +{"t":1782932746429,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->12.0,14.0 b1:8.3,4.7->14.6,9.9 b2:27.9,13.5->19.4,12.3] [+279434ms]","src":"client"} +{"t":1782932750465,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->27.9,9.6 b1:8.3,4.7->18.1,8.9 b2:27.9,13.5->19.4,9.9] [+283470ms]","src":"client"} +{"t":1782932754503,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->11.2,14.1 b1:8.3,4.7->24.7,8.7 b2:27.9,13.5->19.4,9.1] [+287508ms]","src":"client"} +{"t":1782932758540,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->14.5,12.3 b1:8.3,4.7->19.3,9.5 b2:27.9,13.5->18.7,8.9] [+291545ms]","src":"client"} +{"t":1782932762587,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->5.0,15.2 b1:8.3,4.7->11.6,16.8 b2:27.9,13.5->27.7,16.3] [+295592ms]","src":"client"} +{"t":1782932766631,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->13.2,9.6 b1:8.3,4.7->17.2,15.9 b2:27.9,13.5->23.7,6.1] [+299626ms]","src":"client"} +{"t":1782932770663,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->18.6,13.0 b1:8.3,4.7->28.0,16.8 b2:27.9,13.5->11.8,12.6] [+303666ms]","src":"client"} +{"t":1782932774701,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->15.0,9.4 b1:8.3,4.7->20.3,9.8 b2:27.9,13.5->12.0,6.1] [+307705ms]","src":"client"} +{"t":1782932778735,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->8.0,7.2 b1:8.3,4.7->16.1,15.7 b2:27.9,13.5->15.1,7.4] [+311739ms]","src":"client"} +{"t":1782932782768,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->12.0,8.9 b1:8.3,4.7->19.4,9.6 b2:27.9,13.5->13.3,9.6] [+315772ms]","src":"client"} +{"t":1782932786805,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->18.1,6.2 b1:8.3,4.7->7.9,10.6 b2:27.9,13.5->6.6,10.2] [+319810ms]","src":"client"} +{"t":1782932790836,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->20.4,5.0 b1:8.3,4.7->28.1,6.2 b2:27.9,13.5->12.2,5.0] [+323840ms]","src":"client"} +{"t":1782932794869,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->9.0,11.1 b1:8.3,4.7->12.5,16.4 b2:27.9,13.5->12.2,5.0] [+327873ms]","src":"client"} +{"t":1782932798901,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->12.2,5.9 b1:8.3,4.7->22.0,5.1 b2:27.9,13.5->28.8,8.3] [+331904ms]","src":"client"} +{"t":1782932802943,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->10.9,11.3 b1:8.3,4.7->8.4,6.8 b2:27.9,13.5->19.0,12.3] [+335947ms]","src":"client"} +{"t":1782932806980,"cat":"LobbyB","room":"aad-1782932390649","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.1,9.4->5.9,16.7 b1:8.3,4.7->8.5,11.2 b2:27.9,13.5->29.0,6.3] [+339983ms]","src":"client"} +{"t":1782962916968,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"lobby-open","detail":"space=a-1782962913897 [+0ms]","src":"client"} +{"t":1782962917014,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+38ms]","src":"client"} +{"t":1782962917036,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"preload-start","detail":" [+40ms]","src":"client"} +{"t":1782962917101,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"manifest-done","detail":" [+128ms]","src":"client"} +{"t":1782962917110,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"colors-resolved","detail":" [+129ms]","src":"client"} +{"t":1782962917158,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"socket-connect","detail":" [+178ms]","src":"client"} +{"t":1782962917177,"cat":"LobbyA","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782962918258,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+1294ms]","src":"client"} +{"t":1782962918374,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"preload-occupants-done","detail":" [+1384ms]","src":"client"} +{"t":1782962918380,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+1385ms]","src":"client"} +{"t":1782962918689,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=3 [+1719ms]","src":"client"} +{"t":1782962918714,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+1720ms]","src":"client"} +{"t":1782962918716,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=a-1782962913897 mapName=LobbyA [+1742ms]","src":"client"} +{"t":1782962918717,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,14.0->11.2,12.7 b1:16.0,14.0->17.8,13.7 b2:22.0,13.0->20.2,13.1] [+1746ms]","src":"client"} +{"t":1782962922949,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:11.7,11.1->11.9,10.6 b1:19.4,13.4->19.9,13.3 b2:18.6,13.1->18.6,13.1] [+5983ms]","src":"client"} +{"t":1782962927963,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:6.2,12.2->6.2,12.2 b1:29.8,10.0->29.8,10.0 b2:6.5,5.3->6.5,5.3] [+11000ms]","src":"client"} +{"t":1782962931984,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:11.0,11.2->16.2,15.0 b1:10.6,4.0->10.6,4.0 b2:14.9,12.6->9.2,6.7] [+15020ms]","src":"client"} +{"t":1782962936025,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:22.5,17.6->22.5,17.6 b1:28.1,11.8->28.1,11.8 b2:6.2,12.5->6.2,12.5] [+19061ms]","src":"client"} +{"t":1782962936470,"cat":"LobbyA","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง (1 คนในห้อง)","src":"server"} +{"t":1782962938417,"cat":"LobbyB","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782962938875,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+21908ms]","src":"client"} +{"t":1782962939566,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+22598ms]","src":"client"} +{"t":1782962940050,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:14.5,12.0->14.5,12.2 b1:5.4,15.7->5.4,15.7 b2:18.7,6.0->19.4,6.1] [+23084ms]","src":"client"} +{"t":1782962940211,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+23247ms]","src":"client"} +{"t":1782962940707,"cat":"LobbyB","room":"a","who":"บอท7","ev":"story-gate","detail":"[TC156] อ่าน story จบ 1/1","src":"server"} +{"t":1782962940707,"cat":"LobbyB","room":"a","who":"","ev":"story-gate","detail":"[TC156] ทุกคนอ่าน story ครบ (1) → ปลดล็อกเลือกผู้ต้องสงสัย (pass)","src":"server"} +{"t":1782962941373,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+24416ms]","src":"client"} +{"t":1782962944105,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:18.2,16.8->17.2,17.0 b1:15.5,6.3->15.5,6.3 b2:28.6,6.5->28.6,6.5] [+27138ms]","src":"client"} +{"t":1782962948133,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:24.9,9.8->25.8,9.4 b1:9.9,9.3->9.0,9.8 b2:25.7,3.4->25.7,3.4] [+31166ms]","src":"client"} +{"t":1782962952168,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:13.5,9.8->12.7,10.2 b1:7.9,10.3->7.9,10.1 b2:25.7,3.4->25.7,3.4] [+35200ms]","src":"client"} +{"t":1782962954734,"cat":"MG2","room":"a","who":"","ev":"game-start","detail":"เริ่มมินิเกม: Minigame-2 วิ่งหลบสิ่งกีดขวาง (การ์ด 1)","src":"server"} +{"t":1782962954754,"cat":"MG2","room":"a","who":"บอท7","ev":"lobbyb-gate","detail":"[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ 1 คน · ผู้ต้องสงสัย #1","src":"server"} +{"t":1782962954799,"cat":"MG2","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782962955223,"cat":"MG2","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782963015648,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"lobby-open","detail":"space=a-1782962913897 [+0ms]","src":"client"} +{"t":1782963015692,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+10ms]","src":"client"} +{"t":1782963015719,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"preload-start","detail":" [+12ms]","src":"client"} +{"t":1782963015731,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=true [+14ms]","src":"client"} +{"t":1782963015783,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"colors-resolved","detail":" [+138ms]","src":"client"} +{"t":1782963015784,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"manifest-done","detail":" [+137ms]","src":"client"} +{"t":1782963015818,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"socket-connect","detail":" [+149ms]","src":"client"} +{"t":1782963015874,"cat":"LobbyB","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782963017140,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+1495ms]","src":"client"} +{"t":1782963017233,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"preload-occupants-done","detail":" [+1568ms]","src":"client"} +{"t":1782963017234,"cat":"LobbyA","room":"a-1782962913897","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+1568ms]","src":"client"} +{"t":1782963017253,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mn8nx46h bots=3 [+1605ms]","src":"client"} +{"t":1782963017273,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+1606ms]","src":"client"} +{"t":1782963017278,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"suspect-join","detail":"phaseActive=true pickIdx=0 postCase=true clientMapId=mn8nx46h spaceId=a-1782962913897 mapName=LobbyB [+1629ms]","src":"client"} +{"t":1782963017559,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,15.0->10.9,15.1 b1:15.0,15.0->14.1,14.9 b2:21.0,15.0->21.9,15.0] [+1891ms]","src":"client"} +{"t":1782963021756,"cat":"LobbyB","room":"a-1782962913897","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:24.0,13.4->23.7,13.0 b1:13.7,11.2->14.2,10.9 b2:27.5,15.5->27.5,15.5] [+6106ms]","src":"client"} +{"t":1782963024158,"cat":"MG1","room":"a","who":"","ev":"game-start","detail":"เริ่มมินิเกม: Minigame-1 จริงหรือไม่ (การ์ด 2)","src":"server"} +{"t":1782963024165,"cat":"MG1","room":"a","who":"บอท7","ev":"lobbyb-gate","detail":"[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ 1 คน · ผู้ต้องสงสัย #2","src":"server"} +{"t":1782963024204,"cat":"MG1","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782963024612,"cat":"MG1","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782965050184,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"lobby-open","detail":"space=a-1782965047174 [+1ms]","src":"client"} +{"t":1782965050222,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"suspect-gate","detail":"postCase=false howtoOk=true micOk=true sent=false [+32ms]","src":"client"} +{"t":1782965050244,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"preload-start","detail":" [+34ms]","src":"client"} +{"t":1782965050300,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"colors-resolved","detail":" [+129ms]","src":"client"} +{"t":1782965050302,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"manifest-done","detail":" [+129ms]","src":"client"} +{"t":1782965050324,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"socket-connect","detail":" [+140ms]","src":"client"} +{"t":1782965050348,"cat":"LobbyA","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782965051043,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"mychar-tinted","detail":"roomJoinReady=false [+831ms]","src":"client"} +{"t":1782965051043,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"preload-occupants-done","detail":" [+877ms]","src":"client"} +{"t":1782965051085,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"join-ok","detail":"peers=1 host=me map=mlsbbxfe bots=3 [+900ms]","src":"client"} +{"t":1782965051087,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"preload-ready(occupants)","detail":" [+877ms]","src":"client"} +{"t":1782965051091,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+900ms]","src":"client"} +{"t":1782965051094,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"suspect-join","detail":"phaseActive=false pickIdx=0 postCase=false clientMapId=mlsbbxfe spaceId=a-1782965047174 mapName=LobbyA [+924ms]","src":"client"} +{"t":1782965051132,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:10.0,14.0->9.4,14.3 b1:16.0,14.0->16.5,13.6 b2:22.0,13.0->22.4,12.5] [+929ms]","src":"client"} +{"t":1782965055566,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:6.1,17.2->6.1,17.2 b1:22.7,8.6->22.7,8.6 b2:27.0,6.5->26.5,6.7] [+5398ms]","src":"client"} +{"t":1782965059599,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:16.3,13.0->16.3,13.0 b1:6.3,10.2->6.3,10.2 b2:22.2,11.1->22.2,11.1] [+9433ms]","src":"client"} +{"t":1782965063636,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:22.9,15.7->23.8,16.1 b1:15.0,11.1->15.0,11.1 b2:6.4,17.6->6.4,17.7] [+13470ms]","src":"client"} +{"t":1782965066818,"cat":"LobbyA","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host เปิด wizard เลือกคดี → ล็อก set-ready ทั้งห้อง (1 คนในห้อง)","src":"server"} +{"t":1782965067676,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:8.0,4.6->8.9,4.9 b1:15.0,11.7->15.0,11.7 b2:24.5,5.2->25.0,5.2] [+17506ms]","src":"client"} +{"t":1782965069985,"cat":"LobbyB","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782965070442,"cat":"LobbyA","room":"a-1782965047174","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+20271ms]","src":"client"} +{"t":1782965071023,"cat":"LobbyB","room":"a-1782965047174","who":"บอท7","ev":"suspect-gate","detail":"postCase=true howtoOk=true micOk=true sent=false [+20847ms]","src":"client"} +{"t":1782965071523,"cat":"LobbyB","room":"a-1782965047174","who":"บอท7","ev":"suspect-open-evt","detail":"idx=0 postCase=true [+21350ms]","src":"client"} +{"t":1782965071642,"cat":"LobbyB","room":"a","who":"บอท7","ev":"story-gate","detail":"[TC156] อ่าน story จบ 1/1","src":"server"} +{"t":1782965071642,"cat":"LobbyB","room":"a","who":"","ev":"story-gate","detail":"[TC156] ทุกคนอ่าน story ครบ (1) → ปลดล็อกเลือกผู้ต้องสงสัย (pass)","src":"server"} +{"t":1782965071750,"cat":"LobbyB","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:9.6,5.7->9.6,5.9 b1:9.2,15.3->10.1,14.9 b2:24.9,15.9->24.9,15.9] [+21582ms]","src":"client"} +{"t":1782965072133,"cat":"LobbyB","room":"a-1782965047174","who":"บอท7","ev":"loading-hide","detail":"เริ่มเล่นได้ [+21963ms]","src":"client"} +{"t":1782965075792,"cat":"LobbyB","room":"a-1782965047174","who":"บอท7","ev":"bot-pos","detail":"map=35x20 n=3 [b0:28.9,12.2->28.9,12.2 b1:26.5,7.8->25.8,8.5 b2:18.2,13.2->17.3,13.4] [+25626ms]","src":"client"} +{"t":1782965078994,"cat":"MG4","room":"a","who":"","ev":"game-start","detail":"เริ่มมินิเกม: Minigame-4 คำศัพท์ในกระบวนการยุติธรรม (การ์ด 1)","src":"server"} +{"t":1782965078999,"cat":"MG4","room":"a","who":"บอท7","ev":"lobbyb-gate","detail":"[TC144] เริ่มสืบสวน OK — ผู้เล่นครบ 1 คน · ผู้ต้องสงสัย #1","src":"server"} +{"t":1782965079043,"cat":"MG4","room":"a","who":"บอท7","ev":"preplay-lock","detail":"[TC154] host ปิด wizard → ปลดล็อก set-ready (1 คนในห้อง)","src":"server"} +{"t":1782965079429,"cat":"MG4","room":"a","who":"บอท7","ev":"join","detail":"สี#7 · คน 1 + บอท 3","src":"server"} +{"t":1782965086771,"cat":"MG4","room":"a","who":"","ev":"mg4-read","detail":"q=1 deck=29 rounds=0/10","src":"server"} +{"t":1782965104774,"cat":"MG4","room":"a","who":"","ev":"mg4-round","detail":"resolve 1/10 q=1 winner=- willEnd=false","src":"server"} +{"t":1782965107276,"cat":"MG4","room":"a","who":"","ev":"mg4-read","detail":"q=2 deck=28 rounds=1/10","src":"server"} +{"t":1782965113308,"cat":"MG4","room":"a","who":"","ev":"mg4-round","detail":"resolve 2/10 q=2 winner=cMav5ieq7U8JmdqiAAAL willEnd=false","src":"server"} +{"t":1782965115808,"cat":"MG4","room":"a","who":"","ev":"mg4-read","detail":"q=3 deck=27 rounds=2/10","src":"server"} +{"t":1782965126886,"cat":"MG4","room":"a","who":"","ev":"mg4-round","detail":"resolve 3/10 q=3 winner=__pv_bot_1 willEnd=false","src":"server"} +{"t":1782965129387,"cat":"MG4","room":"a","who":"","ev":"mg4-read","detail":"q=4 deck=26 rounds=3/10","src":"server"} diff --git a/www/html/gamelog/index.php b/www/html/gamelog/index.php index eb16714..3839adb 100644 --- a/www/html/gamelog/index.php +++ b/www/html/gamelog/index.php @@ -48,6 +48,11 @@

🎮 Game Log — 7 มินิเกม + LobbyA / LobbyB · srv1361159

+
+ 🔎 ค้นหา TC154 / TC156 / TC157 / TC144 / TC158 (หรือ ev: preplay-lock · story-gate · vote-gate · lobbyb-gate · mg4-warp) + → ดูว่าฟีเจอร์ทำงานไหม แล้วอัปเดตผลที่ /testcase/ + · เสียง (TC159-164) = ฟังเอง ไม่มีใน log +
diff --git a/www/html/gamelog/index.php.bak-tclink b/www/html/gamelog/index.php.bak-tclink new file mode 100644 index 0000000..eb16714 --- /dev/null +++ b/www/html/gamelog/index.php.bak-tclink @@ -0,0 +1,129 @@ + + + + + + +Game Log · Justice + + + +
+

🎮 Game Log — 7 มินิเกม + LobbyA / LobbyB · srv1361159

+
+
+ + + + + + +
+
+
กำลังโหลด...
+ + + + diff --git a/www/html/testcase/data/bugs.json b/www/html/testcase/data/bugs.json index deb0bf4..91da784 100644 --- a/www/html/testcase/data/bugs.json +++ b/www/html/testcase/data/bugs.json @@ -1,1152 +1,1272 @@ -[ - { - "n": 2, - "name": "ตัวละครไม่แสดง", - "detail": "หน้าเลือกเกม Quiz battle ภาพตัวละครไม่แสดง", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "Minigame 1" - }, - { - "n": 3, - "name": "ระบบ join เกมมีปัญหา", - "detail": "เมื่อเข้ามินิเกม : บางคนเล่นเกมกะบอท บางคนเล่นกะเพื่อน แต่ละคนเข้ามินิเกมแล้วเจอกันบ้าง ไม่เจอกันบ้าง", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 5, - "name": "ระบบ Host มีปัญหา - 2", - "detail": "เมื่อกดออกจากเกม Host เปลี่ยนจากพี่ต้อมเป็นพี่คิวอัติโนมัติ", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 6, - "name": "ระบบ Host มีปัญหา - 3", - "detail": "เมื่อเข้ามินิเกม ทุกคนเห็นตัวเองเป็น host หมดเลย แต่กดเริ่มเกมไม่ได้", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 7, - "name": "ระบบสีตัวละคร ยังใช้ไม่ได้", - "detail": "สีตัวละครเปลี่ยนไปเรื่อย ไม่รุ้ว่าเราสีอะไรแน่ งงไปหมด", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 8, - "name": "หน้าเข้าร่วมเกมส์", - "detail": "ห้องไหนที่เริ่มเกมส์ไปแล้ว เมื่อกดเข้าร่วมแสดง popup alert error ปรับดีไซต์ให้สวยงามกว่านี้", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 9, - "name": "เกมวิ่งขึ้นบันได ผิด flow", - "detail": "ปรับ flow ให้ถูกต้อง คือ ใครตกจอตาย แต่ถ้าขึ้นไปอยู่บนสุดแล้วจะกระโดดไม่ไปต่อเฉยๆ แต่ไม่ตาย", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "Minigame 5" - }, - { - "n": 10, - "name": "เกมวิ่งขึ้นบันได", - "detail": "เวลาหมดแล้ว ให้เล่นรอบ 2 auto / รอบ 3 มี popup ให้กดเล่นต่อ", - "prio": "", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "Minigame 2" - }, - { - "n": 11, - "name": "เกมยิงลูกโป่ง", - "detail": "ฉากเกมยิงลูกโป่ง (MG7 Mega Virus) เล็กไป — ต้องการให้ภาพพื้นหลัง (BG) เต็มจอ แต่การเดิน (movement) และพื้นที่เล่น (area) ต่างๆ คงเดิม", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "แก้แล้ว (deploy prod play.js?v=0.006250401): BG ของ MG7 เป็นภาพ 16:9 (1920x1080) แต่โค้ดเดิม map ภาพเข้า world rect 4:3 (640x480) ทำให้ภาพถูกบีบ + เหลือขอบดำซ้าย-ขวา = ฉากดูเล็ก. เปลี่ยนการวาด BG ของ balloon_boss เป็นแบบ cover เต็มจอ (รักษาสัดส่วน 16:9, crop ส่วนเกิน, จัดกึ่งกลาง) วาดแยกจาก world layer → กล้อง/movement/area ของเกมคงเดิมทุกอย่าง เปลี่ยนเฉพาะพื้นหลังให้เต็มจอ. ไฟล์: Game/public/js/play.js (draw loop, สาขา isBalloonBoss cover-fill).", - "cat": "Minigame 7" - }, - { - "n": 14, - "name": "Room ID", - "detail": "เวลาจอยห้องให้กรอกเเค่ ID กับรหัสที่สร้าง", - "prio": "ต่ำ", - "status": "แก้ไขแล้ว", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 16, - "name": "grid กับ ฉาก ไม่ตรงกัน", - "detail": "เกม Quiz battle เกมที่ 1 ฉากกับกริดไม่ตรงกัน ทำให้เดินทะลุ หรือเดินติดในจุดที่ไม่ควรติด", - "prio": "สูง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "Minigame 1" - }, - { - "n": 17, - "name": "ตัวละครวิ่งเร็วเกินไป", - "detail": "เกม Quiz battle เกมที่ 1 ตัวละครผู้เล่นเร็วเกินไป", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-06-25", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 18, - "name": "Oauth Social log-in error", - "detail": "ไม่สามารถ log-inด้วย facebook / Google ได้ path: /oauth-public.php", - "prio": "ต่ำ", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 19, - "name": "หน้า login", - "detail": "นโยบายความเป็นส่วนตัวกดไม่ได้", - "prio": "กลาง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 20, - "name": "แสดงเลข version ตรงมุมจอ", - "detail": "แสดงเลข version ตรงมุมจอขวาบนหรือขวาล่างก็ได้", - "prio": "ต่ำ", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — badge มุมขวาล่าง อ่าน ?v= อัตโนมัติ", - "cat": "อื่นๆ" - }, - { - "n": 21, - "name": "ลดขนาดตัวละคร", - "detail": "ลดขนาดตัวละครในห้อง lobby ลงอีก 2เท่า", - "prio": "สูง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 22, - "name": "เปลี่ยน ui popup ตอนพูดบนหัว", - "detail": "แก้ png seq บนหัวใหม่ เป็นแบบอื่น", - "prio": "กลาง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 23, - "name": "ชื่่อตัวละครทับลำโพง/ทับ bubble", - "detail": "ใน lobby ขยับชื่อลงมาอย่าให้ทับ", - "prio": "กลาง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 25, - "name": "เพิ่ม ข้อความแจ้งเตือนอัพเดทกลางจอ", - "detail": "ถ้ามีการอัพเดทซีน อัพเดทสีตัวละครให้มีป็อปอัพเด้งมากลางหน้าจอ ว่ามีการอัพเดทและให้รอโหลด", - "prio": "สูง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 26, - "name": "เอาเงาตัวละครออก 1 อัน", - "detail": "ตอนนี้ตัวละครมีเงา 2 ชั้น เอาออกไป 1 ชั้น", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — ปิด default-shadow overlay เหลือเงาในสไปรต์", - "cat": "อื่นๆ" - }, - { - "n": 27, - "name": "optimize ตัวละครให้เล็กลงอีก มันโหลดช้า", - "detail": "", - "prio": "สูง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 28, - "name": "ปุ่ม start ใน lobby", - "detail": "ทำให้ดูโต้ตอบหน่อย เช่นพอเดินเข้าไปใกล้ให้มัน glow ขึ้นมา", - "prio": "กลาง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 32, - "name": "เอา popup ขออนุญาตใช้ไมค์ออกไปเลย น่ารำคาญ", - "detail": "", - "prio": "สูง", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 34, - "name": "เกม ใช่ หรือไม่ : ตอนจบบั๊ค", - "detail": "ตอนสรุปคะแนน ยังนับเวลาอยุ่ และขึ้น gameover ทับหน้าสรุปคะแนน", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG1 ใช่/ไม่: capture mg1AnyScore ก่อนล้าง (gameover) + guard quiz-phase หลังจบ (timer)", - "cat": "Minigame 1" - }, - { - "n": 35, - "name": "เกมวิ่ง : กดปุ่มไม่กระโดด", - "detail": "ปุ่ม jump กดแล้วไม่กระโดด", - "prio": "วิกฤต", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "Minigame 5" - }, - { - "n": 36, - "name": "เกมวิ่ง : ตอนจบบั๊ค วิ่งเลยฉาก", - "detail": "ตอนจบบั๊ค วิ่งเลยฉาก (ในบางรอบ)", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG2 วิ่งเลยฉาก: walk-to-finish ค่อยๆเดินเข้าเส้น + clamp ไม่เลยเส้น", - "cat": "Minigame 2" - }, - { - "n": 40, - "name": "จำนวนคน ให้เล่นได้ตั้งแต่ 4-6 คน", - "detail": "กรณีเลือกผู้เล่นตั้งแต่ 4 คนขึ้นไปไม่ต้องมี bot", - "prio": "วิกฤต", - "status": "เปิด", - "updated": "", - "note": "", - "cat": "อื่นๆ" - }, - { - "n": 41, - "name": "เกม หยิบแผ่นป้าย", - "detail": "ต้องเล่นให้ครบ 10 ข้อก่อน ค่อยจบเกม (ตอนนี้เล่นถูก 1 ข้อ จบเกมเลย)", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG4 หยิบป้าย: ใช้ map จริง (currentPlayMapId) เปิด mission flow + default 10 ข้อ", - "cat": "Minigame 4" - }, - { - "n": 42, - "name": "เกม กระโดด", - "detail": "ท่ากระโดดแปลก ยืนซอยเท้าทำไม", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "anim ไม่ sync: ยืนบนแท่นที่เลื่อนลง world Y เปลี่ยนทุกเฟรม → ปลายทางเดา isWalking จาก delta = เห็น ซอยเท้า ตลอด. แก้: ส่ง isWalking จริงใน move (jsWalking) + ปลายทางใช้ค่านั้น (play.js ~19162/20501/24930). v0.006260100", - "cat": "Minigame 5" - }, - { - "n": 43, - "name": "เกม กระโดด", - "detail": "ขั้นบันไดมันถี่ไป บางทีกระโดดแล้วเบิ้ลขั้นเลย ปรับให้ระยะห่างแต่ละขั้นมันเท่ากัน", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "ขั้นถี่/ไม่เท่า → เบิ้ลขั้น+ติดรอแท่ง. แก้ layout mnptfts2: ระยะเท่ากันทุกขั้น (gap 3 แถว, rows 2/5/8/11/14/17, zigzag กว้าง6) ในโซนปลอดภัย (เลี่ยง hazard แถว 0/18/19). reach apex 4.5 > 3 = ถึงทุกขั้น (ไม่ติดรอ #43b) + ขั้นถัดไป-อีกขั้น 6 แถว > reach = กระโดดข้ามไม่ได้ (ไม่เบิ้ล). seam รอยต่อ wrap ห่าง 5 แถว 1 จุด/รอบ (h=20 หาร 3 ไม่ลง). restart service หลังแก้แมป | รอบ2: (ก) แก้ลายแท่นบิด — เดิม width เท่ากัน(6)ทำให้ภาพถูกบีบ → ตั้ง width = ขนาดจริงของรูป (เล็ก=var3 7ช่อง/212px, กลาง=var2 13ช่อง/389px) วาดได้สัดส่วนจริงไม่บิด. (ข) ติดบนสุด = seam รอยต่อ wrap 5แถว >ระยะกระโดด(apex4.5) → bump jumpSurviveJumpHeightMult 1.5→1.7 (apex 5.1) ให้ข้าม seam ได้ แต่ข้าม2ขั้น(6แถว)ยังไม่ได้. var1(ใหญ่ 46ช่อง)ใหญ่เกินแมป35 ใช้ไม่ได้ไม่บิด → ใช้ เล็ก/กลาง", - "cat": "Minigame 5" - }, - { - "n": 44, - "name": "เกมกระโดด", - "detail": "อีเวนท์พิเศษ (ทนาย/เทพความรู้) หายไป", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG5 อีเวนต์พิเศษ: เพิ่ม branch jump_survive วาง icon บน platform", - "cat": "Minigame 5" - }, - { - "n": 46, - "name": "เกมยิิงอุกาบาด", - "detail": "สีตัวละครยังไม่ตรงกับที่เลือกมา", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6 สี: ยาน Rocket-1..8 ตาม lobby theme ของผู้เล่น", - "cat": "Minigame 6" - }, - { - "n": 47, - "name": "เกมยิิงอุกาบาด", - "detail": "อุกาบาดให้พุ่งเร็วกว่านี้ และลูกเล็กกว่านี้เกมง่ายไป", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6: อุกาบาตเร็วขึ้น (+~50%) + เล็กลง (48->34/11->8)", - "cat": "Minigame 6" - }, - { - "n": 50, - "name": "ผู้เล่น 6 คน: บางเกมเล่นได้แค่ 5 (คนที่ 6 หายจากสรุป)", - "detail": "สรุปคะแนนฝั่ง client slice(0,5) ตัดคนที่ 6 — แก้เป็น slice(0,6) ทุกมินิเกม (MG1/3/4/5/6/7)", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", - "cat": "Minigame 1" - }, - { - "n": 51, - "name": "ผู้เล่น 6 คน: คะแนนตอนจบไม่ตรงกันแต่ละจอ", - "detail": "MG1 merge คะแนน server / MG6,7 merge คะแนน host -> ครบ 6 ตรงกัน (MG3 ยัง local)", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-24", - "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", - "cat": "Minigame 6" - }, - { - "n": 53, - "name": "เพิ่มหน้าโหลด (loading) ตอนเข้าเกม", - "detail": "overlay spinner กันเห็นจอเปล่า/asset แว่บ ซ่อนเมื่อ draw เฟรมแรก + safety 8s", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)", - "cat": "อื่นๆ" - }, - { - "n": 54, - "name": "host โหลดนานมากตอนเข้าเกม", - "detail": "optimize asset/char (เกี่ยว row 28) — loading overlay ช่วยซ่อน แต่ยังโหลดช้าจริง", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-06-25", - "note": "loading overlay บังอาการแล้ว · root = canvas tint ภาพตัวละครใหญ่ (source ไม่อยู่ใน repo) → ต้อง optimize ภาพแยก", - "cat": "อื่นๆ" - }, - { - "n": 56, - "name": "ตอนกลับ LobbyB โชว์รูปแรกก่อนแล้วค่อยถูก", - "detail": "เหมือน #1 การ์ด — ต้องใส่ loading state ตอน return LobbyB ด้วย (มี room-loading-overlay อยู่แต่ซ่อนเร็วไป)", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "✅ แก้แล้ว 2026-06-25 v0.0308 — การ์ดมี spinner (#1) + room-loading overlay ใส่ % + รอ char preload เสร็จก่อนซ่อน + safety 12s", - "cat": "อื่นๆ" - }, - { - "n": 58, - "name": "ปุ่ม \"เล่นเกมต่อ\" ไม่รู้ว่าค้างหรือรอคนอื่น", - "detail": "text \"รอผู้เล่นอื่น (X/Y)\" ถูก set อยู่แล้วแต่ CSS font-size:0 ซ่อน → เพิ่ม .is-waiting CSS โชว์ข้อความ+spinner แทนรูปปุ่ม", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", - "cat": "อื่นๆ" - }, - { - "n": 60, - "name": "ทำหน้า testcase บนเซิร์ฟเวอร์", - "detail": "srv1361159.hstgr.cloud/testcase — bug list + filter/search/นับสถานะ generate จาก xlsx", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", - "cat": "อื่นๆ" - }, - { - "n": 62, - "name": "MG1/MG4 user/bot ติดกัน (เดินไม่ผ่าน)", - "detail": "เพิ่ม anti-stuck: ดันชนเพื่อน → เขย่า (shake) + ปลดล็อกเดินผ่านกันชั่วคราว คงกำแพง/area/quiz-lock; ทั้งผู้เล่นและบอท", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "✅ แก้แล้ว 2026-06-25 v0.006250100 (รอเทส)", - "cat": "Minigame 4" - }, - { - "n": 65, - "name": "อีเวนต์พิเศษ (ทนาย/เทพ) = 1 ใน 3 (ตามดีไซน์)", - "detail": "ดีไซน์: 3 เกม/LobbyB เล่นได้เกมละ 3 ครั้ง · อีเวนต์พิเศษ 1/3 ต่อครั้ง (1 ใบ) · Test Mode บังคับ. revert การแก้ \">1 ต่อรอบ\" กลับเป็น 1 ใบ/ครั้งที่ติด", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "✅ revert 2026-06-25 — กลับเป็น 1/3 (1 ใบ/ครั้งที่ติด) ตามดีไซน์ · ยืนยันโครงสร้าง 3เกม×3ครั้ง + disruptor toggle (>4คน/Test Mode) ถูกต้อง", - "cat": "อื่นๆ" - }, - { - "n": 71, - "name": "หน้าผลชนะ (โพเดียม) ตัวละครลอย ไม่ยืนบนพื้น", - "detail": "หน้า \"ยินดีด้วย ทีมนักสืบ ชนะ!\" ตัวละครผู้ชนะลอยอยู่กลางอากาศ ไม่ยืนบนพื้นเวที", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-25", - "note": "แก้แล้ว (deploy prod room-lobby.js?v=0.0312): พื้นหลัง win-bg เดิม center/cover ทำให้พื้นเวทีถูก crop เลื่อน + podium ตั้ง bottom:9% ตัวละครเลยลอย. แก้เป็น (1) ปักพื้นหลังไว้ขอบล่าง background-position:center bottom -> พื้นเวทีติดขอบล่างจอเสมอทุกสัดส่วน (2) ลด podium bottom 9% -> 2% ให้ตัวละครยืนบนพื้น. ไฟล์: Game/public/js/room-lobby.js (injectFinalResultStyle, #trial-final-podium).", - "cat": "อื่นๆ" - }, - { - "n": 74, - "name": "แถวหน้าตัวละคร (ready ใต้ปุ่ม HOW TO) ค้างบนจอระหว่างเล่นเกม", - "detail": "หลังกด HOW TO/Ready แล้วเริ่มเกม กล่องแถว avatar คนที่กด ready (ใต้ปุ่ม) ไม่ถูกซ่อน ค้างอยู่กลางจอล่างทับฉากเกม (เห็นชัดใน MG3 stack) — กระทบ MG1/3/5/6", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "กล่องลอย #gch-ready-avatars-float ถูกซ่อนเฉพาะ MG2 (dismissGauntletCrownHowtoOverlayPlay) + MG4 (quiz_carry) เท่านั้น. begin*CountdownThenRun ของ MG1/3/5/6 (stack/jump/space/quiz) ตั้ง gauntletCrownHowtoVisible=false แต่ไม่เรียก hideReadyAvatarsFloat() → กล่องค้าง display:flex ทั้งเกม (ไม่มี driver เรียกใหม่ตอน live). แก้: เพิ่ม hideReadyAvatarsFloat() ในทั้ง 4 ฟังก์ชันเริ่มเกม (play.js). v0.006260101", - "cat": "อื่นๆ" - }, - { - "name": "block สีไม่ตรงในฝั่ง client", - "detail": "", - "prio": "กลาง", - "status": "เปิด", - "note": "", - "cat": "Minigame 3", - "updated": "2026-06-26", - "n": 75 - }, - { - "name": "บางที client บางคนเล่นไม่ได้", - "detail": "", - "prio": "กลาง", - "status": "เปิด", - "note": "", - "cat": "Minigame 3", - "updated": "2026-06-26", - "n": 76 - }, - { - "n": 82, - "name": "MG1: หน้า How-to ใหม่ (SAFE/SCAM)", - "detail": "เปลี่ยน how-to เป็น \"วิเคราะห์ภัยร้าย\" (เดิมเป็นดีไซน์ MG4)", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: เข้า MG1 → หน้า HOW TO PLAY ต้องเป็น \"วิเคราะห์ภัยร้าย ปกป้องระบบให้ปลอดภัย\" มี SAFE/SCAM + การ์ด COMMON/RARE/LEGENDARY", - "cat": "Minigame 1" - }, - { - "n": 83, - "name": "MG1: ปุ่ม READY 2 สถานะ", - "detail": "ยังไม่กด=ฟ้า / กดแล้ว=เขียว+เครื่องหมายถูก", - "prio": "ต่ำ", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: หน้า ready MG1 → ปุ่มฟ้า · กด → เขียวมี ✓ · กดอีกที → กลับฟ้า", - "cat": "Minigame 1" - }, - { - "n": 84, - "name": "MG1: auto-start เมื่อทุกคน ready", - "detail": "ครบ ready → server เริ่มเอง ไม่ต้องรอ host กด START", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: ทุกคนกด READY ครบ → เกมเริ่มเอง (นับ 3-2-1) ไม่ต้องมีใครกด START · ลองให้ host ออกก่อนคนสุดท้าย ready → ยังเริ่มได้", - "cat": "Minigame 1" - }, - { - "n": 85, - "name": "MG1: คน/บอท วิ่งเร็วเท่ากัน", - "detail": "movement time-based", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: 2 เครื่องเล่น MG1 พร้อมกัน → คนวิ่งเร็วเท่ากัน · บอทวิ่งเข้าโซน SAFE/SCAM สม่ำเสมอ", - "cat": "Minigame 1" - }, - { - "n": 86, - "name": "MG1: ผีหาย (avatar ไม่ซ้ำ)", - "detail": "server ส่ง play-roster ทุก 1.5s ตัดตัวซ้ำระหว่างเล่น", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: เล่น MG1 แล้วมีคนหลุด/reconnect → ต้องไม่มีตัวซ้ำขยับไม่ได้ค้างในสนาม", - "cat": "Minigame 1" - }, - { - "n": 87, - "name": "MG1: บอทเดินต่อแม้ host หลุด", - "detail": "server เป็นเจ้าของการเดินบอท quiz", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: เล่น MG1 กับบอท · host รีเฟรช/ออกกลางเกม → บอทต้องเดิน+วิ่งเข้าโซนต่อ ไม่หยุดนิ่ง", - "cat": "Minigame 1" - }, - { - "n": 88, - "name": "MG1: คะแนนบอทตรงกันทุกจอ", - "detail": "server คิดคะแนนบอท (เดิม host คิดเอง ไม่ sync)", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-26", - "note": "วิธีเทส: 2 เครื่องเล่น MG1 กับบอท → คะแนนบอทบนกระดานต้องตรงกันทั้ง 2 จอทุกข้อ", - "cat": "Minigame 1" - }, - { - "n": 89, - "name": "MG1: ผีบอทอยู่ในโซนผี ไม่ทับ SAFE/SCAM", - "detail": "บอทตอบผิด=ผี ตำแหน่งจริงอยู่ในโซนผี (กลาง-บน) — แก้การวาดภาพผีที่เคยห้อยลงทับสนามคำตอบ", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: เล่น MG1 กับบอท ให้มีบอทตอบผิด → ตัวผีต้องลอยอยู่แถบกลาง-บน (เหนือ SAFE/SCAM) ไม่ห้อยลงทับช่อง SAFE/SCAM", - "cat": "Minigame 1" - }, - { - "n": 90, - "name": "MG1: บอทเดินลื่น ไม่วาป", - "detail": "บอท quiz เดินจาก server (time-based) + lerp นุ่ม", - "prio": "กลาง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: เล่น MG1 กับบอท → บอทเดินเข้าโซน SAFE/SCAM ลื่น ไม่กระตุก ไม่วาปไปมา", - "cat": "Minigame 1" - }, - { - "n": 92, - "name": "Admin: ตัวเลขผู้ใช้ออนไลน์ + ความจุ", - "detail": "widget 👥 N/500 สบาย/เริ่มหนัก/หนัก (เขียว/เหลือง/แดง) บนแถบ Admin", - "prio": "ต่ำ", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: เปิดหน้า Admin → มุมขวาบนมี 👥 ตัวเลขผู้เล่นออนไลน์ · hover เห็น CPU/RAM/ห้อง/socket · อัปเดตทุก 10 วิ", - "cat": "อื่นๆ" - }, - { - "n": 93, - "name": "MG1: หลุดกลางเกม → บอท takeover ในที่เดิม", - "detail": "บอทแทนทันที สี/ตำแหน่ง/สถานะเดิม (คะแนนต่อ, ตกรอบ→ผีต่อ)", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: เล่น MG1 หลายคน → คนนึงปิดแท็บกลางเกม → ตัวละครต้องไม่หาย กลายเป็นบอทสีเดิม ตำแหน่งเดิม เล่นต่อ + มี \"(บอท)\" หลังชื่อ", - "cat": "Minigame 1" - }, - { - "n": 94, - "name": "MG1: หลุดแล้วกลับมาใน 10วิ → คืนสิทธิ์เล่น", - "detail": "reconnect-undo: ถอนบอท takeover คืนตัวจริง (กันตัวซ้ำ)", - "prio": "สูง", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: เล่น MG1 → refresh กลางเกม (กลับมาใน 10วิ) → ต้องเล่นเองต่อได้ ไม่มีบอทซ้ำกับตัวเรา", - "cat": "Minigame 1" - }, - { - "n": 95, - "name": "MG1: host หลุดในหน้า how-to → เล่นต่อได้", - "detail": "expectedActive ลดตอนคนออกถาวร → host ใหม่กด START ได้ (เดิมค้าง \"รอ x/y\")", - "prio": "วิกฤต", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: เข้า MG1 how-to หลายคน → host ปิดแท็บ → รอ ~10วิ → host ใหม่กด START ได้ + บอทสีเดิมเล่นแทน", - "cat": "Minigame 1" - }, - { - "n": 98, - "name": "MG how-to: เห็น countdown ตอนมีคนหลุด", - "detail": "grace banner z-index ขึ้นเหนือ how-to overlay", - "prio": "ต่ำ", - "status": "แก้ไขแล้ว", - "updated": "2026-06-27", - "note": "วิธีเทส: ในหน้า how-to มีคนหลุด → ต้องเห็นแถบ \"🔌 ... หลุด — รอกลับมา N วิ\" ลอยเหนือ how-to (ไม่ถูกบัง)", - "cat": "Minigame 1" - }, - { - "n": 99, - "name": "Login + ตั้งชื่อผู้เล่น", - "detail": "ล็อกอิน/ตั้งชื่อได้ ชื่อไปแสดงในห้อง/เกม", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เปิดเกม → login → ตั้งชื่อ → เข้า Main Menu → ชื่อตรงทุกหน้า", - "cat": "อื่นๆ" - }, - { - "n": 100, - "name": "Main Menu นำทางครบ", - "detail": "ปุ่มทุกอันใน Main Menu พาไปหน้าถูก (สร้าง/เข้าห้อง, โปรไฟล์, how-to)", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: กดทุกปุ่มใน Main Menu → ไปหน้าตรงป้าย + ปุ่มย้อนกลับได้", - "cat": "อื่นๆ" - }, - { - "n": 101, - "name": "สร้างห้อง Public", - "detail": "สร้างห้องสาธารณะได้ เข้า LobbyA", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Create Room → Public → ตั้งชื่อ → ยืนยัน → เข้า LobbyA เป็น host", - "cat": "อื่นๆ" - }, - { - "n": 102, - "name": "สร้างห้อง Private (PIN 4 หลัก)", - "detail": "ห้องส่วนตัวต้องใส่ PIN 4 หลัก ล็อก 🔒", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Create Room → Private → ใส่ PIN 4 หลัก → ยืนยัน → ห้องขึ้น 🔒 · ใส่ PIN ไม่ครบ = ยืนยันไม่ได้", - "cat": "อื่นๆ" - }, - { - "n": 103, - "name": "สรุปจำนวนบอทถูก (1-3 คน)", - "detail": "เลือก 1/2/3 คน → บอท 5/4/3 (รวม 6)", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือกจำนวนผู้เล่น 1,2,3 → summary แสดง '+5/+4/+3 Bot'", - "cat": "อื่นๆ" - }, - { - "n": 104, - "name": "เลือก ≥ 4 คน = ไม่มีบอท", - "detail": "เลือก 4/5/6 คน → 0 Bot ทั้ง summary และในเกมจริง", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือก 4,5,6 → summary '+0 Bot' → เข้าเกมจริงต้องไม่มีบอทเติม (รอคนจริง)", - "cat": "อื่นๆ" - }, - { - "n": 105, - "name": "เข้าห้อง Public จากรายการ", - "detail": "เห็นรายชื่อห้อง public + กดเข้าได้", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Join Room → เห็นห้อง public → กดเข้า → เข้า LobbyA เป็น client", - "cat": "อื่นๆ" - }, - { - "n": 106, - "name": "เข้าห้อง Private ด้วยชื่อ+PIN", - "detail": "เข้าห้องส่วนตัวได้เมื่อใส่ชื่อ+PIN ถูก · ผิด = เข้าไม่ได้", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Join Room → Private → ใส่ชื่อห้อง+PIN → ถูก=เข้าได้ / ผิด=แจ้ง error", - "cat": "อื่นๆ" - }, - { - "n": 107, - "name": "ห้องเต็ม", - "detail": "ห้องเต็ม (ครบ maxPlayers) เข้าไม่ได้ แจ้งเตือน", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ห้องมีคนครบ → คนใหม่กดเข้า → แจ้ง 'ห้องเต็ม (n/max)'", - "cat": "อื่นๆ" - }, - { - "n": 108, - "name": "ฉาก LobbyA โหลดเร็ว", - "detail": "เข้าฉากภายใน ~2 วิ ไม่ค้างขาว", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เข้า LobbyA → จับเวลา loading-hide < ~3 วิ (เดิมเคยค้าง 15-40 วิ)", - "cat": "LobbyA" - }, - { - "n": 109, - "name": "กดพร้อม (Ready)", - "detail": "toggle พร้อม/ไม่พร้อม ได้ ทุกคนเห็นตรงกัน", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: กด Ready → สถานะเปลี่ยน + คนอื่นเห็น · กดซ้ำ = ยกเลิกพร้อม", - "cat": "LobbyA" - }, - { - "n": 110, - "name": "ห้องแต่งตัว (กด F)", - "detail": "เดินไปจุดเขียว กด F เปิดห้องแต่งตัว เปลี่ยนชุด/ตัวละครได้", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: LobbyA → เดินไปไอคอนห้องแต่งตัว → กด F → popup เปิด → เปลี่ยนได้ทุกคน", - "cat": "LobbyA" - }, - { - "n": 111, - "name": "Host Console (กด F, เฉพาะ host)", - "detail": "host เดินไปไอคอนชมพู กด F เปิด console · client กดไม่ได้", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: host → จุดชมพู → F → console เปิด · client → F → แจ้ง 'เฉพาะ Host'", - "cat": "LobbyA" - }, - { - "n": 112, - "name": "ปุ่มกลางแมป (START GAME) + glow", - "detail": "ทุกคน+host พร้อม → ปุ่มกลางเรือง glow เหลืองกระพริบ → host กด F เริ่มเกม", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ทุกคนกด Ready ครบ → ปุ่ม START กลางแมป glow เหลือง → host เดินเข้าใกล้ กด F → เข้า wizard เลือกระดับ/คดี", - "cat": "LobbyA" - }, - { - "n": 113, - "name": "บอทเดินในฉาก ไม่หลุดขอบ", - "detail": "บอท LobbyA เดินในฉากหลัก ไม่ติดขอบ/นอกฉาก", - "prio": "ต่ำ", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบอทใน LobbyA → ต้องเดินวนในฉาก ไม่ค้างขอบ/หลุดออกนอก", - "cat": "LobbyA" - }, - { - "n": 114, - "name": "เสียงพูดใน Lobby (mic)", - "detail": "เปิด/ปิดไมค์ได้ ได้ยินเสียงกัน", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: หลายคน → เปิดไมค์ → พูด → คนอื่นได้ยิน · กดปิด = เงียบ", - "cat": "LobbyA" - }, - { - "n": 115, - "name": "MG1 เข้าเกม + how-to", - "detail": "เข้า MG1 เห็น how-to → เริ่มพร้อมกัน", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เริ่มเกม → เข้า MG1 → how-to ขึ้น → ปิด/countdown → เริ่มพร้อมกันทุกคน", - "cat": "Minigame 1" - }, - { - "n": 116, - "name": "MG1 ตอบถูก/ผิด", - "detail": "ตอบถูกได้คะแนน · ตอบผิดกลายเป็นผี/ล็อก", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เดินเข้าโซนจริง/ไม่จริง → ถูก=คะแนน+ / ผิด=ล็อก(ผี) ตรงกันทุกเครื่อง", - "cat": "Minigame 1" - }, - { - "n": 117, - "name": "MG1 บอทเล่น + คะแนนตรง", - "detail": "บอทตอบเอง คะแนน/สถานะบอทตรงทุกเครื่อง", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG1 → ตอบเอง + คะแนน/ล็อก ตรงทุก client", - "cat": "Minigame 1" - }, - { - "n": 118, - "name": "MG1 จบเกม + สรุปคะแนน", - "detail": "หมดเวลา/ทุกคนตอบผิด → จบ + สรุปคะแนนถูก", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ (complete/timeup) + คะแนน/coin ตามอันดับ", - "cat": "Minigame 1" - }, - { - "n": 119, - "name": "MG2 วิ่ง+กระโดดหลบ", - "detail": "เดิน/วิ่ง + กระโดดข้ามสิ่งกีดขวางได้ ชน=เสียแต้ม", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG2 → วิ่งหลบ → กระโดดข้าม obstacle · โดนเลเซอร์/สิ่งกีดขวาง = penalty", - "cat": "Minigame 2" - }, - { - "n": 120, - "name": "MG2 ปรับความสูง/เวลากระโดด (Admin)", - "detail": "ปรับ 'ความสูงกระโดด' + 'เวลากระโดดขึ้น-ลง' ใน Admin → มีผลรอบถัดไป", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: Admin → Minigame-2 → ตั้งความสูง/tick → เริ่มเกมใหม่ → กระโดดสูง/เร็วตามตั้ง", - "cat": "Minigame 2" - }, - { - "n": 121, - "name": "MG2 finish/จบ", - "detail": "วิ่งถึงเส้นชัย/หมดเวลา → จบถูก ไม่ค้าง", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG2 → ถึงปลายทาง หรือหมดเวลา → ฉากจบ + คะแนน", - "cat": "Minigame 2" - }, - { - "n": 122, - "name": "MG3 ต่อบล็อกตรง", - "detail": "เล็งตรง support → บล็อกลงตรง ไม่เฉียง ต่อได้", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG3 → กด DROP ตอนบล็อกอยู่เหนือบล็อกก่อน → ลงตรงดิ่ง ทับพอดี (ไม่ไถลเฉียง)", - "cat": "Minigame 3" - }, - { - "n": 123, - "name": "MG3 หัวใจรวม + จบเกม", - "detail": "หัวใจ (System Integrity) เป็นของรวม · ใครพลาดก็ลด · พลาด 3 ครั้ง = จบ", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: หลายคน → ผลัดกันวาง · วางเยื้องมาก = หัวใจลด (ทุกคนเห็นเท่ากัน) · ครบ 3 = จบเกม", - "cat": "Minigame 3" - }, - { - "n": 124, - "name": "MG3 บอทวางตรง support", - "detail": "บอทวางบล็อกลงตรงยอดตึก ไม่เฉียง/หลุด", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG3 → วางลงตรงบล็อกก่อนหน้า (ตึกไม่เป๋)", - "cat": "Minigame 3" - }, - { - "n": 125, - "name": "MG3 สีบล็อกตรง avatar", - "detail": "สีบล็อกที่วาง = สีตัวละครคนวาง ทุกเครื่องตรงกัน", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: วาง → สีบล็อกตรงสี avatar คนวาง (host/client/บอท ตรงกัน)", - "cat": "Minigame 3" - }, - { - "n": 126, - "name": "MG3 ตัวห้อยแกว่งลื่น", - "detail": "บล็อกที่ห้อยแกว่งลื่น ไม่กระตุก", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบล็อกห้อย → แกว่งนุ่ม ไม่กระตุก/วาป", - "cat": "Minigame 3" - }, - { - "n": 127, - "name": "MG4 หยิบคำ-ส่งคำตอบ", - "detail": "หยิบ word option จากโต๊ะข้าง → เดินไปส่งที่โต๊ะกลาง", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG4 → เดินไปโต๊ะ option → หยิบคำ → เดินไปโต๊ะ submit → ส่ง → ถูก=คะแนน", - "cat": "Minigame 4" - }, - { - "n": 128, - "name": "MG4 บอทไม่ยืนบนโต๊ะกลาง", - "detail": "บอทไม่ spawn/ยืนติดบนคอนโซลกลาง เดินหยิบ-ส่งปกติ", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG4 → ไม่มีบอทค้างยืนบนโต๊ะกลาง · บอทหยิบ-ส่งได้", - "cat": "Minigame 4" - }, - { - "n": 129, - "name": "MG4 จบ + คะแนน", - "detail": "หมดเวลา/ครบข้อ → จบ + คะแนนถูก", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ + คะแนนตามส่ง", - "cat": "Minigame 4" - }, - { - "n": 130, - "name": "MG5 กระโดดขึ้นแท่นเอาตัวรอด", - "detail": "กระโดดขึ้นแท่นตามสายพานเลื่อน ตก= ตกรอบ", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG5 → กระโดดขึ้นแท่น หนีพื้นเลื่อน · ตกล่าง = eliminated", - "cat": "Minigame 5" - }, - { - "n": 131, - "name": "MG5 สลับแท็บ (unfocus) ตกเนียน", - "detail": "สลับแท็บออก → ตัวละครตกลงต่อ คนอื่นเห็นลื่น (ไม่ลอยค้าง/กระตุก)", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG5 กำลังเล่น → สลับไปแท็บอื่น 5 วิ → คนอื่นเห็นตัวเราไหลลงเนียน", - "cat": "Minigame 5" - }, - { - "n": 132, - "name": "MG5 บอท + จบเกม", - "detail": "บอทกระโดดเอง · จบเกม (all-dead/timeup) + เกรด", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG5 กระโดดเอง → เล่นจนจบ → เกรด/คะแนนถูก", - "cat": "Minigame 5" - }, - { - "n": 133, - "name": "MG6 ยิงอุกาบาต", - "detail": "บังคับยาน + ยิง หลบอุกาบาต โดน 3 ครั้ง=ตาย", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG6 → ขยับยาน ยิง · โดนอุกาบาต 3 = eliminated", - "cat": "Minigame 6" - }, - { - "n": 134, - "name": "MG6 บอทขยับทุก client", - "detail": "บอททุกตัวขยับเห็นตรงกันทุกเครื่อง (ไม่มีบอทค้าง)", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: หลายคน MG6 → บอททุกตัวต้องขยับบนทุกจอ (ไม่มีตัวค้างนิ่งเฉพาะบางเครื่อง)", - "cat": "Minigame 6" - }, - { - "n": 135, - "name": "MG6 จบ + คะแนน", - "detail": "หมดเวลา/รอด → จบ + คะแนนถูก", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน/coin ตามอันดับ", - "cat": "Minigame 6" - }, - { - "n": 136, - "name": "MG7 ยิงบอส (Mega Virus)", - "detail": "เล็ง+ยิงบอส ลด HP บอส · บอสชนเรา=เสียหาย", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: MG7 → หมุนเล็ง ยิงบอส → HP บอสลด · บอสเคลื่อนที่ชน = เด้ง/เสียหาย", - "cat": "Minigame 7" - }, - { - "n": 137, - "name": "MG7 บอทยิง+หลบบอส", - "detail": "บอทยิงบอส + หลบบอส ไม่ค้างนิ่ง", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG7 → ยิงบอส + ดริฟต์หลบ ไม่ติดนิ่ง", - "cat": "Minigame 7" - }, - { - "n": 138, - "name": "MG7 จบ + คะแนน", - "detail": "บอสตาย/หมดเวลา → จบ + คะแนนถูก", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน", - "cat": "Minigame 7" - }, - { - "n": 139, - "name": "กลับ LobbyB หลังจบมินิเกม", - "detail": "จบรอบมินิเกม → กลับเข้า LobbyB (หน้าคดี)", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → interstitial → เข้า LobbyB", - "cat": "LobbyB" - }, - { - "n": 140, - "name": "หน้าเลือกผู้ต้องสงสัยเปิด", - "detail": "จบเคส → หน้าเลือกผู้ต้องสงสัยเปิดอัตโนมัติทุกคน", - "prio": "วิกฤต", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เล่นจบเคส → LobbyB → overlay เลือกผู้ต้องสงสัยขึ้นทุกเครื่อง (ไม่ค้าง/ไม่ขึ้น)", - "cat": "LobbyB" - }, - { - "n": 141, - "name": "ปุ่มกลาง (F) เปิดผู้ต้องสงสัยซ้ำ", - "detail": "ปิด overlay แล้ว → ปุ่มกลางแมป glow → กด F เปิดกลับ (แทนปุ่มลอยเดิม)", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ปิดหน้าผู้ต้องสงสัย → ปุ่มกลาง glow → เดินเข้าใกล้ กด F → overlay เปิดกลับ", - "cat": "LobbyB" - }, - { - "n": 142, - "name": "เสนอบทตัวป่วน", - "detail": "≥4 คน (หรือเปิด force) → มีการเสนอบทตัวป่วน กดรับ/ปฏิเสธได้ในเวลาที่ตั้ง", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: จบเคส → ระบบเสนอบทตัวป่วนให้ 1 คน → countdown (ตาม Admin) → กดรับ/ปฏิเสธ", - "cat": "LobbyB" - }, - { - "n": 143, - "name": "สืบผู้ต้องสงสัย (เก็บหลักฐาน)", - "detail": "สืบครบ 3 ผู้ต้องสงสัย (เล่นมินิเกม) → เก็บหลักฐานได้", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เลือกผู้ต้องสงสัย → เล่นมินิเกม → ได้หลักฐาน +1 ต่อคน", - "cat": "LobbyB" - }, - { - "n": 144, - "name": "โหวตชี้ตัวคนร้าย (พิจารณาคดี)", - "detail": "สืบครบ → โหวตชี้คนร้าย → ผลตัดสินถูก", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: สืบครบ 3 → เปิดพิจารณาคดี → โหวต → เฉลยถูก/ผิด", - "cat": "LobbyB" - }, - { - "n": 145, - "name": "หน้า Cyber Awareness หลังตัดสิน", - "detail": "หลังตัดสินคดี → หน้าให้ความรู้กฎหมายไซเบอร์ ก่อนกลับ Main Lobby", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ตัดสินคดีจบ → interstitial cyber-law → กลับ Main-Lobby", - "cat": "LobbyB" - }, - { - "n": 146, - "name": "Host หลุด → migration", - "detail": "host ปิด/หลุด → เลือก host ใหม่อัตโนมัติ เกมไปต่อได้", - "prio": "วิกฤต", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ระหว่างเกม host ปิดแท็บ → ~10วิ → host ใหม่ถูกตั้ง เกมไม่ค้าง", - "cat": "ระบบ disconnect" - }, - { - "n": 147, - "name": "ผู้เล่นหลุด → บอทแทน", - "detail": "คนหลุดกลางมินิเกม → บอทเล่นแทน (สีเดิม) ไม่ค้าง", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม ผู้เล่นปิดแท็บ → บอท takeover เล่นแทน (สีตามคนออก)", - "cat": "ระบบ disconnect" - }, - { - "n": 148, - "name": "แถบ grace ตอนมีคนหลุด", - "detail": "มีคนหลุด → แถบ '🔌 ... หลุด รอกลับ N วิ' ขึ้น (เหนือ how-to)", - "prio": "สูง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: คนหลุด → แถบ grace countdown ขึ้นทุกเครื่อง (ลอยเหนือ overlay)", - "cat": "ระบบ disconnect" - }, - { - "n": 149, - "name": "Reconnect กลับเข้าเกม", - "detail": "หลุดแล้วต่อกลับ → กลับเข้าห้อง/มินิเกมเดิมได้", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: หลุดเน็ตชั่วคราว → เชื่อมกลับ → auto re-join ห้องเดิม ไม่ต้องเริ่มใหม่", - "cat": "ระบบ disconnect" - }, - { - "n": 150, - "name": "คะแนน/COINS ตามอันดับ", - "detail": "จบมินิเกม → รับ coin ตามอันดับ (ผู้รอด)", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → ผู้รอดได้ coin [10,8,6,4,2,0] ตามอันดับ + คะแนนสะสมขึ้น leaderboard", - "cat": "อื่นๆ" - }, - { - "n": 151, - "name": "กระดานผู้นำ (Leaderboard)", - "detail": "คะแนนสะสม/high score แสดงถูกในกระดานผู้นำ", - "prio": "ต่ำ", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: เล่นจบ → เช็ค leaderboard → คะแนนอัปเดตถูก", - "cat": "อื่นๆ" - }, - { - "n": 152, - "name": "เสียงพูดในมินิเกม (MG1-7)", - "detail": "เปิดไมค์ในมินิเกม → ได้ยินกัน (WebRTC mesh)", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม → กดไมค์ → พูด → คนอื่นได้ยิน", - "cat": "อื่นๆ" - }, - { - "n": 153, - "name": "ออกจากห้อง (Exit)", - "detail": "ออกจาก Lobby → host ไป Create Room / client ไป Join Room", - "prio": "กลาง", - "status": "เปิด", - "updated": "2026-07-01", - "note": "🧪 Flow TC · วิธีเทส: LobbyA/B → กด Exit → host→Create, client→Join", - "cat": "อื่นๆ" - } +[ + { + "n": 2, + "name": "ตัวละครไม่แสดง", + "detail": "หน้าเลือกเกม Quiz battle ภาพตัวละครไม่แสดง", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 3, + "name": "ระบบ join เกมมีปัญหา", + "detail": "เมื่อเข้ามินิเกม : บางคนเล่นเกมกะบอท บางคนเล่นกะเพื่อน แต่ละคนเข้ามินิเกมแล้วเจอกันบ้าง ไม่เจอกันบ้าง", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 5, + "name": "ระบบ Host มีปัญหา - 2", + "detail": "เมื่อกดออกจากเกม Host เปลี่ยนจากพี่ต้อมเป็นพี่คิวอัติโนมัติ", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 6, + "name": "ระบบ Host มีปัญหา - 3", + "detail": "เมื่อเข้ามินิเกม ทุกคนเห็นตัวเองเป็น host หมดเลย แต่กดเริ่มเกมไม่ได้", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 7, + "name": "ระบบสีตัวละคร ยังใช้ไม่ได้", + "detail": "สีตัวละครเปลี่ยนไปเรื่อย ไม่รุ้ว่าเราสีอะไรแน่ งงไปหมด", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 8, + "name": "หน้าเข้าร่วมเกมส์", + "detail": "ห้องไหนที่เริ่มเกมส์ไปแล้ว เมื่อกดเข้าร่วมแสดง popup alert error ปรับดีไซต์ให้สวยงามกว่านี้", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 9, + "name": "เกมวิ่งขึ้นบันได ผิด flow", + "detail": "ปรับ flow ให้ถูกต้อง คือ ใครตกจอตาย แต่ถ้าขึ้นไปอยู่บนสุดแล้วจะกระโดดไม่ไปต่อเฉยๆ แต่ไม่ตาย", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 10, + "name": "เกมวิ่งขึ้นบันได", + "detail": "เวลาหมดแล้ว ให้เล่นรอบ 2 auto / รอบ 3 มี popup ให้กดเล่นต่อ", + "prio": "", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 2" + }, + { + "n": 11, + "name": "เกมยิงลูกโป่ง", + "detail": "ฉากเกมยิงลูกโป่ง (MG7 Mega Virus) เล็กไป — ต้องการให้ภาพพื้นหลัง (BG) เต็มจอ แต่การเดิน (movement) และพื้นที่เล่น (area) ต่างๆ คงเดิม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod play.js?v=0.006250401): BG ของ MG7 เป็นภาพ 16:9 (1920x1080) แต่โค้ดเดิม map ภาพเข้า world rect 4:3 (640x480) ทำให้ภาพถูกบีบ + เหลือขอบดำซ้าย-ขวา = ฉากดูเล็ก. เปลี่ยนการวาด BG ของ balloon_boss เป็นแบบ cover เต็มจอ (รักษาสัดส่วน 16:9, crop ส่วนเกิน, จัดกึ่งกลาง) วาดแยกจาก world layer → กล้อง/movement/area ของเกมคงเดิมทุกอย่าง เปลี่ยนเฉพาะพื้นหลังให้เต็มจอ. ไฟล์: Game/public/js/play.js (draw loop, สาขา isBalloonBoss cover-fill).", + "cat": "Minigame 7" + }, + { + "n": 14, + "name": "Room ID", + "detail": "เวลาจอยห้องให้กรอกเเค่ ID กับรหัสที่สร้าง", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 16, + "name": "grid กับ ฉาก ไม่ตรงกัน", + "detail": "เกม Quiz battle เกมที่ 1 ฉากกับกริดไม่ตรงกัน ทำให้เดินทะลุ หรือเดินติดในจุดที่ไม่ควรติด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 17, + "name": "ตัวละครวิ่งเร็วเกินไป", + "detail": "เกม Quiz battle เกมที่ 1 ตัวละครผู้เล่นเร็วเกินไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 18, + "name": "Oauth Social log-in error", + "detail": "ไม่สามารถ log-inด้วย facebook / Google ได้ path: /oauth-public.php", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 19, + "name": "หน้า login", + "detail": "นโยบายความเป็นส่วนตัวกดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 20, + "name": "แสดงเลข version ตรงมุมจอ", + "detail": "แสดงเลข version ตรงมุมจอขวาบนหรือขวาล่างก็ได้", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — badge มุมขวาล่าง อ่าน ?v= อัตโนมัติ", + "cat": "อื่นๆ" + }, + { + "n": 21, + "name": "ลดขนาดตัวละคร", + "detail": "ลดขนาดตัวละครในห้อง lobby ลงอีก 2เท่า", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 22, + "name": "เปลี่ยน ui popup ตอนพูดบนหัว", + "detail": "แก้ png seq บนหัวใหม่ เป็นแบบอื่น", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 23, + "name": "ชื่่อตัวละครทับลำโพง/ทับ bubble", + "detail": "ใน lobby ขยับชื่อลงมาอย่าให้ทับ", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 25, + "name": "เพิ่ม ข้อความแจ้งเตือนอัพเดทกลางจอ", + "detail": "ถ้ามีการอัพเดทซีน อัพเดทสีตัวละครให้มีป็อปอัพเด้งมากลางหน้าจอ ว่ามีการอัพเดทและให้รอโหลด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 26, + "name": "เอาเงาตัวละครออก 1 อัน", + "detail": "ตอนนี้ตัวละครมีเงา 2 ชั้น เอาออกไป 1 ชั้น", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — ปิด default-shadow overlay เหลือเงาในสไปรต์", + "cat": "อื่นๆ" + }, + { + "n": 27, + "name": "optimize ตัวละครให้เล็กลงอีก มันโหลดช้า", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 28, + "name": "ปุ่ม start ใน lobby", + "detail": "ทำให้ดูโต้ตอบหน่อย เช่นพอเดินเข้าไปใกล้ให้มัน glow ขึ้นมา", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 32, + "name": "เอา popup ขออนุญาตใช้ไมค์ออกไปเลย น่ารำคาญ", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 34, + "name": "เกม ใช่ หรือไม่ : ตอนจบบั๊ค", + "detail": "ตอนสรุปคะแนน ยังนับเวลาอยุ่ และขึ้น gameover ทับหน้าสรุปคะแนน", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG1 ใช่/ไม่: capture mg1AnyScore ก่อนล้าง (gameover) + guard quiz-phase หลังจบ (timer)", + "cat": "Minigame 1" + }, + { + "n": 35, + "name": "เกมวิ่ง : กดปุ่มไม่กระโดด", + "detail": "ปุ่ม jump กดแล้วไม่กระโดด", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 36, + "name": "เกมวิ่ง : ตอนจบบั๊ค วิ่งเลยฉาก", + "detail": "ตอนจบบั๊ค วิ่งเลยฉาก (ในบางรอบ)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG2 วิ่งเลยฉาก: walk-to-finish ค่อยๆเดินเข้าเส้น + clamp ไม่เลยเส้น", + "cat": "Minigame 2" + }, + { + "n": 40, + "name": "จำนวนคน ให้เล่นได้ตั้งแต่ 4-6 คน", + "detail": "กรณีเลือกผู้เล่นตั้งแต่ 4 คนขึ้นไปไม่ต้องมี bot", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 41, + "name": "เกม หยิบแผ่นป้าย", + "detail": "ต้องเล่นให้ครบ 10 ข้อก่อน ค่อยจบเกม (ตอนนี้เล่นถูก 1 ข้อ จบเกมเลย)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG4 หยิบป้าย: ใช้ map จริง (currentPlayMapId) เปิด mission flow + default 10 ข้อ", + "cat": "Minigame 4" + }, + { + "n": 42, + "name": "เกม กระโดด", + "detail": "ท่ากระโดดแปลก ยืนซอยเท้าทำไม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "anim ไม่ sync: ยืนบนแท่นที่เลื่อนลง world Y เปลี่ยนทุกเฟรม → ปลายทางเดา isWalking จาก delta = เห็น ซอยเท้า ตลอด. แก้: ส่ง isWalking จริงใน move (jsWalking) + ปลายทางใช้ค่านั้น (play.js ~19162/20501/24930). v0.006260100", + "cat": "Minigame 5" + }, + { + "n": 43, + "name": "เกม กระโดด", + "detail": "ขั้นบันไดมันถี่ไป บางทีกระโดดแล้วเบิ้ลขั้นเลย ปรับให้ระยะห่างแต่ละขั้นมันเท่ากัน", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "ขั้นถี่/ไม่เท่า → เบิ้ลขั้น+ติดรอแท่ง. แก้ layout mnptfts2: ระยะเท่ากันทุกขั้น (gap 3 แถว, rows 2/5/8/11/14/17, zigzag กว้าง6) ในโซนปลอดภัย (เลี่ยง hazard แถว 0/18/19). reach apex 4.5 > 3 = ถึงทุกขั้น (ไม่ติดรอ #43b) + ขั้นถัดไป-อีกขั้น 6 แถว > reach = กระโดดข้ามไม่ได้ (ไม่เบิ้ล). seam รอยต่อ wrap ห่าง 5 แถว 1 จุด/รอบ (h=20 หาร 3 ไม่ลง). restart service หลังแก้แมป | รอบ2: (ก) แก้ลายแท่นบิด — เดิม width เท่ากัน(6)ทำให้ภาพถูกบีบ → ตั้ง width = ขนาดจริงของรูป (เล็ก=var3 7ช่อง/212px, กลาง=var2 13ช่อง/389px) วาดได้สัดส่วนจริงไม่บิด. (ข) ติดบนสุด = seam รอยต่อ wrap 5แถว >ระยะกระโดด(apex4.5) → bump jumpSurviveJumpHeightMult 1.5→1.7 (apex 5.1) ให้ข้าม seam ได้ แต่ข้าม2ขั้น(6แถว)ยังไม่ได้. var1(ใหญ่ 46ช่อง)ใหญ่เกินแมป35 ใช้ไม่ได้ไม่บิด → ใช้ เล็ก/กลาง", + "cat": "Minigame 5" + }, + { + "n": 44, + "name": "เกมกระโดด", + "detail": "อีเวนท์พิเศษ (ทนาย/เทพความรู้) หายไป", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG5 อีเวนต์พิเศษ: เพิ่ม branch jump_survive วาง icon บน platform", + "cat": "Minigame 5" + }, + { + "n": 46, + "name": "เกมยิิงอุกาบาด", + "detail": "สีตัวละครยังไม่ตรงกับที่เลือกมา", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6 สี: ยาน Rocket-1..8 ตาม lobby theme ของผู้เล่น", + "cat": "Minigame 6" + }, + { + "n": 47, + "name": "เกมยิิงอุกาบาด", + "detail": "อุกาบาดให้พุ่งเร็วกว่านี้ และลูกเล็กกว่านี้เกมง่ายไป", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6: อุกาบาตเร็วขึ้น (+~50%) + เล็กลง (48->34/11->8)", + "cat": "Minigame 6" + }, + { + "n": 50, + "name": "ผู้เล่น 6 คน: บางเกมเล่นได้แค่ 5 (คนที่ 6 หายจากสรุป)", + "detail": "สรุปคะแนนฝั่ง client slice(0,5) ตัดคนที่ 6 — แก้เป็น slice(0,6) ทุกมินิเกม (MG1/3/4/5/6/7)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 1" + }, + { + "n": 51, + "name": "ผู้เล่น 6 คน: คะแนนตอนจบไม่ตรงกันแต่ละจอ", + "detail": "MG1 merge คะแนน server / MG6,7 merge คะแนน host -> ครบ 6 ตรงกัน (MG3 ยัง local)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 6" + }, + { + "n": 53, + "name": "เพิ่มหน้าโหลด (loading) ตอนเข้าเกม", + "detail": "overlay spinner กันเห็นจอเปล่า/asset แว่บ ซ่อนเมื่อ draw เฟรมแรก + safety 8s", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 54, + "name": "host โหลดนานมากตอนเข้าเกม", + "detail": "optimize asset/char (เกี่ยว row 28) — loading overlay ช่วยซ่อน แต่ยังโหลดช้าจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "loading overlay บังอาการแล้ว · root = canvas tint ภาพตัวละครใหญ่ (source ไม่อยู่ใน repo) → ต้อง optimize ภาพแยก", + "cat": "อื่นๆ" + }, + { + "n": 56, + "name": "ตอนกลับ LobbyB โชว์รูปแรกก่อนแล้วค่อยถูก", + "detail": "เหมือน #1 การ์ด — ต้องใส่ loading state ตอน return LobbyB ด้วย (มี room-loading-overlay อยู่แต่ซ่อนเร็วไป)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0308 — การ์ดมี spinner (#1) + room-loading overlay ใส่ % + รอ char preload เสร็จก่อนซ่อน + safety 12s", + "cat": "อื่นๆ" + }, + { + "n": 58, + "name": "ปุ่ม \"เล่นเกมต่อ\" ไม่รู้ว่าค้างหรือรอคนอื่น", + "detail": "text \"รอผู้เล่นอื่น (X/Y)\" ถูก set อยู่แล้วแต่ CSS font-size:0 ซ่อน → เพิ่ม .is-waiting CSS โชว์ข้อความ+spinner แทนรูปปุ่ม", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 60, + "name": "ทำหน้า testcase บนเซิร์ฟเวอร์", + "detail": "srv1361159.hstgr.cloud/testcase — bug list + filter/search/นับสถานะ generate จาก xlsx", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 62, + "name": "MG1/MG4 user/bot ติดกัน (เดินไม่ผ่าน)", + "detail": "เพิ่ม anti-stuck: ดันชนเพื่อน → เขย่า (shake) + ปลดล็อกเดินผ่านกันชั่วคราว คงกำแพง/area/quiz-lock; ทั้งผู้เล่นและบอท", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006250100 (รอเทส)", + "cat": "Minigame 4" + }, + { + "n": 65, + "name": "อีเวนต์พิเศษ (ทนาย/เทพ) = 1 ใน 3 (ตามดีไซน์)", + "detail": "ดีไซน์: 3 เกม/LobbyB เล่นได้เกมละ 3 ครั้ง · อีเวนต์พิเศษ 1/3 ต่อครั้ง (1 ใบ) · Test Mode บังคับ. revert การแก้ \">1 ต่อรอบ\" กลับเป็น 1 ใบ/ครั้งที่ติด", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ revert 2026-06-25 — กลับเป็น 1/3 (1 ใบ/ครั้งที่ติด) ตามดีไซน์ · ยืนยันโครงสร้าง 3เกม×3ครั้ง + disruptor toggle (>4คน/Test Mode) ถูกต้อง", + "cat": "อื่นๆ" + }, + { + "n": 71, + "name": "หน้าผลชนะ (โพเดียม) ตัวละครลอย ไม่ยืนบนพื้น", + "detail": "หน้า \"ยินดีด้วย ทีมนักสืบ ชนะ!\" ตัวละครผู้ชนะลอยอยู่กลางอากาศ ไม่ยืนบนพื้นเวที", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod room-lobby.js?v=0.0312): พื้นหลัง win-bg เดิม center/cover ทำให้พื้นเวทีถูก crop เลื่อน + podium ตั้ง bottom:9% ตัวละครเลยลอย. แก้เป็น (1) ปักพื้นหลังไว้ขอบล่าง background-position:center bottom -> พื้นเวทีติดขอบล่างจอเสมอทุกสัดส่วน (2) ลด podium bottom 9% -> 2% ให้ตัวละครยืนบนพื้น. ไฟล์: Game/public/js/room-lobby.js (injectFinalResultStyle, #trial-final-podium).", + "cat": "อื่นๆ" + }, + { + "n": 74, + "name": "แถวหน้าตัวละคร (ready ใต้ปุ่ม HOW TO) ค้างบนจอระหว่างเล่นเกม", + "detail": "หลังกด HOW TO/Ready แล้วเริ่มเกม กล่องแถว avatar คนที่กด ready (ใต้ปุ่ม) ไม่ถูกซ่อน ค้างอยู่กลางจอล่างทับฉากเกม (เห็นชัดใน MG3 stack) — กระทบ MG1/3/5/6", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "กล่องลอย #gch-ready-avatars-float ถูกซ่อนเฉพาะ MG2 (dismissGauntletCrownHowtoOverlayPlay) + MG4 (quiz_carry) เท่านั้น. begin*CountdownThenRun ของ MG1/3/5/6 (stack/jump/space/quiz) ตั้ง gauntletCrownHowtoVisible=false แต่ไม่เรียก hideReadyAvatarsFloat() → กล่องค้าง display:flex ทั้งเกม (ไม่มี driver เรียกใหม่ตอน live). แก้: เพิ่ม hideReadyAvatarsFloat() ในทั้ง 4 ฟังก์ชันเริ่มเกม (play.js). v0.006260101", + "cat": "อื่นๆ" + }, + { + "name": "block สีไม่ตรงในฝั่ง client", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 75 + }, + { + "name": "บางที client บางคนเล่นไม่ได้", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 76 + }, + { + "n": 82, + "name": "MG1: หน้า How-to ใหม่ (SAFE/SCAM)", + "detail": "เปลี่ยน how-to เป็น \"วิเคราะห์ภัยร้าย\" (เดิมเป็นดีไซน์ MG4)", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เข้า MG1 → หน้า HOW TO PLAY ต้องเป็น \"วิเคราะห์ภัยร้าย ปกป้องระบบให้ปลอดภัย\" มี SAFE/SCAM + การ์ด COMMON/RARE/LEGENDARY", + "cat": "Minigame 1" + }, + { + "n": 83, + "name": "MG1: ปุ่ม READY 2 สถานะ", + "detail": "ยังไม่กด=ฟ้า / กดแล้ว=เขียว+เครื่องหมายถูก", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: หน้า ready MG1 → ปุ่มฟ้า · กด → เขียวมี ✓ · กดอีกที → กลับฟ้า", + "cat": "Minigame 1" + }, + { + "n": 84, + "name": "MG1: auto-start เมื่อทุกคน ready", + "detail": "ครบ ready → server เริ่มเอง ไม่ต้องรอ host กด START", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: ทุกคนกด READY ครบ → เกมเริ่มเอง (นับ 3-2-1) ไม่ต้องมีใครกด START · ลองให้ host ออกก่อนคนสุดท้าย ready → ยังเริ่มได้", + "cat": "Minigame 1" + }, + { + "n": 85, + "name": "MG1: คน/บอท วิ่งเร็วเท่ากัน", + "detail": "movement time-based", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 พร้อมกัน → คนวิ่งเร็วเท่ากัน · บอทวิ่งเข้าโซน SAFE/SCAM สม่ำเสมอ", + "cat": "Minigame 1" + }, + { + "n": 86, + "name": "MG1: ผีหาย (avatar ไม่ซ้ำ)", + "detail": "server ส่ง play-roster ทุก 1.5s ตัดตัวซ้ำระหว่างเล่น", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 แล้วมีคนหลุด/reconnect → ต้องไม่มีตัวซ้ำขยับไม่ได้ค้างในสนาม", + "cat": "Minigame 1" + }, + { + "n": 87, + "name": "MG1: บอทเดินต่อแม้ host หลุด", + "detail": "server เป็นเจ้าของการเดินบอท quiz", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 กับบอท · host รีเฟรช/ออกกลางเกม → บอทต้องเดิน+วิ่งเข้าโซนต่อ ไม่หยุดนิ่ง", + "cat": "Minigame 1" + }, + { + "n": 88, + "name": "MG1: คะแนนบอทตรงกันทุกจอ", + "detail": "server คิดคะแนนบอท (เดิม host คิดเอง ไม่ sync)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 กับบอท → คะแนนบอทบนกระดานต้องตรงกันทั้ง 2 จอทุกข้อ", + "cat": "Minigame 1" + }, + { + "n": 89, + "name": "MG1: ผีบอทอยู่ในโซนผี ไม่ทับ SAFE/SCAM", + "detail": "บอทตอบผิด=ผี ตำแหน่งจริงอยู่ในโซนผี (กลาง-บน) — แก้การวาดภาพผีที่เคยห้อยลงทับสนามคำตอบ", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท ให้มีบอทตอบผิด → ตัวผีต้องลอยอยู่แถบกลาง-บน (เหนือ SAFE/SCAM) ไม่ห้อยลงทับช่อง SAFE/SCAM", + "cat": "Minigame 1" + }, + { + "n": 90, + "name": "MG1: บอทเดินลื่น ไม่วาป", + "detail": "บอท quiz เดินจาก server (time-based) + lerp นุ่ม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท → บอทเดินเข้าโซน SAFE/SCAM ลื่น ไม่กระตุก ไม่วาปไปมา", + "cat": "Minigame 1" + }, + { + "n": 92, + "name": "Admin: ตัวเลขผู้ใช้ออนไลน์ + ความจุ", + "detail": "widget 👥 N/500 สบาย/เริ่มหนัก/หนัก (เขียว/เหลือง/แดง) บนแถบ Admin", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เปิดหน้า Admin → มุมขวาบนมี 👥 ตัวเลขผู้เล่นออนไลน์ · hover เห็น CPU/RAM/ห้อง/socket · อัปเดตทุก 10 วิ", + "cat": "อื่นๆ" + }, + { + "n": 93, + "name": "MG1: หลุดกลางเกม → บอท takeover ในที่เดิม", + "detail": "บอทแทนทันที สี/ตำแหน่ง/สถานะเดิม (คะแนนต่อ, ตกรอบ→ผีต่อ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 หลายคน → คนนึงปิดแท็บกลางเกม → ตัวละครต้องไม่หาย กลายเป็นบอทสีเดิม ตำแหน่งเดิม เล่นต่อ + มี \"(บอท)\" หลังชื่อ", + "cat": "Minigame 1" + }, + { + "n": 94, + "name": "MG1: หลุดแล้วกลับมาใน 10วิ → คืนสิทธิ์เล่น", + "detail": "reconnect-undo: ถอนบอท takeover คืนตัวจริง (กันตัวซ้ำ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 → refresh กลางเกม (กลับมาใน 10วิ) → ต้องเล่นเองต่อได้ ไม่มีบอทซ้ำกับตัวเรา", + "cat": "Minigame 1" + }, + { + "n": 95, + "name": "MG1: host หลุดในหน้า how-to → เล่นต่อได้", + "detail": "expectedActive ลดตอนคนออกถาวร → host ใหม่กด START ได้ (เดิมค้าง \"รอ x/y\")", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เข้า MG1 how-to หลายคน → host ปิดแท็บ → รอ ~10วิ → host ใหม่กด START ได้ + บอทสีเดิมเล่นแทน", + "cat": "Minigame 1" + }, + { + "n": 98, + "name": "MG how-to: เห็น countdown ตอนมีคนหลุด", + "detail": "grace banner z-index ขึ้นเหนือ how-to overlay", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: ในหน้า how-to มีคนหลุด → ต้องเห็นแถบ \"🔌 ... หลุด — รอกลับมา N วิ\" ลอยเหนือ how-to (ไม่ถูกบัง)", + "cat": "Minigame 1" + }, + { + "n": 99, + "name": "Login + ตั้งชื่อผู้เล่น", + "detail": "ล็อกอิน/ตั้งชื่อได้ ชื่อไปแสดงในห้อง/เกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เปิดเกม → login → ตั้งชื่อ → เข้า Main Menu → ชื่อตรงทุกหน้า", + "cat": "อื่นๆ" + }, + { + "n": 100, + "name": "Main Menu นำทางครบ", + "detail": "ปุ่มทุกอันใน Main Menu พาไปหน้าถูก (สร้าง/เข้าห้อง, โปรไฟล์, how-to)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กดทุกปุ่มใน Main Menu → ไปหน้าตรงป้าย + ปุ่มย้อนกลับได้", + "cat": "อื่นๆ" + }, + { + "n": 101, + "name": "สร้างห้อง Public", + "detail": "สร้างห้องสาธารณะได้ เข้า LobbyA", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Public → ตั้งชื่อ → ยืนยัน → เข้า LobbyA เป็น host", + "cat": "อื่นๆ" + }, + { + "n": 102, + "name": "สร้างห้อง Private (PIN 4 หลัก)", + "detail": "ห้องส่วนตัวต้องใส่ PIN 4 หลัก ล็อก 🔒", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Private → ใส่ PIN 4 หลัก → ยืนยัน → ห้องขึ้น 🔒 · ใส่ PIN ไม่ครบ = ยืนยันไม่ได้", + "cat": "อื่นๆ" + }, + { + "n": 103, + "name": "สรุปจำนวนบอทถูก (1-3 คน)", + "detail": "เลือก 1/2/3 คน → บอท 5/4/3 (รวม 6)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือกจำนวนผู้เล่น 1,2,3 → summary แสดง '+5/+4/+3 Bot'", + "cat": "อื่นๆ" + }, + { + "n": 104, + "name": "เลือก ≥ 4 คน = ไม่มีบอท", + "detail": "เลือก 4/5/6 คน → 0 Bot ทั้ง summary และในเกมจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือก 4,5,6 → summary '+0 Bot' → เข้าเกมจริงต้องไม่มีบอทเติม (รอคนจริง)", + "cat": "อื่นๆ" + }, + { + "n": 105, + "name": "เข้าห้อง Public จากรายการ", + "detail": "เห็นรายชื่อห้อง public + กดเข้าได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → เห็นห้อง public → กดเข้า → เข้า LobbyA เป็น client", + "cat": "อื่นๆ" + }, + { + "n": 106, + "name": "เข้าห้อง Private ด้วยชื่อ+PIN", + "detail": "เข้าห้องส่วนตัวได้เมื่อใส่ชื่อ+PIN ถูก · ผิด = เข้าไม่ได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → Private → ใส่ชื่อห้อง+PIN → ถูก=เข้าได้ / ผิด=แจ้ง error", + "cat": "อื่นๆ" + }, + { + "n": 107, + "name": "ห้องเต็ม", + "detail": "ห้องเต็ม (ครบ maxPlayers) เข้าไม่ได้ แจ้งเตือน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ห้องมีคนครบ → คนใหม่กดเข้า → แจ้ง 'ห้องเต็ม (n/max)'", + "cat": "อื่นๆ" + }, + { + "n": 108, + "name": "ฉาก LobbyA โหลดเร็ว", + "detail": "เข้าฉากภายใน ~2 วิ ไม่ค้างขาว", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เข้า LobbyA → จับเวลา loading-hide < ~3 วิ (เดิมเคยค้าง 15-40 วิ)", + "cat": "LobbyA" + }, + { + "n": 109, + "name": "กดพร้อม (Ready)", + "detail": "toggle พร้อม/ไม่พร้อม ได้ ทุกคนเห็นตรงกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กด Ready → สถานะเปลี่ยน + คนอื่นเห็น · กดซ้ำ = ยกเลิกพร้อม", + "cat": "LobbyA" + }, + { + "n": 110, + "name": "ห้องแต่งตัว (กด F)", + "detail": "เดินไปจุดเขียว กด F เปิดห้องแต่งตัว เปลี่ยนชุด/ตัวละครได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA → เดินไปไอคอนห้องแต่งตัว → กด F → popup เปิด → เปลี่ยนได้ทุกคน", + "cat": "LobbyA" + }, + { + "n": 111, + "name": "Host Console (กด F, เฉพาะ host)", + "detail": "host เดินไปไอคอนชมพู กด F เปิด console · client กดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: host → จุดชมพู → F → console เปิด · client → F → แจ้ง 'เฉพาะ Host'", + "cat": "LobbyA" + }, + { + "n": 112, + "name": "ปุ่มกลางแมป (START GAME) + glow", + "detail": "ทุกคน+host พร้อม → ปุ่มกลางเรือง glow เหลืองกระพริบ → host กด F เริ่มเกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ทุกคนกด Ready ครบ → ปุ่ม START กลางแมป glow เหลือง → host เดินเข้าใกล้ กด F → เข้า wizard เลือกระดับ/คดี", + "cat": "LobbyA" + }, + { + "n": 113, + "name": "บอทเดินในฉาก ไม่หลุดขอบ", + "detail": "บอท LobbyA เดินในฉากหลัก ไม่ติดขอบ/นอกฉาก", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอทใน LobbyA → ต้องเดินวนในฉาก ไม่ค้างขอบ/หลุดออกนอก", + "cat": "LobbyA" + }, + { + "n": 114, + "name": "เสียงพูดใน Lobby (mic)", + "detail": "เปิด/ปิดไมค์ได้ ได้ยินเสียงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → เปิดไมค์ → พูด → คนอื่นได้ยิน · กดปิด = เงียบ", + "cat": "LobbyA" + }, + { + "n": 115, + "name": "MG1 เข้าเกม + how-to", + "detail": "เข้า MG1 เห็น how-to → เริ่มพร้อมกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เริ่มเกม → เข้า MG1 → how-to ขึ้น → ปิด/countdown → เริ่มพร้อมกันทุกคน", + "cat": "Minigame 1" + }, + { + "n": 116, + "name": "MG1 ตอบถูก/ผิด", + "detail": "ตอบถูกได้คะแนน · ตอบผิดกลายเป็นผี/ล็อก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เดินเข้าโซนจริง/ไม่จริง → ถูก=คะแนน+ / ผิด=ล็อก(ผี) ตรงกันทุกเครื่อง", + "cat": "Minigame 1" + }, + { + "n": 117, + "name": "MG1 บอทเล่น + คะแนนตรง", + "detail": "บอทตอบเอง คะแนน/สถานะบอทตรงทุกเครื่อง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG1 → ตอบเอง + คะแนน/ล็อก ตรงทุก client", + "cat": "Minigame 1" + }, + { + "n": 118, + "name": "MG1 จบเกม + สรุปคะแนน", + "detail": "หมดเวลา/ทุกคนตอบผิด → จบ + สรุปคะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ (complete/timeup) + คะแนน/coin ตามอันดับ", + "cat": "Minigame 1" + }, + { + "n": 119, + "name": "MG2 วิ่ง+กระโดดหลบ", + "detail": "เดิน/วิ่ง + กระโดดข้ามสิ่งกีดขวางได้ ชน=เสียแต้ม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → วิ่งหลบ → กระโดดข้าม obstacle · โดนเลเซอร์/สิ่งกีดขวาง = penalty", + "cat": "Minigame 2" + }, + { + "n": 120, + "name": "MG2 ปรับความสูง/เวลากระโดด (Admin)", + "detail": "ปรับ 'ความสูงกระโดด' + 'เวลากระโดดขึ้น-ลง' ใน Admin → มีผลรอบถัดไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Admin → Minigame-2 → ตั้งความสูง/tick → เริ่มเกมใหม่ → กระโดดสูง/เร็วตามตั้ง", + "cat": "Minigame 2" + }, + { + "n": 121, + "name": "MG2 finish/จบ", + "detail": "วิ่งถึงเส้นชัย/หมดเวลา → จบถูก ไม่ค้าง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → ถึงปลายทาง หรือหมดเวลา → ฉากจบ + คะแนน", + "cat": "Minigame 2" + }, + { + "n": 122, + "name": "MG3 ต่อบล็อกตรง", + "detail": "เล็งตรง support → บล็อกลงตรง ไม่เฉียง ต่อได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG3 → กด DROP ตอนบล็อกอยู่เหนือบล็อกก่อน → ลงตรงดิ่ง ทับพอดี (ไม่ไถลเฉียง)", + "cat": "Minigame 3" + }, + { + "n": 123, + "name": "MG3 หัวใจรวม + จบเกม", + "detail": "หัวใจ (System Integrity) เป็นของรวม · ใครพลาดก็ลด · พลาด 3 ครั้ง = จบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → ผลัดกันวาง · วางเยื้องมาก = หัวใจลด (ทุกคนเห็นเท่ากัน) · ครบ 3 = จบเกม", + "cat": "Minigame 3" + }, + { + "n": 124, + "name": "MG3 บอทวางตรง support", + "detail": "บอทวางบล็อกลงตรงยอดตึก ไม่เฉียง/หลุด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG3 → วางลงตรงบล็อกก่อนหน้า (ตึกไม่เป๋)", + "cat": "Minigame 3" + }, + { + "n": 125, + "name": "MG3 สีบล็อกตรง avatar", + "detail": "สีบล็อกที่วาง = สีตัวละครคนวาง ทุกเครื่องตรงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: วาง → สีบล็อกตรงสี avatar คนวาง (host/client/บอท ตรงกัน)", + "cat": "Minigame 3" + }, + { + "n": 126, + "name": "MG3 ตัวห้อยแกว่งลื่น", + "detail": "บล็อกที่ห้อยแกว่งลื่น ไม่กระตุก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบล็อกห้อย → แกว่งนุ่ม ไม่กระตุก/วาป", + "cat": "Minigame 3" + }, + { + "n": 127, + "name": "MG4 หยิบคำ-ส่งคำตอบ", + "detail": "หยิบ word option จากโต๊ะข้าง → เดินไปส่งที่โต๊ะกลาง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG4 → เดินไปโต๊ะ option → หยิบคำ → เดินไปโต๊ะ submit → ส่ง → ถูก=คะแนน", + "cat": "Minigame 4" + }, + { + "n": 128, + "name": "MG4 บอทไม่ยืนบนโต๊ะกลาง", + "detail": "บอทไม่ spawn/ยืนติดบนคอนโซลกลาง เดินหยิบ-ส่งปกติ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG4 → ไม่มีบอทค้างยืนบนโต๊ะกลาง · บอทหยิบ-ส่งได้", + "cat": "Minigame 4" + }, + { + "n": 129, + "name": "MG4 จบ + คะแนน", + "detail": "หมดเวลา/ครบข้อ → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ + คะแนนตามส่ง", + "cat": "Minigame 4" + }, + { + "n": 130, + "name": "MG5 กระโดดขึ้นแท่นเอาตัวรอด", + "detail": "กระโดดขึ้นแท่นตามสายพานเลื่อน ตก= ตกรอบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 → กระโดดขึ้นแท่น หนีพื้นเลื่อน · ตกล่าง = eliminated", + "cat": "Minigame 5" + }, + { + "n": 131, + "name": "MG5 สลับแท็บ (unfocus) ตกเนียน", + "detail": "สลับแท็บออก → ตัวละครตกลงต่อ คนอื่นเห็นลื่น (ไม่ลอยค้าง/กระตุก)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 กำลังเล่น → สลับไปแท็บอื่น 5 วิ → คนอื่นเห็นตัวเราไหลลงเนียน", + "cat": "Minigame 5" + }, + { + "n": 132, + "name": "MG5 บอท + จบเกม", + "detail": "บอทกระโดดเอง · จบเกม (all-dead/timeup) + เกรด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG5 กระโดดเอง → เล่นจนจบ → เกรด/คะแนนถูก", + "cat": "Minigame 5" + }, + { + "n": 133, + "name": "MG6 ยิงอุกาบาต", + "detail": "บังคับยาน + ยิง หลบอุกาบาต โดน 3 ครั้ง=ตาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG6 → ขยับยาน ยิง · โดนอุกาบาต 3 = eliminated", + "cat": "Minigame 6" + }, + { + "n": 134, + "name": "MG6 บอทขยับทุก client", + "detail": "บอททุกตัวขยับเห็นตรงกันทุกเครื่อง (ไม่มีบอทค้าง)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน MG6 → บอททุกตัวต้องขยับบนทุกจอ (ไม่มีตัวค้างนิ่งเฉพาะบางเครื่อง)", + "cat": "Minigame 6" + }, + { + "n": 135, + "name": "MG6 จบ + คะแนน", + "detail": "หมดเวลา/รอด → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน/coin ตามอันดับ", + "cat": "Minigame 6" + }, + { + "n": 136, + "name": "MG7 ยิงบอส (Mega Virus)", + "detail": "เล็ง+ยิงบอส ลด HP บอส · บอสชนเรา=เสียหาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG7 → หมุนเล็ง ยิงบอส → HP บอสลด · บอสเคลื่อนที่ชน = เด้ง/เสียหาย", + "cat": "Minigame 7" + }, + { + "n": 137, + "name": "MG7 บอทยิง+หลบบอส", + "detail": "บอทยิงบอส + หลบบอส ไม่ค้างนิ่ง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG7 → ยิงบอส + ดริฟต์หลบ ไม่ติดนิ่ง", + "cat": "Minigame 7" + }, + { + "n": 138, + "name": "MG7 จบ + คะแนน", + "detail": "บอสตาย/หมดเวลา → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน", + "cat": "Minigame 7" + }, + { + "n": 139, + "name": "กลับ LobbyB หลังจบมินิเกม", + "detail": "จบรอบมินิเกม → กลับเข้า LobbyB (หน้าคดี)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → interstitial → เข้า LobbyB", + "cat": "LobbyB" + }, + { + "n": 140, + "name": "หน้าเลือกผู้ต้องสงสัยเปิด", + "detail": "จบเคส → หน้าเลือกผู้ต้องสงสัยเปิดอัตโนมัติทุกคน", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบเคส → LobbyB → overlay เลือกผู้ต้องสงสัยขึ้นทุกเครื่อง (ไม่ค้าง/ไม่ขึ้น)", + "cat": "LobbyB" + }, + { + "n": 141, + "name": "ปุ่มกลาง (F) เปิดผู้ต้องสงสัยซ้ำ", + "detail": "ปิด overlay แล้ว → ปุ่มกลางแมป glow → กด F เปิดกลับ (แทนปุ่มลอยเดิม)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ปิดหน้าผู้ต้องสงสัย → ปุ่มกลาง glow → เดินเข้าใกล้ กด F → overlay เปิดกลับ", + "cat": "LobbyB" + }, + { + "n": 142, + "name": "เสนอบทตัวป่วน", + "detail": "≥4 คน (หรือเปิด force) → มีการเสนอบทตัวป่วน กดรับ/ปฏิเสธได้ในเวลาที่ตั้ง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบเคส → ระบบเสนอบทตัวป่วนให้ 1 คน → countdown (ตาม Admin) → กดรับ/ปฏิเสธ", + "cat": "LobbyB" + }, + { + "n": 143, + "name": "สืบผู้ต้องสงสัย (เก็บหลักฐาน)", + "detail": "สืบครบ 3 ผู้ต้องสงสัย (เล่นมินิเกม) → เก็บหลักฐานได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เลือกผู้ต้องสงสัย → เล่นมินิเกม → ได้หลักฐาน +1 ต่อคน", + "cat": "LobbyB" + }, + { + "n": 144, + "name": "โหวตชี้ตัวคนร้าย (พิจารณาคดี)", + "detail": "สืบครบ → โหวตชี้คนร้าย → ผลตัดสินถูก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "🧪 Flow TC · วิธีเทส: สืบครบ 3 → เปิดพิจารณาคดี → โหวต → เฉลยถูก/ผิด · 🔎 gamelog: [TC144] / ev=lobbyb-gate (LobbyB) · deploy 2026-07-01", + "cat": "LobbyB" + }, + { + "n": 145, + "name": "หน้า Cyber Awareness หลังตัดสิน", + "detail": "หลังตัดสินคดี → หน้าให้ความรู้กฎหมายไซเบอร์ ก่อนกลับ Main Lobby", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ตัดสินคดีจบ → interstitial cyber-law → กลับ Main-Lobby", + "cat": "LobbyB" + }, + { + "n": 146, + "name": "Host หลุด → migration", + "detail": "host ปิด/หลุด → เลือก host ใหม่อัตโนมัติ เกมไปต่อได้", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างเกม host ปิดแท็บ → ~10วิ → host ใหม่ถูกตั้ง เกมไม่ค้าง", + "cat": "ระบบ disconnect" + }, + { + "n": 147, + "name": "ผู้เล่นหลุด → บอทแทน", + "detail": "คนหลุดกลางมินิเกม → บอทเล่นแทน (สีเดิม) ไม่ค้าง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม ผู้เล่นปิดแท็บ → บอท takeover เล่นแทน (สีตามคนออก)", + "cat": "ระบบ disconnect" + }, + { + "n": 148, + "name": "แถบ grace ตอนมีคนหลุด", + "detail": "มีคนหลุด → แถบ '🔌 ... หลุด รอกลับ N วิ' ขึ้น (เหนือ how-to)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: คนหลุด → แถบ grace countdown ขึ้นทุกเครื่อง (ลอยเหนือ overlay)", + "cat": "ระบบ disconnect" + }, + { + "n": 149, + "name": "Reconnect กลับเข้าเกม", + "detail": "หลุดแล้วต่อกลับ → กลับเข้าห้อง/มินิเกมเดิมได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลุดเน็ตชั่วคราว → เชื่อมกลับ → auto re-join ห้องเดิม ไม่ต้องเริ่มใหม่", + "cat": "ระบบ disconnect" + }, + { + "n": 150, + "name": "คะแนน/COINS ตามอันดับ", + "detail": "จบมินิเกม → รับ coin ตามอันดับ (ผู้รอด)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → ผู้รอดได้ coin [10,8,6,4,2,0] ตามอันดับ + คะแนนสะสมขึ้น leaderboard", + "cat": "อื่นๆ" + }, + { + "n": 151, + "name": "กระดานผู้นำ (Leaderboard)", + "detail": "คะแนนสะสม/high score แสดงถูกในกระดานผู้นำ", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบ → เช็ค leaderboard → คะแนนอัปเดตถูก", + "cat": "อื่นๆ" + }, + { + "n": 152, + "name": "เสียงพูดในมินิเกม (MG1-7)", + "detail": "เปิดไมค์ในมินิเกม → ได้ยินกัน (WebRTC mesh)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม → กดไมค์ → พูด → คนอื่นได้ยิน", + "cat": "อื่นๆ" + }, + { + "n": 153, + "name": "ออกจากห้อง (Exit)", + "detail": "ออกจาก Lobby → host ไป Create Room / client ไป Join Room", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA/B → กด Exit → host→Create, client→Join", + "cat": "อื่นๆ" + }, + { + "n": 154, + "name": "LobbyA: ล็อก unready ระหว่าง host เลือกคดี", + "detail": "ทุกคน ready + host เปิด wizard เลือกระดับ/คดี → client กด unready ไม่ได้ (ปุ่ม disable + ป้าย \"host กำลังเลือกคดี\")", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: ทุกคนกด Ready ครบ → host กด F เปิด wizard → อีกเครื่องลองกดยกเลิก Ready → ต้องกดไม่ได้ · 🔎 gamelog: [TC154] / ev=preplay-lock (LobbyA) · deploy 2026-07-01", + "cat": "LobbyA" + }, + { + "n": 155, + "name": "LobbyB: ต้องรอทุกคนเข้าครบก่อน \"ชี้ตัวคนร้าย\"", + "detail": "ปุ่มชี้ตัวคนร้าย (พิจารณาคดี) เปิดได้เฉพาะเมื่อผู้เล่นเข้า LobbyB ครบทุกคน · ยังไม่ครบ = disable + ป้าย \"รอผู้เล่น (n/total)\"", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: จบเคส → คนบางคนยังไม่เข้า LobbyB → host/ระบบต้องยังชี้ตัวไม่ได้ จนครบทุกคน", + "cat": "LobbyB" + }, + { + "n": 156, + "name": "LobbyA→B: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story ครบ", + "detail": "มีหน้าอ่านเนื้อเรื่อง story 1-2 (cutscene) ก่อน LobbyB · host เลือกผู้ต้องสงสัยไม่ได้จนกว่าทุกคนกด \"ไปต่อ\" อ่านจบครบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: เข้า LobbyB → หน้า story ขึ้น → บางคนยังไม่กดอ่านจบ → host ต้องเลือกผู้ต้องสงสัยไม่ได้ (ป้าย \"รออ่านเนื้อเรื่อง n/total\") · 🔎 gamelog: [TC156] / ev=story-gate (LobbyB) · deploy 2026-07-01", + "cat": "LobbyA" + }, + { + "n": 157, + "name": "Vote Ban: เริ่มโหวตเมื่อทุกคนเข้า+โหลดเสร็จ · ยังไม่โหวต=เลือกผู้ต้องสงสัยไม่ได้", + "detail": "โหวตแบน/ปิดปากผู้เล่น เริ่มเมื่อทุกคนเข้า LobbyB + โหลดเสร็จเท่านั้น · ถ้ายังไม่โหวตเสร็จ host เลือกผู้ต้องสงสัยไม่ได้ (ตาม flow)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: จบเคสที่มีการ์ด ban/silence → ต้องรอทุกคนเข้า+โหลดเสร็จ แล้วค่อยเริ่มโหวต → ระหว่างโหวต host เลือกผู้ต้องสงสัยไม่ได้จนโหวตจบ · 🔎 gamelog: [TC157] / ev=vote-gate (LobbyB) · deploy 2026-07-01", + "cat": "LobbyB" + }, + { + "n": 158, + "name": "MG4: บอทว๊าป/กระโดดข้ามโต๊ะกลาง", + "detail": "บอทต้องเดินอ้อมโต๊ะกลาง ไม่ว๊าป/กระโดดข้าม (ลื่นไม่ teleport)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: MG4 หลายรอบ → สังเกตบอท: ต้องไม่กระโดด/วาปข้ามโต๊ะกลาง · เดินอ้อมเนียน · 🔎 gamelog: [TC158] / ev=mg4-warp (MG4 · pass = ไม่มี event) · deploy 2026-07-01", + "cat": "Minigame 4" + }, + { + "n": 159, + "name": "เพิ่ม BG Music + Sound Effect", + "detail": "มีเพลงพื้นหลังต่อฉาก (เมนู/LobbyA/B/มินิเกม) + SFX เหตุการณ์ (ปุ่ม, ready, drop, ยิง, ถูก/ผิด, ชนะ/แพ้) + ปุ่ม 🔊/🔇 ปรับ/ปิดได้", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-07-02", + "note": "✅ Phase 1+2 deploy 2026-07-02 (sound.js?v=1 · play.js?v=0.006301062). BGM 7 เกม+lobby · จบเกม ชนะ/หมดเวลา/แพ้ · toggle Music/SFX จำค่า · BGM หรี่ตอนพูด · per-event SFX ครบ 7 เกม (ถูก/ผิด · drop · jump · laser · bomb · boss-elim · countdown) · Admin endpoint /Game/api/sound-settings พร้อม. เทสย่อย TC160-165. ค้าง: Admin UI slider · remote voice-duck · MG6 crash", + "cat": "เสียง (Sound)" + }, + { + "n": 160, + "name": "เสียง: BGM ต่อฉาก (lobby + MG1-7)", + "detail": "เข้า LobbyA/B และแต่ละมินิเกม → มีเพลงพื้นหลังเล่นวน · เปลี่ยนเกม → เพลงเปลี่ยนตาม", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "lobby ยืม BGM มินิเกม 4 · เริ่มเล่นตอนแตะจอครั้งแรก (นโยบาย autoplay) · idempotent", + "cat": "เสียง (Sound)" + }, + { + "n": 161, + "name": "เสียง: จบเกม ชนะ/หมดเวลา/แพ้ (ครบ 7 เกม)", + "detail": "จบมินิเกมทั้ง 7 → ชนะ = แฟนแฟร์ · หมดเวลา = นกหวีด · แพ้ = game-over · BGM หยุด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "hook begin*ResultFlash* ทุกเกม (jdEndSfxFromFile) · MG7 ไม่มีไฟล์ timeup", + "cat": "เสียง (Sound)" + }, + { + "n": 162, + "name": "เสียง: ปุ่มคลิก + MG4 ตอบถูก/ผิด", + "detail": "กดปุ่มจริง → เสียงคลิก · MG4 ตอบถูก = เสียง correct / ผิด = เสียง wrong", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "ปุ่มคลิก = delegated (เฉพาะ element ปุ่มจริง)", + "cat": "เสียง (Sound)" + }, + { + "n": 163, + "name": "เสียง: ปุ่ม Music/SFX (profile) เปิด-ปิด + จำค่า", + "detail": "profile → กด toggle Music / SFX → เปิด/ปิดเสียงได้จริง · รีเฟรช/เปลี่ยนหน้า ค่ายังเดิม", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "localStorage jd_music_on / jd_sfx_on มีผลทุกหน้า · Main-Menu ยังไม่ wire (Phase 2)", + "cat": "เสียง (Sound)" + }, + { + "n": 164, + "name": "เสียง: ไมค์ไม่โดนกลบ (หรี่ BGM ตอนพูด)", + "detail": "เปิดไมค์คุย → BGM หรี่ลงอัตโนมัติ เสียงคนชัด ไม่โดนเพลง/เอฟเฟกต์กลบ (caution)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "duck ×0.22 ตอน voice-activity · ตอนนี้ duck เฉพาะตอนเราพูด (remote = Phase 2)", + "cat": "เสียง (Sound)" + }, + { + "n": 165, + "name": "เสียง: SFX รายเกม (drop/jump/laser/bomb/boss/countdown)", + "detail": "MG3 วางบล็อก=drop · MG2/MG5 กระโดด=jump · MG6/MG7 ยิง=laser · MG6 อุกกาบาตระเบิด=bomb · MG7 ฆ่าบอส=boss-elim · นับถอยหลัง 3-2-1=tick", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "per-event SFX Phase 2 · เล่นตอนเหตุการณ์จริง · MG6 crash (ยานชน) ยังไม่ต่อ", + "cat": "เสียง (Sound)" + } ] \ No newline at end of file diff --git a/www/html/testcase/data/bugs.json.bak-20260701-plan6 b/www/html/testcase/data/bugs.json.bak-20260701-plan6 new file mode 100644 index 0000000..deb0bf4 --- /dev/null +++ b/www/html/testcase/data/bugs.json.bak-20260701-plan6 @@ -0,0 +1,1152 @@ +[ + { + "n": 2, + "name": "ตัวละครไม่แสดง", + "detail": "หน้าเลือกเกม Quiz battle ภาพตัวละครไม่แสดง", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 3, + "name": "ระบบ join เกมมีปัญหา", + "detail": "เมื่อเข้ามินิเกม : บางคนเล่นเกมกะบอท บางคนเล่นกะเพื่อน แต่ละคนเข้ามินิเกมแล้วเจอกันบ้าง ไม่เจอกันบ้าง", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 5, + "name": "ระบบ Host มีปัญหา - 2", + "detail": "เมื่อกดออกจากเกม Host เปลี่ยนจากพี่ต้อมเป็นพี่คิวอัติโนมัติ", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 6, + "name": "ระบบ Host มีปัญหา - 3", + "detail": "เมื่อเข้ามินิเกม ทุกคนเห็นตัวเองเป็น host หมดเลย แต่กดเริ่มเกมไม่ได้", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 7, + "name": "ระบบสีตัวละคร ยังใช้ไม่ได้", + "detail": "สีตัวละครเปลี่ยนไปเรื่อย ไม่รุ้ว่าเราสีอะไรแน่ งงไปหมด", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 8, + "name": "หน้าเข้าร่วมเกมส์", + "detail": "ห้องไหนที่เริ่มเกมส์ไปแล้ว เมื่อกดเข้าร่วมแสดง popup alert error ปรับดีไซต์ให้สวยงามกว่านี้", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 9, + "name": "เกมวิ่งขึ้นบันได ผิด flow", + "detail": "ปรับ flow ให้ถูกต้อง คือ ใครตกจอตาย แต่ถ้าขึ้นไปอยู่บนสุดแล้วจะกระโดดไม่ไปต่อเฉยๆ แต่ไม่ตาย", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 10, + "name": "เกมวิ่งขึ้นบันได", + "detail": "เวลาหมดแล้ว ให้เล่นรอบ 2 auto / รอบ 3 มี popup ให้กดเล่นต่อ", + "prio": "", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 2" + }, + { + "n": 11, + "name": "เกมยิงลูกโป่ง", + "detail": "ฉากเกมยิงลูกโป่ง (MG7 Mega Virus) เล็กไป — ต้องการให้ภาพพื้นหลัง (BG) เต็มจอ แต่การเดิน (movement) และพื้นที่เล่น (area) ต่างๆ คงเดิม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod play.js?v=0.006250401): BG ของ MG7 เป็นภาพ 16:9 (1920x1080) แต่โค้ดเดิม map ภาพเข้า world rect 4:3 (640x480) ทำให้ภาพถูกบีบ + เหลือขอบดำซ้าย-ขวา = ฉากดูเล็ก. เปลี่ยนการวาด BG ของ balloon_boss เป็นแบบ cover เต็มจอ (รักษาสัดส่วน 16:9, crop ส่วนเกิน, จัดกึ่งกลาง) วาดแยกจาก world layer → กล้อง/movement/area ของเกมคงเดิมทุกอย่าง เปลี่ยนเฉพาะพื้นหลังให้เต็มจอ. ไฟล์: Game/public/js/play.js (draw loop, สาขา isBalloonBoss cover-fill).", + "cat": "Minigame 7" + }, + { + "n": 14, + "name": "Room ID", + "detail": "เวลาจอยห้องให้กรอกเเค่ ID กับรหัสที่สร้าง", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 16, + "name": "grid กับ ฉาก ไม่ตรงกัน", + "detail": "เกม Quiz battle เกมที่ 1 ฉากกับกริดไม่ตรงกัน ทำให้เดินทะลุ หรือเดินติดในจุดที่ไม่ควรติด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 17, + "name": "ตัวละครวิ่งเร็วเกินไป", + "detail": "เกม Quiz battle เกมที่ 1 ตัวละครผู้เล่นเร็วเกินไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 18, + "name": "Oauth Social log-in error", + "detail": "ไม่สามารถ log-inด้วย facebook / Google ได้ path: /oauth-public.php", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 19, + "name": "หน้า login", + "detail": "นโยบายความเป็นส่วนตัวกดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 20, + "name": "แสดงเลข version ตรงมุมจอ", + "detail": "แสดงเลข version ตรงมุมจอขวาบนหรือขวาล่างก็ได้", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — badge มุมขวาล่าง อ่าน ?v= อัตโนมัติ", + "cat": "อื่นๆ" + }, + { + "n": 21, + "name": "ลดขนาดตัวละคร", + "detail": "ลดขนาดตัวละครในห้อง lobby ลงอีก 2เท่า", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 22, + "name": "เปลี่ยน ui popup ตอนพูดบนหัว", + "detail": "แก้ png seq บนหัวใหม่ เป็นแบบอื่น", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 23, + "name": "ชื่่อตัวละครทับลำโพง/ทับ bubble", + "detail": "ใน lobby ขยับชื่อลงมาอย่าให้ทับ", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 25, + "name": "เพิ่ม ข้อความแจ้งเตือนอัพเดทกลางจอ", + "detail": "ถ้ามีการอัพเดทซีน อัพเดทสีตัวละครให้มีป็อปอัพเด้งมากลางหน้าจอ ว่ามีการอัพเดทและให้รอโหลด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 26, + "name": "เอาเงาตัวละครออก 1 อัน", + "detail": "ตอนนี้ตัวละครมีเงา 2 ชั้น เอาออกไป 1 ชั้น", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — ปิด default-shadow overlay เหลือเงาในสไปรต์", + "cat": "อื่นๆ" + }, + { + "n": 27, + "name": "optimize ตัวละครให้เล็กลงอีก มันโหลดช้า", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 28, + "name": "ปุ่ม start ใน lobby", + "detail": "ทำให้ดูโต้ตอบหน่อย เช่นพอเดินเข้าไปใกล้ให้มัน glow ขึ้นมา", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 32, + "name": "เอา popup ขออนุญาตใช้ไมค์ออกไปเลย น่ารำคาญ", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 34, + "name": "เกม ใช่ หรือไม่ : ตอนจบบั๊ค", + "detail": "ตอนสรุปคะแนน ยังนับเวลาอยุ่ และขึ้น gameover ทับหน้าสรุปคะแนน", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG1 ใช่/ไม่: capture mg1AnyScore ก่อนล้าง (gameover) + guard quiz-phase หลังจบ (timer)", + "cat": "Minigame 1" + }, + { + "n": 35, + "name": "เกมวิ่ง : กดปุ่มไม่กระโดด", + "detail": "ปุ่ม jump กดแล้วไม่กระโดด", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 36, + "name": "เกมวิ่ง : ตอนจบบั๊ค วิ่งเลยฉาก", + "detail": "ตอนจบบั๊ค วิ่งเลยฉาก (ในบางรอบ)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG2 วิ่งเลยฉาก: walk-to-finish ค่อยๆเดินเข้าเส้น + clamp ไม่เลยเส้น", + "cat": "Minigame 2" + }, + { + "n": 40, + "name": "จำนวนคน ให้เล่นได้ตั้งแต่ 4-6 คน", + "detail": "กรณีเลือกผู้เล่นตั้งแต่ 4 คนขึ้นไปไม่ต้องมี bot", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 41, + "name": "เกม หยิบแผ่นป้าย", + "detail": "ต้องเล่นให้ครบ 10 ข้อก่อน ค่อยจบเกม (ตอนนี้เล่นถูก 1 ข้อ จบเกมเลย)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG4 หยิบป้าย: ใช้ map จริง (currentPlayMapId) เปิด mission flow + default 10 ข้อ", + "cat": "Minigame 4" + }, + { + "n": 42, + "name": "เกม กระโดด", + "detail": "ท่ากระโดดแปลก ยืนซอยเท้าทำไม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "anim ไม่ sync: ยืนบนแท่นที่เลื่อนลง world Y เปลี่ยนทุกเฟรม → ปลายทางเดา isWalking จาก delta = เห็น ซอยเท้า ตลอด. แก้: ส่ง isWalking จริงใน move (jsWalking) + ปลายทางใช้ค่านั้น (play.js ~19162/20501/24930). v0.006260100", + "cat": "Minigame 5" + }, + { + "n": 43, + "name": "เกม กระโดด", + "detail": "ขั้นบันไดมันถี่ไป บางทีกระโดดแล้วเบิ้ลขั้นเลย ปรับให้ระยะห่างแต่ละขั้นมันเท่ากัน", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "ขั้นถี่/ไม่เท่า → เบิ้ลขั้น+ติดรอแท่ง. แก้ layout mnptfts2: ระยะเท่ากันทุกขั้น (gap 3 แถว, rows 2/5/8/11/14/17, zigzag กว้าง6) ในโซนปลอดภัย (เลี่ยง hazard แถว 0/18/19). reach apex 4.5 > 3 = ถึงทุกขั้น (ไม่ติดรอ #43b) + ขั้นถัดไป-อีกขั้น 6 แถว > reach = กระโดดข้ามไม่ได้ (ไม่เบิ้ล). seam รอยต่อ wrap ห่าง 5 แถว 1 จุด/รอบ (h=20 หาร 3 ไม่ลง). restart service หลังแก้แมป | รอบ2: (ก) แก้ลายแท่นบิด — เดิม width เท่ากัน(6)ทำให้ภาพถูกบีบ → ตั้ง width = ขนาดจริงของรูป (เล็ก=var3 7ช่อง/212px, กลาง=var2 13ช่อง/389px) วาดได้สัดส่วนจริงไม่บิด. (ข) ติดบนสุด = seam รอยต่อ wrap 5แถว >ระยะกระโดด(apex4.5) → bump jumpSurviveJumpHeightMult 1.5→1.7 (apex 5.1) ให้ข้าม seam ได้ แต่ข้าม2ขั้น(6แถว)ยังไม่ได้. var1(ใหญ่ 46ช่อง)ใหญ่เกินแมป35 ใช้ไม่ได้ไม่บิด → ใช้ เล็ก/กลาง", + "cat": "Minigame 5" + }, + { + "n": 44, + "name": "เกมกระโดด", + "detail": "อีเวนท์พิเศษ (ทนาย/เทพความรู้) หายไป", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG5 อีเวนต์พิเศษ: เพิ่ม branch jump_survive วาง icon บน platform", + "cat": "Minigame 5" + }, + { + "n": 46, + "name": "เกมยิิงอุกาบาด", + "detail": "สีตัวละครยังไม่ตรงกับที่เลือกมา", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6 สี: ยาน Rocket-1..8 ตาม lobby theme ของผู้เล่น", + "cat": "Minigame 6" + }, + { + "n": 47, + "name": "เกมยิิงอุกาบาด", + "detail": "อุกาบาดให้พุ่งเร็วกว่านี้ และลูกเล็กกว่านี้เกมง่ายไป", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6: อุกาบาตเร็วขึ้น (+~50%) + เล็กลง (48->34/11->8)", + "cat": "Minigame 6" + }, + { + "n": 50, + "name": "ผู้เล่น 6 คน: บางเกมเล่นได้แค่ 5 (คนที่ 6 หายจากสรุป)", + "detail": "สรุปคะแนนฝั่ง client slice(0,5) ตัดคนที่ 6 — แก้เป็น slice(0,6) ทุกมินิเกม (MG1/3/4/5/6/7)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 1" + }, + { + "n": 51, + "name": "ผู้เล่น 6 คน: คะแนนตอนจบไม่ตรงกันแต่ละจอ", + "detail": "MG1 merge คะแนน server / MG6,7 merge คะแนน host -> ครบ 6 ตรงกัน (MG3 ยัง local)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 6" + }, + { + "n": 53, + "name": "เพิ่มหน้าโหลด (loading) ตอนเข้าเกม", + "detail": "overlay spinner กันเห็นจอเปล่า/asset แว่บ ซ่อนเมื่อ draw เฟรมแรก + safety 8s", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 54, + "name": "host โหลดนานมากตอนเข้าเกม", + "detail": "optimize asset/char (เกี่ยว row 28) — loading overlay ช่วยซ่อน แต่ยังโหลดช้าจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "loading overlay บังอาการแล้ว · root = canvas tint ภาพตัวละครใหญ่ (source ไม่อยู่ใน repo) → ต้อง optimize ภาพแยก", + "cat": "อื่นๆ" + }, + { + "n": 56, + "name": "ตอนกลับ LobbyB โชว์รูปแรกก่อนแล้วค่อยถูก", + "detail": "เหมือน #1 การ์ด — ต้องใส่ loading state ตอน return LobbyB ด้วย (มี room-loading-overlay อยู่แต่ซ่อนเร็วไป)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0308 — การ์ดมี spinner (#1) + room-loading overlay ใส่ % + รอ char preload เสร็จก่อนซ่อน + safety 12s", + "cat": "อื่นๆ" + }, + { + "n": 58, + "name": "ปุ่ม \"เล่นเกมต่อ\" ไม่รู้ว่าค้างหรือรอคนอื่น", + "detail": "text \"รอผู้เล่นอื่น (X/Y)\" ถูก set อยู่แล้วแต่ CSS font-size:0 ซ่อน → เพิ่ม .is-waiting CSS โชว์ข้อความ+spinner แทนรูปปุ่ม", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 60, + "name": "ทำหน้า testcase บนเซิร์ฟเวอร์", + "detail": "srv1361159.hstgr.cloud/testcase — bug list + filter/search/นับสถานะ generate จาก xlsx", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 62, + "name": "MG1/MG4 user/bot ติดกัน (เดินไม่ผ่าน)", + "detail": "เพิ่ม anti-stuck: ดันชนเพื่อน → เขย่า (shake) + ปลดล็อกเดินผ่านกันชั่วคราว คงกำแพง/area/quiz-lock; ทั้งผู้เล่นและบอท", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006250100 (รอเทส)", + "cat": "Minigame 4" + }, + { + "n": 65, + "name": "อีเวนต์พิเศษ (ทนาย/เทพ) = 1 ใน 3 (ตามดีไซน์)", + "detail": "ดีไซน์: 3 เกม/LobbyB เล่นได้เกมละ 3 ครั้ง · อีเวนต์พิเศษ 1/3 ต่อครั้ง (1 ใบ) · Test Mode บังคับ. revert การแก้ \">1 ต่อรอบ\" กลับเป็น 1 ใบ/ครั้งที่ติด", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ revert 2026-06-25 — กลับเป็น 1/3 (1 ใบ/ครั้งที่ติด) ตามดีไซน์ · ยืนยันโครงสร้าง 3เกม×3ครั้ง + disruptor toggle (>4คน/Test Mode) ถูกต้อง", + "cat": "อื่นๆ" + }, + { + "n": 71, + "name": "หน้าผลชนะ (โพเดียม) ตัวละครลอย ไม่ยืนบนพื้น", + "detail": "หน้า \"ยินดีด้วย ทีมนักสืบ ชนะ!\" ตัวละครผู้ชนะลอยอยู่กลางอากาศ ไม่ยืนบนพื้นเวที", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod room-lobby.js?v=0.0312): พื้นหลัง win-bg เดิม center/cover ทำให้พื้นเวทีถูก crop เลื่อน + podium ตั้ง bottom:9% ตัวละครเลยลอย. แก้เป็น (1) ปักพื้นหลังไว้ขอบล่าง background-position:center bottom -> พื้นเวทีติดขอบล่างจอเสมอทุกสัดส่วน (2) ลด podium bottom 9% -> 2% ให้ตัวละครยืนบนพื้น. ไฟล์: Game/public/js/room-lobby.js (injectFinalResultStyle, #trial-final-podium).", + "cat": "อื่นๆ" + }, + { + "n": 74, + "name": "แถวหน้าตัวละคร (ready ใต้ปุ่ม HOW TO) ค้างบนจอระหว่างเล่นเกม", + "detail": "หลังกด HOW TO/Ready แล้วเริ่มเกม กล่องแถว avatar คนที่กด ready (ใต้ปุ่ม) ไม่ถูกซ่อน ค้างอยู่กลางจอล่างทับฉากเกม (เห็นชัดใน MG3 stack) — กระทบ MG1/3/5/6", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "กล่องลอย #gch-ready-avatars-float ถูกซ่อนเฉพาะ MG2 (dismissGauntletCrownHowtoOverlayPlay) + MG4 (quiz_carry) เท่านั้น. begin*CountdownThenRun ของ MG1/3/5/6 (stack/jump/space/quiz) ตั้ง gauntletCrownHowtoVisible=false แต่ไม่เรียก hideReadyAvatarsFloat() → กล่องค้าง display:flex ทั้งเกม (ไม่มี driver เรียกใหม่ตอน live). แก้: เพิ่ม hideReadyAvatarsFloat() ในทั้ง 4 ฟังก์ชันเริ่มเกม (play.js). v0.006260101", + "cat": "อื่นๆ" + }, + { + "name": "block สีไม่ตรงในฝั่ง client", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 75 + }, + { + "name": "บางที client บางคนเล่นไม่ได้", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 76 + }, + { + "n": 82, + "name": "MG1: หน้า How-to ใหม่ (SAFE/SCAM)", + "detail": "เปลี่ยน how-to เป็น \"วิเคราะห์ภัยร้าย\" (เดิมเป็นดีไซน์ MG4)", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เข้า MG1 → หน้า HOW TO PLAY ต้องเป็น \"วิเคราะห์ภัยร้าย ปกป้องระบบให้ปลอดภัย\" มี SAFE/SCAM + การ์ด COMMON/RARE/LEGENDARY", + "cat": "Minigame 1" + }, + { + "n": 83, + "name": "MG1: ปุ่ม READY 2 สถานะ", + "detail": "ยังไม่กด=ฟ้า / กดแล้ว=เขียว+เครื่องหมายถูก", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: หน้า ready MG1 → ปุ่มฟ้า · กด → เขียวมี ✓ · กดอีกที → กลับฟ้า", + "cat": "Minigame 1" + }, + { + "n": 84, + "name": "MG1: auto-start เมื่อทุกคน ready", + "detail": "ครบ ready → server เริ่มเอง ไม่ต้องรอ host กด START", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: ทุกคนกด READY ครบ → เกมเริ่มเอง (นับ 3-2-1) ไม่ต้องมีใครกด START · ลองให้ host ออกก่อนคนสุดท้าย ready → ยังเริ่มได้", + "cat": "Minigame 1" + }, + { + "n": 85, + "name": "MG1: คน/บอท วิ่งเร็วเท่ากัน", + "detail": "movement time-based", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 พร้อมกัน → คนวิ่งเร็วเท่ากัน · บอทวิ่งเข้าโซน SAFE/SCAM สม่ำเสมอ", + "cat": "Minigame 1" + }, + { + "n": 86, + "name": "MG1: ผีหาย (avatar ไม่ซ้ำ)", + "detail": "server ส่ง play-roster ทุก 1.5s ตัดตัวซ้ำระหว่างเล่น", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 แล้วมีคนหลุด/reconnect → ต้องไม่มีตัวซ้ำขยับไม่ได้ค้างในสนาม", + "cat": "Minigame 1" + }, + { + "n": 87, + "name": "MG1: บอทเดินต่อแม้ host หลุด", + "detail": "server เป็นเจ้าของการเดินบอท quiz", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 กับบอท · host รีเฟรช/ออกกลางเกม → บอทต้องเดิน+วิ่งเข้าโซนต่อ ไม่หยุดนิ่ง", + "cat": "Minigame 1" + }, + { + "n": 88, + "name": "MG1: คะแนนบอทตรงกันทุกจอ", + "detail": "server คิดคะแนนบอท (เดิม host คิดเอง ไม่ sync)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 กับบอท → คะแนนบอทบนกระดานต้องตรงกันทั้ง 2 จอทุกข้อ", + "cat": "Minigame 1" + }, + { + "n": 89, + "name": "MG1: ผีบอทอยู่ในโซนผี ไม่ทับ SAFE/SCAM", + "detail": "บอทตอบผิด=ผี ตำแหน่งจริงอยู่ในโซนผี (กลาง-บน) — แก้การวาดภาพผีที่เคยห้อยลงทับสนามคำตอบ", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท ให้มีบอทตอบผิด → ตัวผีต้องลอยอยู่แถบกลาง-บน (เหนือ SAFE/SCAM) ไม่ห้อยลงทับช่อง SAFE/SCAM", + "cat": "Minigame 1" + }, + { + "n": 90, + "name": "MG1: บอทเดินลื่น ไม่วาป", + "detail": "บอท quiz เดินจาก server (time-based) + lerp นุ่ม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท → บอทเดินเข้าโซน SAFE/SCAM ลื่น ไม่กระตุก ไม่วาปไปมา", + "cat": "Minigame 1" + }, + { + "n": 92, + "name": "Admin: ตัวเลขผู้ใช้ออนไลน์ + ความจุ", + "detail": "widget 👥 N/500 สบาย/เริ่มหนัก/หนัก (เขียว/เหลือง/แดง) บนแถบ Admin", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เปิดหน้า Admin → มุมขวาบนมี 👥 ตัวเลขผู้เล่นออนไลน์ · hover เห็น CPU/RAM/ห้อง/socket · อัปเดตทุก 10 วิ", + "cat": "อื่นๆ" + }, + { + "n": 93, + "name": "MG1: หลุดกลางเกม → บอท takeover ในที่เดิม", + "detail": "บอทแทนทันที สี/ตำแหน่ง/สถานะเดิม (คะแนนต่อ, ตกรอบ→ผีต่อ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 หลายคน → คนนึงปิดแท็บกลางเกม → ตัวละครต้องไม่หาย กลายเป็นบอทสีเดิม ตำแหน่งเดิม เล่นต่อ + มี \"(บอท)\" หลังชื่อ", + "cat": "Minigame 1" + }, + { + "n": 94, + "name": "MG1: หลุดแล้วกลับมาใน 10วิ → คืนสิทธิ์เล่น", + "detail": "reconnect-undo: ถอนบอท takeover คืนตัวจริง (กันตัวซ้ำ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 → refresh กลางเกม (กลับมาใน 10วิ) → ต้องเล่นเองต่อได้ ไม่มีบอทซ้ำกับตัวเรา", + "cat": "Minigame 1" + }, + { + "n": 95, + "name": "MG1: host หลุดในหน้า how-to → เล่นต่อได้", + "detail": "expectedActive ลดตอนคนออกถาวร → host ใหม่กด START ได้ (เดิมค้าง \"รอ x/y\")", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เข้า MG1 how-to หลายคน → host ปิดแท็บ → รอ ~10วิ → host ใหม่กด START ได้ + บอทสีเดิมเล่นแทน", + "cat": "Minigame 1" + }, + { + "n": 98, + "name": "MG how-to: เห็น countdown ตอนมีคนหลุด", + "detail": "grace banner z-index ขึ้นเหนือ how-to overlay", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: ในหน้า how-to มีคนหลุด → ต้องเห็นแถบ \"🔌 ... หลุด — รอกลับมา N วิ\" ลอยเหนือ how-to (ไม่ถูกบัง)", + "cat": "Minigame 1" + }, + { + "n": 99, + "name": "Login + ตั้งชื่อผู้เล่น", + "detail": "ล็อกอิน/ตั้งชื่อได้ ชื่อไปแสดงในห้อง/เกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เปิดเกม → login → ตั้งชื่อ → เข้า Main Menu → ชื่อตรงทุกหน้า", + "cat": "อื่นๆ" + }, + { + "n": 100, + "name": "Main Menu นำทางครบ", + "detail": "ปุ่มทุกอันใน Main Menu พาไปหน้าถูก (สร้าง/เข้าห้อง, โปรไฟล์, how-to)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กดทุกปุ่มใน Main Menu → ไปหน้าตรงป้าย + ปุ่มย้อนกลับได้", + "cat": "อื่นๆ" + }, + { + "n": 101, + "name": "สร้างห้อง Public", + "detail": "สร้างห้องสาธารณะได้ เข้า LobbyA", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Public → ตั้งชื่อ → ยืนยัน → เข้า LobbyA เป็น host", + "cat": "อื่นๆ" + }, + { + "n": 102, + "name": "สร้างห้อง Private (PIN 4 หลัก)", + "detail": "ห้องส่วนตัวต้องใส่ PIN 4 หลัก ล็อก 🔒", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Private → ใส่ PIN 4 หลัก → ยืนยัน → ห้องขึ้น 🔒 · ใส่ PIN ไม่ครบ = ยืนยันไม่ได้", + "cat": "อื่นๆ" + }, + { + "n": 103, + "name": "สรุปจำนวนบอทถูก (1-3 คน)", + "detail": "เลือก 1/2/3 คน → บอท 5/4/3 (รวม 6)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือกจำนวนผู้เล่น 1,2,3 → summary แสดง '+5/+4/+3 Bot'", + "cat": "อื่นๆ" + }, + { + "n": 104, + "name": "เลือก ≥ 4 คน = ไม่มีบอท", + "detail": "เลือก 4/5/6 คน → 0 Bot ทั้ง summary และในเกมจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือก 4,5,6 → summary '+0 Bot' → เข้าเกมจริงต้องไม่มีบอทเติม (รอคนจริง)", + "cat": "อื่นๆ" + }, + { + "n": 105, + "name": "เข้าห้อง Public จากรายการ", + "detail": "เห็นรายชื่อห้อง public + กดเข้าได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → เห็นห้อง public → กดเข้า → เข้า LobbyA เป็น client", + "cat": "อื่นๆ" + }, + { + "n": 106, + "name": "เข้าห้อง Private ด้วยชื่อ+PIN", + "detail": "เข้าห้องส่วนตัวได้เมื่อใส่ชื่อ+PIN ถูก · ผิด = เข้าไม่ได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → Private → ใส่ชื่อห้อง+PIN → ถูก=เข้าได้ / ผิด=แจ้ง error", + "cat": "อื่นๆ" + }, + { + "n": 107, + "name": "ห้องเต็ม", + "detail": "ห้องเต็ม (ครบ maxPlayers) เข้าไม่ได้ แจ้งเตือน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ห้องมีคนครบ → คนใหม่กดเข้า → แจ้ง 'ห้องเต็ม (n/max)'", + "cat": "อื่นๆ" + }, + { + "n": 108, + "name": "ฉาก LobbyA โหลดเร็ว", + "detail": "เข้าฉากภายใน ~2 วิ ไม่ค้างขาว", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เข้า LobbyA → จับเวลา loading-hide < ~3 วิ (เดิมเคยค้าง 15-40 วิ)", + "cat": "LobbyA" + }, + { + "n": 109, + "name": "กดพร้อม (Ready)", + "detail": "toggle พร้อม/ไม่พร้อม ได้ ทุกคนเห็นตรงกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กด Ready → สถานะเปลี่ยน + คนอื่นเห็น · กดซ้ำ = ยกเลิกพร้อม", + "cat": "LobbyA" + }, + { + "n": 110, + "name": "ห้องแต่งตัว (กด F)", + "detail": "เดินไปจุดเขียว กด F เปิดห้องแต่งตัว เปลี่ยนชุด/ตัวละครได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA → เดินไปไอคอนห้องแต่งตัว → กด F → popup เปิด → เปลี่ยนได้ทุกคน", + "cat": "LobbyA" + }, + { + "n": 111, + "name": "Host Console (กด F, เฉพาะ host)", + "detail": "host เดินไปไอคอนชมพู กด F เปิด console · client กดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: host → จุดชมพู → F → console เปิด · client → F → แจ้ง 'เฉพาะ Host'", + "cat": "LobbyA" + }, + { + "n": 112, + "name": "ปุ่มกลางแมป (START GAME) + glow", + "detail": "ทุกคน+host พร้อม → ปุ่มกลางเรือง glow เหลืองกระพริบ → host กด F เริ่มเกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ทุกคนกด Ready ครบ → ปุ่ม START กลางแมป glow เหลือง → host เดินเข้าใกล้ กด F → เข้า wizard เลือกระดับ/คดี", + "cat": "LobbyA" + }, + { + "n": 113, + "name": "บอทเดินในฉาก ไม่หลุดขอบ", + "detail": "บอท LobbyA เดินในฉากหลัก ไม่ติดขอบ/นอกฉาก", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอทใน LobbyA → ต้องเดินวนในฉาก ไม่ค้างขอบ/หลุดออกนอก", + "cat": "LobbyA" + }, + { + "n": 114, + "name": "เสียงพูดใน Lobby (mic)", + "detail": "เปิด/ปิดไมค์ได้ ได้ยินเสียงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → เปิดไมค์ → พูด → คนอื่นได้ยิน · กดปิด = เงียบ", + "cat": "LobbyA" + }, + { + "n": 115, + "name": "MG1 เข้าเกม + how-to", + "detail": "เข้า MG1 เห็น how-to → เริ่มพร้อมกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เริ่มเกม → เข้า MG1 → how-to ขึ้น → ปิด/countdown → เริ่มพร้อมกันทุกคน", + "cat": "Minigame 1" + }, + { + "n": 116, + "name": "MG1 ตอบถูก/ผิด", + "detail": "ตอบถูกได้คะแนน · ตอบผิดกลายเป็นผี/ล็อก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เดินเข้าโซนจริง/ไม่จริง → ถูก=คะแนน+ / ผิด=ล็อก(ผี) ตรงกันทุกเครื่อง", + "cat": "Minigame 1" + }, + { + "n": 117, + "name": "MG1 บอทเล่น + คะแนนตรง", + "detail": "บอทตอบเอง คะแนน/สถานะบอทตรงทุกเครื่อง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG1 → ตอบเอง + คะแนน/ล็อก ตรงทุก client", + "cat": "Minigame 1" + }, + { + "n": 118, + "name": "MG1 จบเกม + สรุปคะแนน", + "detail": "หมดเวลา/ทุกคนตอบผิด → จบ + สรุปคะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ (complete/timeup) + คะแนน/coin ตามอันดับ", + "cat": "Minigame 1" + }, + { + "n": 119, + "name": "MG2 วิ่ง+กระโดดหลบ", + "detail": "เดิน/วิ่ง + กระโดดข้ามสิ่งกีดขวางได้ ชน=เสียแต้ม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → วิ่งหลบ → กระโดดข้าม obstacle · โดนเลเซอร์/สิ่งกีดขวาง = penalty", + "cat": "Minigame 2" + }, + { + "n": 120, + "name": "MG2 ปรับความสูง/เวลากระโดด (Admin)", + "detail": "ปรับ 'ความสูงกระโดด' + 'เวลากระโดดขึ้น-ลง' ใน Admin → มีผลรอบถัดไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Admin → Minigame-2 → ตั้งความสูง/tick → เริ่มเกมใหม่ → กระโดดสูง/เร็วตามตั้ง", + "cat": "Minigame 2" + }, + { + "n": 121, + "name": "MG2 finish/จบ", + "detail": "วิ่งถึงเส้นชัย/หมดเวลา → จบถูก ไม่ค้าง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → ถึงปลายทาง หรือหมดเวลา → ฉากจบ + คะแนน", + "cat": "Minigame 2" + }, + { + "n": 122, + "name": "MG3 ต่อบล็อกตรง", + "detail": "เล็งตรง support → บล็อกลงตรง ไม่เฉียง ต่อได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG3 → กด DROP ตอนบล็อกอยู่เหนือบล็อกก่อน → ลงตรงดิ่ง ทับพอดี (ไม่ไถลเฉียง)", + "cat": "Minigame 3" + }, + { + "n": 123, + "name": "MG3 หัวใจรวม + จบเกม", + "detail": "หัวใจ (System Integrity) เป็นของรวม · ใครพลาดก็ลด · พลาด 3 ครั้ง = จบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → ผลัดกันวาง · วางเยื้องมาก = หัวใจลด (ทุกคนเห็นเท่ากัน) · ครบ 3 = จบเกม", + "cat": "Minigame 3" + }, + { + "n": 124, + "name": "MG3 บอทวางตรง support", + "detail": "บอทวางบล็อกลงตรงยอดตึก ไม่เฉียง/หลุด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG3 → วางลงตรงบล็อกก่อนหน้า (ตึกไม่เป๋)", + "cat": "Minigame 3" + }, + { + "n": 125, + "name": "MG3 สีบล็อกตรง avatar", + "detail": "สีบล็อกที่วาง = สีตัวละครคนวาง ทุกเครื่องตรงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: วาง → สีบล็อกตรงสี avatar คนวาง (host/client/บอท ตรงกัน)", + "cat": "Minigame 3" + }, + { + "n": 126, + "name": "MG3 ตัวห้อยแกว่งลื่น", + "detail": "บล็อกที่ห้อยแกว่งลื่น ไม่กระตุก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบล็อกห้อย → แกว่งนุ่ม ไม่กระตุก/วาป", + "cat": "Minigame 3" + }, + { + "n": 127, + "name": "MG4 หยิบคำ-ส่งคำตอบ", + "detail": "หยิบ word option จากโต๊ะข้าง → เดินไปส่งที่โต๊ะกลาง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG4 → เดินไปโต๊ะ option → หยิบคำ → เดินไปโต๊ะ submit → ส่ง → ถูก=คะแนน", + "cat": "Minigame 4" + }, + { + "n": 128, + "name": "MG4 บอทไม่ยืนบนโต๊ะกลาง", + "detail": "บอทไม่ spawn/ยืนติดบนคอนโซลกลาง เดินหยิบ-ส่งปกติ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG4 → ไม่มีบอทค้างยืนบนโต๊ะกลาง · บอทหยิบ-ส่งได้", + "cat": "Minigame 4" + }, + { + "n": 129, + "name": "MG4 จบ + คะแนน", + "detail": "หมดเวลา/ครบข้อ → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ + คะแนนตามส่ง", + "cat": "Minigame 4" + }, + { + "n": 130, + "name": "MG5 กระโดดขึ้นแท่นเอาตัวรอด", + "detail": "กระโดดขึ้นแท่นตามสายพานเลื่อน ตก= ตกรอบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 → กระโดดขึ้นแท่น หนีพื้นเลื่อน · ตกล่าง = eliminated", + "cat": "Minigame 5" + }, + { + "n": 131, + "name": "MG5 สลับแท็บ (unfocus) ตกเนียน", + "detail": "สลับแท็บออก → ตัวละครตกลงต่อ คนอื่นเห็นลื่น (ไม่ลอยค้าง/กระตุก)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 กำลังเล่น → สลับไปแท็บอื่น 5 วิ → คนอื่นเห็นตัวเราไหลลงเนียน", + "cat": "Minigame 5" + }, + { + "n": 132, + "name": "MG5 บอท + จบเกม", + "detail": "บอทกระโดดเอง · จบเกม (all-dead/timeup) + เกรด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG5 กระโดดเอง → เล่นจนจบ → เกรด/คะแนนถูก", + "cat": "Minigame 5" + }, + { + "n": 133, + "name": "MG6 ยิงอุกาบาต", + "detail": "บังคับยาน + ยิง หลบอุกาบาต โดน 3 ครั้ง=ตาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG6 → ขยับยาน ยิง · โดนอุกาบาต 3 = eliminated", + "cat": "Minigame 6" + }, + { + "n": 134, + "name": "MG6 บอทขยับทุก client", + "detail": "บอททุกตัวขยับเห็นตรงกันทุกเครื่อง (ไม่มีบอทค้าง)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน MG6 → บอททุกตัวต้องขยับบนทุกจอ (ไม่มีตัวค้างนิ่งเฉพาะบางเครื่อง)", + "cat": "Minigame 6" + }, + { + "n": 135, + "name": "MG6 จบ + คะแนน", + "detail": "หมดเวลา/รอด → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน/coin ตามอันดับ", + "cat": "Minigame 6" + }, + { + "n": 136, + "name": "MG7 ยิงบอส (Mega Virus)", + "detail": "เล็ง+ยิงบอส ลด HP บอส · บอสชนเรา=เสียหาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG7 → หมุนเล็ง ยิงบอส → HP บอสลด · บอสเคลื่อนที่ชน = เด้ง/เสียหาย", + "cat": "Minigame 7" + }, + { + "n": 137, + "name": "MG7 บอทยิง+หลบบอส", + "detail": "บอทยิงบอส + หลบบอส ไม่ค้างนิ่ง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG7 → ยิงบอส + ดริฟต์หลบ ไม่ติดนิ่ง", + "cat": "Minigame 7" + }, + { + "n": 138, + "name": "MG7 จบ + คะแนน", + "detail": "บอสตาย/หมดเวลา → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน", + "cat": "Minigame 7" + }, + { + "n": 139, + "name": "กลับ LobbyB หลังจบมินิเกม", + "detail": "จบรอบมินิเกม → กลับเข้า LobbyB (หน้าคดี)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → interstitial → เข้า LobbyB", + "cat": "LobbyB" + }, + { + "n": 140, + "name": "หน้าเลือกผู้ต้องสงสัยเปิด", + "detail": "จบเคส → หน้าเลือกผู้ต้องสงสัยเปิดอัตโนมัติทุกคน", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบเคส → LobbyB → overlay เลือกผู้ต้องสงสัยขึ้นทุกเครื่อง (ไม่ค้าง/ไม่ขึ้น)", + "cat": "LobbyB" + }, + { + "n": 141, + "name": "ปุ่มกลาง (F) เปิดผู้ต้องสงสัยซ้ำ", + "detail": "ปิด overlay แล้ว → ปุ่มกลางแมป glow → กด F เปิดกลับ (แทนปุ่มลอยเดิม)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ปิดหน้าผู้ต้องสงสัย → ปุ่มกลาง glow → เดินเข้าใกล้ กด F → overlay เปิดกลับ", + "cat": "LobbyB" + }, + { + "n": 142, + "name": "เสนอบทตัวป่วน", + "detail": "≥4 คน (หรือเปิด force) → มีการเสนอบทตัวป่วน กดรับ/ปฏิเสธได้ในเวลาที่ตั้ง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบเคส → ระบบเสนอบทตัวป่วนให้ 1 คน → countdown (ตาม Admin) → กดรับ/ปฏิเสธ", + "cat": "LobbyB" + }, + { + "n": 143, + "name": "สืบผู้ต้องสงสัย (เก็บหลักฐาน)", + "detail": "สืบครบ 3 ผู้ต้องสงสัย (เล่นมินิเกม) → เก็บหลักฐานได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เลือกผู้ต้องสงสัย → เล่นมินิเกม → ได้หลักฐาน +1 ต่อคน", + "cat": "LobbyB" + }, + { + "n": 144, + "name": "โหวตชี้ตัวคนร้าย (พิจารณาคดี)", + "detail": "สืบครบ → โหวตชี้คนร้าย → ผลตัดสินถูก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สืบครบ 3 → เปิดพิจารณาคดี → โหวต → เฉลยถูก/ผิด", + "cat": "LobbyB" + }, + { + "n": 145, + "name": "หน้า Cyber Awareness หลังตัดสิน", + "detail": "หลังตัดสินคดี → หน้าให้ความรู้กฎหมายไซเบอร์ ก่อนกลับ Main Lobby", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ตัดสินคดีจบ → interstitial cyber-law → กลับ Main-Lobby", + "cat": "LobbyB" + }, + { + "n": 146, + "name": "Host หลุด → migration", + "detail": "host ปิด/หลุด → เลือก host ใหม่อัตโนมัติ เกมไปต่อได้", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างเกม host ปิดแท็บ → ~10วิ → host ใหม่ถูกตั้ง เกมไม่ค้าง", + "cat": "ระบบ disconnect" + }, + { + "n": 147, + "name": "ผู้เล่นหลุด → บอทแทน", + "detail": "คนหลุดกลางมินิเกม → บอทเล่นแทน (สีเดิม) ไม่ค้าง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม ผู้เล่นปิดแท็บ → บอท takeover เล่นแทน (สีตามคนออก)", + "cat": "ระบบ disconnect" + }, + { + "n": 148, + "name": "แถบ grace ตอนมีคนหลุด", + "detail": "มีคนหลุด → แถบ '🔌 ... หลุด รอกลับ N วิ' ขึ้น (เหนือ how-to)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: คนหลุด → แถบ grace countdown ขึ้นทุกเครื่อง (ลอยเหนือ overlay)", + "cat": "ระบบ disconnect" + }, + { + "n": 149, + "name": "Reconnect กลับเข้าเกม", + "detail": "หลุดแล้วต่อกลับ → กลับเข้าห้อง/มินิเกมเดิมได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลุดเน็ตชั่วคราว → เชื่อมกลับ → auto re-join ห้องเดิม ไม่ต้องเริ่มใหม่", + "cat": "ระบบ disconnect" + }, + { + "n": 150, + "name": "คะแนน/COINS ตามอันดับ", + "detail": "จบมินิเกม → รับ coin ตามอันดับ (ผู้รอด)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → ผู้รอดได้ coin [10,8,6,4,2,0] ตามอันดับ + คะแนนสะสมขึ้น leaderboard", + "cat": "อื่นๆ" + }, + { + "n": 151, + "name": "กระดานผู้นำ (Leaderboard)", + "detail": "คะแนนสะสม/high score แสดงถูกในกระดานผู้นำ", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบ → เช็ค leaderboard → คะแนนอัปเดตถูก", + "cat": "อื่นๆ" + }, + { + "n": 152, + "name": "เสียงพูดในมินิเกม (MG1-7)", + "detail": "เปิดไมค์ในมินิเกม → ได้ยินกัน (WebRTC mesh)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม → กดไมค์ → พูด → คนอื่นได้ยิน", + "cat": "อื่นๆ" + }, + { + "n": 153, + "name": "ออกจากห้อง (Exit)", + "detail": "ออกจาก Lobby → host ไป Create Room / client ไป Join Room", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA/B → กด Exit → host→Create, client→Join", + "cat": "อื่นๆ" + } +] \ No newline at end of file diff --git a/www/html/testcase/data/bugs.json.bak-20260702-sfx2 b/www/html/testcase/data/bugs.json.bak-20260702-sfx2 new file mode 100644 index 0000000..08ca853 --- /dev/null +++ b/www/html/testcase/data/bugs.json.bak-20260702-sfx2 @@ -0,0 +1,1262 @@ +[ + { + "n": 2, + "name": "ตัวละครไม่แสดง", + "detail": "หน้าเลือกเกม Quiz battle ภาพตัวละครไม่แสดง", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 3, + "name": "ระบบ join เกมมีปัญหา", + "detail": "เมื่อเข้ามินิเกม : บางคนเล่นเกมกะบอท บางคนเล่นกะเพื่อน แต่ละคนเข้ามินิเกมแล้วเจอกันบ้าง ไม่เจอกันบ้าง", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 5, + "name": "ระบบ Host มีปัญหา - 2", + "detail": "เมื่อกดออกจากเกม Host เปลี่ยนจากพี่ต้อมเป็นพี่คิวอัติโนมัติ", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 6, + "name": "ระบบ Host มีปัญหา - 3", + "detail": "เมื่อเข้ามินิเกม ทุกคนเห็นตัวเองเป็น host หมดเลย แต่กดเริ่มเกมไม่ได้", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 7, + "name": "ระบบสีตัวละคร ยังใช้ไม่ได้", + "detail": "สีตัวละครเปลี่ยนไปเรื่อย ไม่รุ้ว่าเราสีอะไรแน่ งงไปหมด", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 8, + "name": "หน้าเข้าร่วมเกมส์", + "detail": "ห้องไหนที่เริ่มเกมส์ไปแล้ว เมื่อกดเข้าร่วมแสดง popup alert error ปรับดีไซต์ให้สวยงามกว่านี้", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 9, + "name": "เกมวิ่งขึ้นบันได ผิด flow", + "detail": "ปรับ flow ให้ถูกต้อง คือ ใครตกจอตาย แต่ถ้าขึ้นไปอยู่บนสุดแล้วจะกระโดดไม่ไปต่อเฉยๆ แต่ไม่ตาย", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 10, + "name": "เกมวิ่งขึ้นบันได", + "detail": "เวลาหมดแล้ว ให้เล่นรอบ 2 auto / รอบ 3 มี popup ให้กดเล่นต่อ", + "prio": "", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 2" + }, + { + "n": 11, + "name": "เกมยิงลูกโป่ง", + "detail": "ฉากเกมยิงลูกโป่ง (MG7 Mega Virus) เล็กไป — ต้องการให้ภาพพื้นหลัง (BG) เต็มจอ แต่การเดิน (movement) และพื้นที่เล่น (area) ต่างๆ คงเดิม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod play.js?v=0.006250401): BG ของ MG7 เป็นภาพ 16:9 (1920x1080) แต่โค้ดเดิม map ภาพเข้า world rect 4:3 (640x480) ทำให้ภาพถูกบีบ + เหลือขอบดำซ้าย-ขวา = ฉากดูเล็ก. เปลี่ยนการวาด BG ของ balloon_boss เป็นแบบ cover เต็มจอ (รักษาสัดส่วน 16:9, crop ส่วนเกิน, จัดกึ่งกลาง) วาดแยกจาก world layer → กล้อง/movement/area ของเกมคงเดิมทุกอย่าง เปลี่ยนเฉพาะพื้นหลังให้เต็มจอ. ไฟล์: Game/public/js/play.js (draw loop, สาขา isBalloonBoss cover-fill).", + "cat": "Minigame 7" + }, + { + "n": 14, + "name": "Room ID", + "detail": "เวลาจอยห้องให้กรอกเเค่ ID กับรหัสที่สร้าง", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 16, + "name": "grid กับ ฉาก ไม่ตรงกัน", + "detail": "เกม Quiz battle เกมที่ 1 ฉากกับกริดไม่ตรงกัน ทำให้เดินทะลุ หรือเดินติดในจุดที่ไม่ควรติด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 17, + "name": "ตัวละครวิ่งเร็วเกินไป", + "detail": "เกม Quiz battle เกมที่ 1 ตัวละครผู้เล่นเร็วเกินไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 18, + "name": "Oauth Social log-in error", + "detail": "ไม่สามารถ log-inด้วย facebook / Google ได้ path: /oauth-public.php", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 19, + "name": "หน้า login", + "detail": "นโยบายความเป็นส่วนตัวกดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 20, + "name": "แสดงเลข version ตรงมุมจอ", + "detail": "แสดงเลข version ตรงมุมจอขวาบนหรือขวาล่างก็ได้", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — badge มุมขวาล่าง อ่าน ?v= อัตโนมัติ", + "cat": "อื่นๆ" + }, + { + "n": 21, + "name": "ลดขนาดตัวละคร", + "detail": "ลดขนาดตัวละครในห้อง lobby ลงอีก 2เท่า", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 22, + "name": "เปลี่ยน ui popup ตอนพูดบนหัว", + "detail": "แก้ png seq บนหัวใหม่ เป็นแบบอื่น", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 23, + "name": "ชื่่อตัวละครทับลำโพง/ทับ bubble", + "detail": "ใน lobby ขยับชื่อลงมาอย่าให้ทับ", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 25, + "name": "เพิ่ม ข้อความแจ้งเตือนอัพเดทกลางจอ", + "detail": "ถ้ามีการอัพเดทซีน อัพเดทสีตัวละครให้มีป็อปอัพเด้งมากลางหน้าจอ ว่ามีการอัพเดทและให้รอโหลด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 26, + "name": "เอาเงาตัวละครออก 1 อัน", + "detail": "ตอนนี้ตัวละครมีเงา 2 ชั้น เอาออกไป 1 ชั้น", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — ปิด default-shadow overlay เหลือเงาในสไปรต์", + "cat": "อื่นๆ" + }, + { + "n": 27, + "name": "optimize ตัวละครให้เล็กลงอีก มันโหลดช้า", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 28, + "name": "ปุ่ม start ใน lobby", + "detail": "ทำให้ดูโต้ตอบหน่อย เช่นพอเดินเข้าไปใกล้ให้มัน glow ขึ้นมา", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 32, + "name": "เอา popup ขออนุญาตใช้ไมค์ออกไปเลย น่ารำคาญ", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 34, + "name": "เกม ใช่ หรือไม่ : ตอนจบบั๊ค", + "detail": "ตอนสรุปคะแนน ยังนับเวลาอยุ่ และขึ้น gameover ทับหน้าสรุปคะแนน", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG1 ใช่/ไม่: capture mg1AnyScore ก่อนล้าง (gameover) + guard quiz-phase หลังจบ (timer)", + "cat": "Minigame 1" + }, + { + "n": 35, + "name": "เกมวิ่ง : กดปุ่มไม่กระโดด", + "detail": "ปุ่ม jump กดแล้วไม่กระโดด", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 36, + "name": "เกมวิ่ง : ตอนจบบั๊ค วิ่งเลยฉาก", + "detail": "ตอนจบบั๊ค วิ่งเลยฉาก (ในบางรอบ)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG2 วิ่งเลยฉาก: walk-to-finish ค่อยๆเดินเข้าเส้น + clamp ไม่เลยเส้น", + "cat": "Minigame 2" + }, + { + "n": 40, + "name": "จำนวนคน ให้เล่นได้ตั้งแต่ 4-6 คน", + "detail": "กรณีเลือกผู้เล่นตั้งแต่ 4 คนขึ้นไปไม่ต้องมี bot", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 41, + "name": "เกม หยิบแผ่นป้าย", + "detail": "ต้องเล่นให้ครบ 10 ข้อก่อน ค่อยจบเกม (ตอนนี้เล่นถูก 1 ข้อ จบเกมเลย)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG4 หยิบป้าย: ใช้ map จริง (currentPlayMapId) เปิด mission flow + default 10 ข้อ", + "cat": "Minigame 4" + }, + { + "n": 42, + "name": "เกม กระโดด", + "detail": "ท่ากระโดดแปลก ยืนซอยเท้าทำไม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "anim ไม่ sync: ยืนบนแท่นที่เลื่อนลง world Y เปลี่ยนทุกเฟรม → ปลายทางเดา isWalking จาก delta = เห็น ซอยเท้า ตลอด. แก้: ส่ง isWalking จริงใน move (jsWalking) + ปลายทางใช้ค่านั้น (play.js ~19162/20501/24930). v0.006260100", + "cat": "Minigame 5" + }, + { + "n": 43, + "name": "เกม กระโดด", + "detail": "ขั้นบันไดมันถี่ไป บางทีกระโดดแล้วเบิ้ลขั้นเลย ปรับให้ระยะห่างแต่ละขั้นมันเท่ากัน", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "ขั้นถี่/ไม่เท่า → เบิ้ลขั้น+ติดรอแท่ง. แก้ layout mnptfts2: ระยะเท่ากันทุกขั้น (gap 3 แถว, rows 2/5/8/11/14/17, zigzag กว้าง6) ในโซนปลอดภัย (เลี่ยง hazard แถว 0/18/19). reach apex 4.5 > 3 = ถึงทุกขั้น (ไม่ติดรอ #43b) + ขั้นถัดไป-อีกขั้น 6 แถว > reach = กระโดดข้ามไม่ได้ (ไม่เบิ้ล). seam รอยต่อ wrap ห่าง 5 แถว 1 จุด/รอบ (h=20 หาร 3 ไม่ลง). restart service หลังแก้แมป | รอบ2: (ก) แก้ลายแท่นบิด — เดิม width เท่ากัน(6)ทำให้ภาพถูกบีบ → ตั้ง width = ขนาดจริงของรูป (เล็ก=var3 7ช่อง/212px, กลาง=var2 13ช่อง/389px) วาดได้สัดส่วนจริงไม่บิด. (ข) ติดบนสุด = seam รอยต่อ wrap 5แถว >ระยะกระโดด(apex4.5) → bump jumpSurviveJumpHeightMult 1.5→1.7 (apex 5.1) ให้ข้าม seam ได้ แต่ข้าม2ขั้น(6แถว)ยังไม่ได้. var1(ใหญ่ 46ช่อง)ใหญ่เกินแมป35 ใช้ไม่ได้ไม่บิด → ใช้ เล็ก/กลาง", + "cat": "Minigame 5" + }, + { + "n": 44, + "name": "เกมกระโดด", + "detail": "อีเวนท์พิเศษ (ทนาย/เทพความรู้) หายไป", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG5 อีเวนต์พิเศษ: เพิ่ม branch jump_survive วาง icon บน platform", + "cat": "Minigame 5" + }, + { + "n": 46, + "name": "เกมยิิงอุกาบาด", + "detail": "สีตัวละครยังไม่ตรงกับที่เลือกมา", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6 สี: ยาน Rocket-1..8 ตาม lobby theme ของผู้เล่น", + "cat": "Minigame 6" + }, + { + "n": 47, + "name": "เกมยิิงอุกาบาด", + "detail": "อุกาบาดให้พุ่งเร็วกว่านี้ และลูกเล็กกว่านี้เกมง่ายไป", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6: อุกาบาตเร็วขึ้น (+~50%) + เล็กลง (48->34/11->8)", + "cat": "Minigame 6" + }, + { + "n": 50, + "name": "ผู้เล่น 6 คน: บางเกมเล่นได้แค่ 5 (คนที่ 6 หายจากสรุป)", + "detail": "สรุปคะแนนฝั่ง client slice(0,5) ตัดคนที่ 6 — แก้เป็น slice(0,6) ทุกมินิเกม (MG1/3/4/5/6/7)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 1" + }, + { + "n": 51, + "name": "ผู้เล่น 6 คน: คะแนนตอนจบไม่ตรงกันแต่ละจอ", + "detail": "MG1 merge คะแนน server / MG6,7 merge คะแนน host -> ครบ 6 ตรงกัน (MG3 ยัง local)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 6" + }, + { + "n": 53, + "name": "เพิ่มหน้าโหลด (loading) ตอนเข้าเกม", + "detail": "overlay spinner กันเห็นจอเปล่า/asset แว่บ ซ่อนเมื่อ draw เฟรมแรก + safety 8s", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 54, + "name": "host โหลดนานมากตอนเข้าเกม", + "detail": "optimize asset/char (เกี่ยว row 28) — loading overlay ช่วยซ่อน แต่ยังโหลดช้าจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "loading overlay บังอาการแล้ว · root = canvas tint ภาพตัวละครใหญ่ (source ไม่อยู่ใน repo) → ต้อง optimize ภาพแยก", + "cat": "อื่นๆ" + }, + { + "n": 56, + "name": "ตอนกลับ LobbyB โชว์รูปแรกก่อนแล้วค่อยถูก", + "detail": "เหมือน #1 การ์ด — ต้องใส่ loading state ตอน return LobbyB ด้วย (มี room-loading-overlay อยู่แต่ซ่อนเร็วไป)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0308 — การ์ดมี spinner (#1) + room-loading overlay ใส่ % + รอ char preload เสร็จก่อนซ่อน + safety 12s", + "cat": "อื่นๆ" + }, + { + "n": 58, + "name": "ปุ่ม \"เล่นเกมต่อ\" ไม่รู้ว่าค้างหรือรอคนอื่น", + "detail": "text \"รอผู้เล่นอื่น (X/Y)\" ถูก set อยู่แล้วแต่ CSS font-size:0 ซ่อน → เพิ่ม .is-waiting CSS โชว์ข้อความ+spinner แทนรูปปุ่ม", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 60, + "name": "ทำหน้า testcase บนเซิร์ฟเวอร์", + "detail": "srv1361159.hstgr.cloud/testcase — bug list + filter/search/นับสถานะ generate จาก xlsx", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 62, + "name": "MG1/MG4 user/bot ติดกัน (เดินไม่ผ่าน)", + "detail": "เพิ่ม anti-stuck: ดันชนเพื่อน → เขย่า (shake) + ปลดล็อกเดินผ่านกันชั่วคราว คงกำแพง/area/quiz-lock; ทั้งผู้เล่นและบอท", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006250100 (รอเทส)", + "cat": "Minigame 4" + }, + { + "n": 65, + "name": "อีเวนต์พิเศษ (ทนาย/เทพ) = 1 ใน 3 (ตามดีไซน์)", + "detail": "ดีไซน์: 3 เกม/LobbyB เล่นได้เกมละ 3 ครั้ง · อีเวนต์พิเศษ 1/3 ต่อครั้ง (1 ใบ) · Test Mode บังคับ. revert การแก้ \">1 ต่อรอบ\" กลับเป็น 1 ใบ/ครั้งที่ติด", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ revert 2026-06-25 — กลับเป็น 1/3 (1 ใบ/ครั้งที่ติด) ตามดีไซน์ · ยืนยันโครงสร้าง 3เกม×3ครั้ง + disruptor toggle (>4คน/Test Mode) ถูกต้อง", + "cat": "อื่นๆ" + }, + { + "n": 71, + "name": "หน้าผลชนะ (โพเดียม) ตัวละครลอย ไม่ยืนบนพื้น", + "detail": "หน้า \"ยินดีด้วย ทีมนักสืบ ชนะ!\" ตัวละครผู้ชนะลอยอยู่กลางอากาศ ไม่ยืนบนพื้นเวที", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod room-lobby.js?v=0.0312): พื้นหลัง win-bg เดิม center/cover ทำให้พื้นเวทีถูก crop เลื่อน + podium ตั้ง bottom:9% ตัวละครเลยลอย. แก้เป็น (1) ปักพื้นหลังไว้ขอบล่าง background-position:center bottom -> พื้นเวทีติดขอบล่างจอเสมอทุกสัดส่วน (2) ลด podium bottom 9% -> 2% ให้ตัวละครยืนบนพื้น. ไฟล์: Game/public/js/room-lobby.js (injectFinalResultStyle, #trial-final-podium).", + "cat": "อื่นๆ" + }, + { + "n": 74, + "name": "แถวหน้าตัวละคร (ready ใต้ปุ่ม HOW TO) ค้างบนจอระหว่างเล่นเกม", + "detail": "หลังกด HOW TO/Ready แล้วเริ่มเกม กล่องแถว avatar คนที่กด ready (ใต้ปุ่ม) ไม่ถูกซ่อน ค้างอยู่กลางจอล่างทับฉากเกม (เห็นชัดใน MG3 stack) — กระทบ MG1/3/5/6", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "กล่องลอย #gch-ready-avatars-float ถูกซ่อนเฉพาะ MG2 (dismissGauntletCrownHowtoOverlayPlay) + MG4 (quiz_carry) เท่านั้น. begin*CountdownThenRun ของ MG1/3/5/6 (stack/jump/space/quiz) ตั้ง gauntletCrownHowtoVisible=false แต่ไม่เรียก hideReadyAvatarsFloat() → กล่องค้าง display:flex ทั้งเกม (ไม่มี driver เรียกใหม่ตอน live). แก้: เพิ่ม hideReadyAvatarsFloat() ในทั้ง 4 ฟังก์ชันเริ่มเกม (play.js). v0.006260101", + "cat": "อื่นๆ" + }, + { + "name": "block สีไม่ตรงในฝั่ง client", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 75 + }, + { + "name": "บางที client บางคนเล่นไม่ได้", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 76 + }, + { + "n": 82, + "name": "MG1: หน้า How-to ใหม่ (SAFE/SCAM)", + "detail": "เปลี่ยน how-to เป็น \"วิเคราะห์ภัยร้าย\" (เดิมเป็นดีไซน์ MG4)", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เข้า MG1 → หน้า HOW TO PLAY ต้องเป็น \"วิเคราะห์ภัยร้าย ปกป้องระบบให้ปลอดภัย\" มี SAFE/SCAM + การ์ด COMMON/RARE/LEGENDARY", + "cat": "Minigame 1" + }, + { + "n": 83, + "name": "MG1: ปุ่ม READY 2 สถานะ", + "detail": "ยังไม่กด=ฟ้า / กดแล้ว=เขียว+เครื่องหมายถูก", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: หน้า ready MG1 → ปุ่มฟ้า · กด → เขียวมี ✓ · กดอีกที → กลับฟ้า", + "cat": "Minigame 1" + }, + { + "n": 84, + "name": "MG1: auto-start เมื่อทุกคน ready", + "detail": "ครบ ready → server เริ่มเอง ไม่ต้องรอ host กด START", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: ทุกคนกด READY ครบ → เกมเริ่มเอง (นับ 3-2-1) ไม่ต้องมีใครกด START · ลองให้ host ออกก่อนคนสุดท้าย ready → ยังเริ่มได้", + "cat": "Minigame 1" + }, + { + "n": 85, + "name": "MG1: คน/บอท วิ่งเร็วเท่ากัน", + "detail": "movement time-based", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 พร้อมกัน → คนวิ่งเร็วเท่ากัน · บอทวิ่งเข้าโซน SAFE/SCAM สม่ำเสมอ", + "cat": "Minigame 1" + }, + { + "n": 86, + "name": "MG1: ผีหาย (avatar ไม่ซ้ำ)", + "detail": "server ส่ง play-roster ทุก 1.5s ตัดตัวซ้ำระหว่างเล่น", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 แล้วมีคนหลุด/reconnect → ต้องไม่มีตัวซ้ำขยับไม่ได้ค้างในสนาม", + "cat": "Minigame 1" + }, + { + "n": 87, + "name": "MG1: บอทเดินต่อแม้ host หลุด", + "detail": "server เป็นเจ้าของการเดินบอท quiz", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 กับบอท · host รีเฟรช/ออกกลางเกม → บอทต้องเดิน+วิ่งเข้าโซนต่อ ไม่หยุดนิ่ง", + "cat": "Minigame 1" + }, + { + "n": 88, + "name": "MG1: คะแนนบอทตรงกันทุกจอ", + "detail": "server คิดคะแนนบอท (เดิม host คิดเอง ไม่ sync)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 กับบอท → คะแนนบอทบนกระดานต้องตรงกันทั้ง 2 จอทุกข้อ", + "cat": "Minigame 1" + }, + { + "n": 89, + "name": "MG1: ผีบอทอยู่ในโซนผี ไม่ทับ SAFE/SCAM", + "detail": "บอทตอบผิด=ผี ตำแหน่งจริงอยู่ในโซนผี (กลาง-บน) — แก้การวาดภาพผีที่เคยห้อยลงทับสนามคำตอบ", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท ให้มีบอทตอบผิด → ตัวผีต้องลอยอยู่แถบกลาง-บน (เหนือ SAFE/SCAM) ไม่ห้อยลงทับช่อง SAFE/SCAM", + "cat": "Minigame 1" + }, + { + "n": 90, + "name": "MG1: บอทเดินลื่น ไม่วาป", + "detail": "บอท quiz เดินจาก server (time-based) + lerp นุ่ม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท → บอทเดินเข้าโซน SAFE/SCAM ลื่น ไม่กระตุก ไม่วาปไปมา", + "cat": "Minigame 1" + }, + { + "n": 92, + "name": "Admin: ตัวเลขผู้ใช้ออนไลน์ + ความจุ", + "detail": "widget 👥 N/500 สบาย/เริ่มหนัก/หนัก (เขียว/เหลือง/แดง) บนแถบ Admin", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เปิดหน้า Admin → มุมขวาบนมี 👥 ตัวเลขผู้เล่นออนไลน์ · hover เห็น CPU/RAM/ห้อง/socket · อัปเดตทุก 10 วิ", + "cat": "อื่นๆ" + }, + { + "n": 93, + "name": "MG1: หลุดกลางเกม → บอท takeover ในที่เดิม", + "detail": "บอทแทนทันที สี/ตำแหน่ง/สถานะเดิม (คะแนนต่อ, ตกรอบ→ผีต่อ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 หลายคน → คนนึงปิดแท็บกลางเกม → ตัวละครต้องไม่หาย กลายเป็นบอทสีเดิม ตำแหน่งเดิม เล่นต่อ + มี \"(บอท)\" หลังชื่อ", + "cat": "Minigame 1" + }, + { + "n": 94, + "name": "MG1: หลุดแล้วกลับมาใน 10วิ → คืนสิทธิ์เล่น", + "detail": "reconnect-undo: ถอนบอท takeover คืนตัวจริง (กันตัวซ้ำ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 → refresh กลางเกม (กลับมาใน 10วิ) → ต้องเล่นเองต่อได้ ไม่มีบอทซ้ำกับตัวเรา", + "cat": "Minigame 1" + }, + { + "n": 95, + "name": "MG1: host หลุดในหน้า how-to → เล่นต่อได้", + "detail": "expectedActive ลดตอนคนออกถาวร → host ใหม่กด START ได้ (เดิมค้าง \"รอ x/y\")", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เข้า MG1 how-to หลายคน → host ปิดแท็บ → รอ ~10วิ → host ใหม่กด START ได้ + บอทสีเดิมเล่นแทน", + "cat": "Minigame 1" + }, + { + "n": 98, + "name": "MG how-to: เห็น countdown ตอนมีคนหลุด", + "detail": "grace banner z-index ขึ้นเหนือ how-to overlay", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: ในหน้า how-to มีคนหลุด → ต้องเห็นแถบ \"🔌 ... หลุด — รอกลับมา N วิ\" ลอยเหนือ how-to (ไม่ถูกบัง)", + "cat": "Minigame 1" + }, + { + "n": 99, + "name": "Login + ตั้งชื่อผู้เล่น", + "detail": "ล็อกอิน/ตั้งชื่อได้ ชื่อไปแสดงในห้อง/เกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เปิดเกม → login → ตั้งชื่อ → เข้า Main Menu → ชื่อตรงทุกหน้า", + "cat": "อื่นๆ" + }, + { + "n": 100, + "name": "Main Menu นำทางครบ", + "detail": "ปุ่มทุกอันใน Main Menu พาไปหน้าถูก (สร้าง/เข้าห้อง, โปรไฟล์, how-to)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กดทุกปุ่มใน Main Menu → ไปหน้าตรงป้าย + ปุ่มย้อนกลับได้", + "cat": "อื่นๆ" + }, + { + "n": 101, + "name": "สร้างห้อง Public", + "detail": "สร้างห้องสาธารณะได้ เข้า LobbyA", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Public → ตั้งชื่อ → ยืนยัน → เข้า LobbyA เป็น host", + "cat": "อื่นๆ" + }, + { + "n": 102, + "name": "สร้างห้อง Private (PIN 4 หลัก)", + "detail": "ห้องส่วนตัวต้องใส่ PIN 4 หลัก ล็อก 🔒", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Private → ใส่ PIN 4 หลัก → ยืนยัน → ห้องขึ้น 🔒 · ใส่ PIN ไม่ครบ = ยืนยันไม่ได้", + "cat": "อื่นๆ" + }, + { + "n": 103, + "name": "สรุปจำนวนบอทถูก (1-3 คน)", + "detail": "เลือก 1/2/3 คน → บอท 5/4/3 (รวม 6)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือกจำนวนผู้เล่น 1,2,3 → summary แสดง '+5/+4/+3 Bot'", + "cat": "อื่นๆ" + }, + { + "n": 104, + "name": "เลือก ≥ 4 คน = ไม่มีบอท", + "detail": "เลือก 4/5/6 คน → 0 Bot ทั้ง summary และในเกมจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือก 4,5,6 → summary '+0 Bot' → เข้าเกมจริงต้องไม่มีบอทเติม (รอคนจริง)", + "cat": "อื่นๆ" + }, + { + "n": 105, + "name": "เข้าห้อง Public จากรายการ", + "detail": "เห็นรายชื่อห้อง public + กดเข้าได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → เห็นห้อง public → กดเข้า → เข้า LobbyA เป็น client", + "cat": "อื่นๆ" + }, + { + "n": 106, + "name": "เข้าห้อง Private ด้วยชื่อ+PIN", + "detail": "เข้าห้องส่วนตัวได้เมื่อใส่ชื่อ+PIN ถูก · ผิด = เข้าไม่ได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → Private → ใส่ชื่อห้อง+PIN → ถูก=เข้าได้ / ผิด=แจ้ง error", + "cat": "อื่นๆ" + }, + { + "n": 107, + "name": "ห้องเต็ม", + "detail": "ห้องเต็ม (ครบ maxPlayers) เข้าไม่ได้ แจ้งเตือน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ห้องมีคนครบ → คนใหม่กดเข้า → แจ้ง 'ห้องเต็ม (n/max)'", + "cat": "อื่นๆ" + }, + { + "n": 108, + "name": "ฉาก LobbyA โหลดเร็ว", + "detail": "เข้าฉากภายใน ~2 วิ ไม่ค้างขาว", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เข้า LobbyA → จับเวลา loading-hide < ~3 วิ (เดิมเคยค้าง 15-40 วิ)", + "cat": "LobbyA" + }, + { + "n": 109, + "name": "กดพร้อม (Ready)", + "detail": "toggle พร้อม/ไม่พร้อม ได้ ทุกคนเห็นตรงกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กด Ready → สถานะเปลี่ยน + คนอื่นเห็น · กดซ้ำ = ยกเลิกพร้อม", + "cat": "LobbyA" + }, + { + "n": 110, + "name": "ห้องแต่งตัว (กด F)", + "detail": "เดินไปจุดเขียว กด F เปิดห้องแต่งตัว เปลี่ยนชุด/ตัวละครได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA → เดินไปไอคอนห้องแต่งตัว → กด F → popup เปิด → เปลี่ยนได้ทุกคน", + "cat": "LobbyA" + }, + { + "n": 111, + "name": "Host Console (กด F, เฉพาะ host)", + "detail": "host เดินไปไอคอนชมพู กด F เปิด console · client กดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: host → จุดชมพู → F → console เปิด · client → F → แจ้ง 'เฉพาะ Host'", + "cat": "LobbyA" + }, + { + "n": 112, + "name": "ปุ่มกลางแมป (START GAME) + glow", + "detail": "ทุกคน+host พร้อม → ปุ่มกลางเรือง glow เหลืองกระพริบ → host กด F เริ่มเกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ทุกคนกด Ready ครบ → ปุ่ม START กลางแมป glow เหลือง → host เดินเข้าใกล้ กด F → เข้า wizard เลือกระดับ/คดี", + "cat": "LobbyA" + }, + { + "n": 113, + "name": "บอทเดินในฉาก ไม่หลุดขอบ", + "detail": "บอท LobbyA เดินในฉากหลัก ไม่ติดขอบ/นอกฉาก", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอทใน LobbyA → ต้องเดินวนในฉาก ไม่ค้างขอบ/หลุดออกนอก", + "cat": "LobbyA" + }, + { + "n": 114, + "name": "เสียงพูดใน Lobby (mic)", + "detail": "เปิด/ปิดไมค์ได้ ได้ยินเสียงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → เปิดไมค์ → พูด → คนอื่นได้ยิน · กดปิด = เงียบ", + "cat": "LobbyA" + }, + { + "n": 115, + "name": "MG1 เข้าเกม + how-to", + "detail": "เข้า MG1 เห็น how-to → เริ่มพร้อมกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เริ่มเกม → เข้า MG1 → how-to ขึ้น → ปิด/countdown → เริ่มพร้อมกันทุกคน", + "cat": "Minigame 1" + }, + { + "n": 116, + "name": "MG1 ตอบถูก/ผิด", + "detail": "ตอบถูกได้คะแนน · ตอบผิดกลายเป็นผี/ล็อก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เดินเข้าโซนจริง/ไม่จริง → ถูก=คะแนน+ / ผิด=ล็อก(ผี) ตรงกันทุกเครื่อง", + "cat": "Minigame 1" + }, + { + "n": 117, + "name": "MG1 บอทเล่น + คะแนนตรง", + "detail": "บอทตอบเอง คะแนน/สถานะบอทตรงทุกเครื่อง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG1 → ตอบเอง + คะแนน/ล็อก ตรงทุก client", + "cat": "Minigame 1" + }, + { + "n": 118, + "name": "MG1 จบเกม + สรุปคะแนน", + "detail": "หมดเวลา/ทุกคนตอบผิด → จบ + สรุปคะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ (complete/timeup) + คะแนน/coin ตามอันดับ", + "cat": "Minigame 1" + }, + { + "n": 119, + "name": "MG2 วิ่ง+กระโดดหลบ", + "detail": "เดิน/วิ่ง + กระโดดข้ามสิ่งกีดขวางได้ ชน=เสียแต้ม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → วิ่งหลบ → กระโดดข้าม obstacle · โดนเลเซอร์/สิ่งกีดขวาง = penalty", + "cat": "Minigame 2" + }, + { + "n": 120, + "name": "MG2 ปรับความสูง/เวลากระโดด (Admin)", + "detail": "ปรับ 'ความสูงกระโดด' + 'เวลากระโดดขึ้น-ลง' ใน Admin → มีผลรอบถัดไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Admin → Minigame-2 → ตั้งความสูง/tick → เริ่มเกมใหม่ → กระโดดสูง/เร็วตามตั้ง", + "cat": "Minigame 2" + }, + { + "n": 121, + "name": "MG2 finish/จบ", + "detail": "วิ่งถึงเส้นชัย/หมดเวลา → จบถูก ไม่ค้าง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → ถึงปลายทาง หรือหมดเวลา → ฉากจบ + คะแนน", + "cat": "Minigame 2" + }, + { + "n": 122, + "name": "MG3 ต่อบล็อกตรง", + "detail": "เล็งตรง support → บล็อกลงตรง ไม่เฉียง ต่อได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG3 → กด DROP ตอนบล็อกอยู่เหนือบล็อกก่อน → ลงตรงดิ่ง ทับพอดี (ไม่ไถลเฉียง)", + "cat": "Minigame 3" + }, + { + "n": 123, + "name": "MG3 หัวใจรวม + จบเกม", + "detail": "หัวใจ (System Integrity) เป็นของรวม · ใครพลาดก็ลด · พลาด 3 ครั้ง = จบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → ผลัดกันวาง · วางเยื้องมาก = หัวใจลด (ทุกคนเห็นเท่ากัน) · ครบ 3 = จบเกม", + "cat": "Minigame 3" + }, + { + "n": 124, + "name": "MG3 บอทวางตรง support", + "detail": "บอทวางบล็อกลงตรงยอดตึก ไม่เฉียง/หลุด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG3 → วางลงตรงบล็อกก่อนหน้า (ตึกไม่เป๋)", + "cat": "Minigame 3" + }, + { + "n": 125, + "name": "MG3 สีบล็อกตรง avatar", + "detail": "สีบล็อกที่วาง = สีตัวละครคนวาง ทุกเครื่องตรงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: วาง → สีบล็อกตรงสี avatar คนวาง (host/client/บอท ตรงกัน)", + "cat": "Minigame 3" + }, + { + "n": 126, + "name": "MG3 ตัวห้อยแกว่งลื่น", + "detail": "บล็อกที่ห้อยแกว่งลื่น ไม่กระตุก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบล็อกห้อย → แกว่งนุ่ม ไม่กระตุก/วาป", + "cat": "Minigame 3" + }, + { + "n": 127, + "name": "MG4 หยิบคำ-ส่งคำตอบ", + "detail": "หยิบ word option จากโต๊ะข้าง → เดินไปส่งที่โต๊ะกลาง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG4 → เดินไปโต๊ะ option → หยิบคำ → เดินไปโต๊ะ submit → ส่ง → ถูก=คะแนน", + "cat": "Minigame 4" + }, + { + "n": 128, + "name": "MG4 บอทไม่ยืนบนโต๊ะกลาง", + "detail": "บอทไม่ spawn/ยืนติดบนคอนโซลกลาง เดินหยิบ-ส่งปกติ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG4 → ไม่มีบอทค้างยืนบนโต๊ะกลาง · บอทหยิบ-ส่งได้", + "cat": "Minigame 4" + }, + { + "n": 129, + "name": "MG4 จบ + คะแนน", + "detail": "หมดเวลา/ครบข้อ → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ + คะแนนตามส่ง", + "cat": "Minigame 4" + }, + { + "n": 130, + "name": "MG5 กระโดดขึ้นแท่นเอาตัวรอด", + "detail": "กระโดดขึ้นแท่นตามสายพานเลื่อน ตก= ตกรอบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 → กระโดดขึ้นแท่น หนีพื้นเลื่อน · ตกล่าง = eliminated", + "cat": "Minigame 5" + }, + { + "n": 131, + "name": "MG5 สลับแท็บ (unfocus) ตกเนียน", + "detail": "สลับแท็บออก → ตัวละครตกลงต่อ คนอื่นเห็นลื่น (ไม่ลอยค้าง/กระตุก)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 กำลังเล่น → สลับไปแท็บอื่น 5 วิ → คนอื่นเห็นตัวเราไหลลงเนียน", + "cat": "Minigame 5" + }, + { + "n": 132, + "name": "MG5 บอท + จบเกม", + "detail": "บอทกระโดดเอง · จบเกม (all-dead/timeup) + เกรด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG5 กระโดดเอง → เล่นจนจบ → เกรด/คะแนนถูก", + "cat": "Minigame 5" + }, + { + "n": 133, + "name": "MG6 ยิงอุกาบาต", + "detail": "บังคับยาน + ยิง หลบอุกาบาต โดน 3 ครั้ง=ตาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG6 → ขยับยาน ยิง · โดนอุกาบาต 3 = eliminated", + "cat": "Minigame 6" + }, + { + "n": 134, + "name": "MG6 บอทขยับทุก client", + "detail": "บอททุกตัวขยับเห็นตรงกันทุกเครื่อง (ไม่มีบอทค้าง)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน MG6 → บอททุกตัวต้องขยับบนทุกจอ (ไม่มีตัวค้างนิ่งเฉพาะบางเครื่อง)", + "cat": "Minigame 6" + }, + { + "n": 135, + "name": "MG6 จบ + คะแนน", + "detail": "หมดเวลา/รอด → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน/coin ตามอันดับ", + "cat": "Minigame 6" + }, + { + "n": 136, + "name": "MG7 ยิงบอส (Mega Virus)", + "detail": "เล็ง+ยิงบอส ลด HP บอส · บอสชนเรา=เสียหาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG7 → หมุนเล็ง ยิงบอส → HP บอสลด · บอสเคลื่อนที่ชน = เด้ง/เสียหาย", + "cat": "Minigame 7" + }, + { + "n": 137, + "name": "MG7 บอทยิง+หลบบอส", + "detail": "บอทยิงบอส + หลบบอส ไม่ค้างนิ่ง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG7 → ยิงบอส + ดริฟต์หลบ ไม่ติดนิ่ง", + "cat": "Minigame 7" + }, + { + "n": 138, + "name": "MG7 จบ + คะแนน", + "detail": "บอสตาย/หมดเวลา → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน", + "cat": "Minigame 7" + }, + { + "n": 139, + "name": "กลับ LobbyB หลังจบมินิเกม", + "detail": "จบรอบมินิเกม → กลับเข้า LobbyB (หน้าคดี)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → interstitial → เข้า LobbyB", + "cat": "LobbyB" + }, + { + "n": 140, + "name": "หน้าเลือกผู้ต้องสงสัยเปิด", + "detail": "จบเคส → หน้าเลือกผู้ต้องสงสัยเปิดอัตโนมัติทุกคน", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบเคส → LobbyB → overlay เลือกผู้ต้องสงสัยขึ้นทุกเครื่อง (ไม่ค้าง/ไม่ขึ้น)", + "cat": "LobbyB" + }, + { + "n": 141, + "name": "ปุ่มกลาง (F) เปิดผู้ต้องสงสัยซ้ำ", + "detail": "ปิด overlay แล้ว → ปุ่มกลางแมป glow → กด F เปิดกลับ (แทนปุ่มลอยเดิม)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ปิดหน้าผู้ต้องสงสัย → ปุ่มกลาง glow → เดินเข้าใกล้ กด F → overlay เปิดกลับ", + "cat": "LobbyB" + }, + { + "n": 142, + "name": "เสนอบทตัวป่วน", + "detail": "≥4 คน (หรือเปิด force) → มีการเสนอบทตัวป่วน กดรับ/ปฏิเสธได้ในเวลาที่ตั้ง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบเคส → ระบบเสนอบทตัวป่วนให้ 1 คน → countdown (ตาม Admin) → กดรับ/ปฏิเสธ", + "cat": "LobbyB" + }, + { + "n": 143, + "name": "สืบผู้ต้องสงสัย (เก็บหลักฐาน)", + "detail": "สืบครบ 3 ผู้ต้องสงสัย (เล่นมินิเกม) → เก็บหลักฐานได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เลือกผู้ต้องสงสัย → เล่นมินิเกม → ได้หลักฐาน +1 ต่อคน", + "cat": "LobbyB" + }, + { + "n": 144, + "name": "โหวตชี้ตัวคนร้าย (พิจารณาคดี)", + "detail": "สืบครบ → โหวตชี้คนร้าย → ผลตัดสินถูก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "🧪 Flow TC · วิธีเทส: สืบครบ 3 → เปิดพิจารณาคดี → โหวต → เฉลยถูก/ผิด · 🔎 gamelog: [TC144] / ev=lobbyb-gate (LobbyB) · deploy 2026-07-01", + "cat": "LobbyB" + }, + { + "n": 145, + "name": "หน้า Cyber Awareness หลังตัดสิน", + "detail": "หลังตัดสินคดี → หน้าให้ความรู้กฎหมายไซเบอร์ ก่อนกลับ Main Lobby", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ตัดสินคดีจบ → interstitial cyber-law → กลับ Main-Lobby", + "cat": "LobbyB" + }, + { + "n": 146, + "name": "Host หลุด → migration", + "detail": "host ปิด/หลุด → เลือก host ใหม่อัตโนมัติ เกมไปต่อได้", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างเกม host ปิดแท็บ → ~10วิ → host ใหม่ถูกตั้ง เกมไม่ค้าง", + "cat": "ระบบ disconnect" + }, + { + "n": 147, + "name": "ผู้เล่นหลุด → บอทแทน", + "detail": "คนหลุดกลางมินิเกม → บอทเล่นแทน (สีเดิม) ไม่ค้าง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม ผู้เล่นปิดแท็บ → บอท takeover เล่นแทน (สีตามคนออก)", + "cat": "ระบบ disconnect" + }, + { + "n": 148, + "name": "แถบ grace ตอนมีคนหลุด", + "detail": "มีคนหลุด → แถบ '🔌 ... หลุด รอกลับ N วิ' ขึ้น (เหนือ how-to)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: คนหลุด → แถบ grace countdown ขึ้นทุกเครื่อง (ลอยเหนือ overlay)", + "cat": "ระบบ disconnect" + }, + { + "n": 149, + "name": "Reconnect กลับเข้าเกม", + "detail": "หลุดแล้วต่อกลับ → กลับเข้าห้อง/มินิเกมเดิมได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลุดเน็ตชั่วคราว → เชื่อมกลับ → auto re-join ห้องเดิม ไม่ต้องเริ่มใหม่", + "cat": "ระบบ disconnect" + }, + { + "n": 150, + "name": "คะแนน/COINS ตามอันดับ", + "detail": "จบมินิเกม → รับ coin ตามอันดับ (ผู้รอด)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → ผู้รอดได้ coin [10,8,6,4,2,0] ตามอันดับ + คะแนนสะสมขึ้น leaderboard", + "cat": "อื่นๆ" + }, + { + "n": 151, + "name": "กระดานผู้นำ (Leaderboard)", + "detail": "คะแนนสะสม/high score แสดงถูกในกระดานผู้นำ", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบ → เช็ค leaderboard → คะแนนอัปเดตถูก", + "cat": "อื่นๆ" + }, + { + "n": 152, + "name": "เสียงพูดในมินิเกม (MG1-7)", + "detail": "เปิดไมค์ในมินิเกม → ได้ยินกัน (WebRTC mesh)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม → กดไมค์ → พูด → คนอื่นได้ยิน", + "cat": "อื่นๆ" + }, + { + "n": 153, + "name": "ออกจากห้อง (Exit)", + "detail": "ออกจาก Lobby → host ไป Create Room / client ไป Join Room", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA/B → กด Exit → host→Create, client→Join", + "cat": "อื่นๆ" + }, + { + "n": 154, + "name": "LobbyA: ล็อก unready ระหว่าง host เลือกคดี", + "detail": "ทุกคน ready + host เปิด wizard เลือกระดับ/คดี → client กด unready ไม่ได้ (ปุ่ม disable + ป้าย \"host กำลังเลือกคดี\")", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: ทุกคนกด Ready ครบ → host กด F เปิด wizard → อีกเครื่องลองกดยกเลิก Ready → ต้องกดไม่ได้ · 🔎 gamelog: [TC154] / ev=preplay-lock (LobbyA) · deploy 2026-07-01", + "cat": "LobbyA" + }, + { + "n": 155, + "name": "LobbyB: ต้องรอทุกคนเข้าครบก่อน \"ชี้ตัวคนร้าย\"", + "detail": "ปุ่มชี้ตัวคนร้าย (พิจารณาคดี) เปิดได้เฉพาะเมื่อผู้เล่นเข้า LobbyB ครบทุกคน · ยังไม่ครบ = disable + ป้าย \"รอผู้เล่น (n/total)\"", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: จบเคส → คนบางคนยังไม่เข้า LobbyB → host/ระบบต้องยังชี้ตัวไม่ได้ จนครบทุกคน", + "cat": "LobbyB" + }, + { + "n": 156, + "name": "LobbyA→B: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story ครบ", + "detail": "มีหน้าอ่านเนื้อเรื่อง story 1-2 (cutscene) ก่อน LobbyB · host เลือกผู้ต้องสงสัยไม่ได้จนกว่าทุกคนกด \"ไปต่อ\" อ่านจบครบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: เข้า LobbyB → หน้า story ขึ้น → บางคนยังไม่กดอ่านจบ → host ต้องเลือกผู้ต้องสงสัยไม่ได้ (ป้าย \"รออ่านเนื้อเรื่อง n/total\") · 🔎 gamelog: [TC156] / ev=story-gate (LobbyB) · deploy 2026-07-01", + "cat": "LobbyA" + }, + { + "n": 157, + "name": "Vote Ban: เริ่มโหวตเมื่อทุกคนเข้า+โหลดเสร็จ · ยังไม่โหวต=เลือกผู้ต้องสงสัยไม่ได้", + "detail": "โหวตแบน/ปิดปากผู้เล่น เริ่มเมื่อทุกคนเข้า LobbyB + โหลดเสร็จเท่านั้น · ถ้ายังไม่โหวตเสร็จ host เลือกผู้ต้องสงสัยไม่ได้ (ตาม flow)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: จบเคสที่มีการ์ด ban/silence → ต้องรอทุกคนเข้า+โหลดเสร็จ แล้วค่อยเริ่มโหวต → ระหว่างโหวต host เลือกผู้ต้องสงสัยไม่ได้จนโหวตจบ · 🔎 gamelog: [TC157] / ev=vote-gate (LobbyB) · deploy 2026-07-01", + "cat": "LobbyB" + }, + { + "n": 158, + "name": "MG4: บอทว๊าป/กระโดดข้ามโต๊ะกลาง", + "detail": "บอทต้องเดินอ้อมโต๊ะกลาง ไม่ว๊าป/กระโดดข้าม (ลื่นไม่ teleport)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "📋 PLAN (flow/sound) · วิธีเทส: MG4 หลายรอบ → สังเกตบอท: ต้องไม่กระโดด/วาปข้ามโต๊ะกลาง · เดินอ้อมเนียน · 🔎 gamelog: [TC158] / ev=mg4-warp (MG4 · pass = ไม่มี event) · deploy 2026-07-01", + "cat": "Minigame 4" + }, + { + "n": 159, + "name": "เพิ่ม BG Music + Sound Effect", + "detail": "มีเพลงพื้นหลังต่อฉาก (เมนู/LobbyA/B/มินิเกม) + SFX เหตุการณ์ (ปุ่ม, ready, drop, ยิง, ถูก/ผิด, ชนะ/แพ้) + ปุ่ม 🔊/🔇 ปรับ/ปิดได้", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-07-02", + "note": "✅ Phase 1 deploy 2026-07-02 (sound.js?v=1 · play.js?v=0.006301061 · room-lobby.js?v=0.0342). ครบ: BGM 7 เกม+lobby · เสียงจบเกม ชนะ/หมดเวลา/แพ้ ครบ 7 · เสียงคลิกปุ่ม · MG4 ถูก/ผิด · toggle Music/SFX (profile) จำค่า localStorage · BGM หรี่ตอนพูด (ไมค์ไม่โดนกลบ). เทสย่อย: TC160-164. ⏳ Phase 2: SFX รายเกม (drop/jump/laser/bomb/countdown) · Main-Menu toggle · Admin volume. เทส = ฟังเอง (client-side ไม่มี gamelog)", + "cat": "เสียง (Sound)" + }, + { + "n": 160, + "name": "เสียง: BGM ต่อฉาก (lobby + MG1-7)", + "detail": "เข้า LobbyA/B และแต่ละมินิเกม → มีเพลงพื้นหลังเล่นวน · เปลี่ยนเกม → เพลงเปลี่ยนตาม", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "lobby ยืม BGM มินิเกม 4 · เริ่มเล่นตอนแตะจอครั้งแรก (นโยบาย autoplay) · idempotent", + "cat": "เสียง (Sound)" + }, + { + "n": 161, + "name": "เสียง: จบเกม ชนะ/หมดเวลา/แพ้ (ครบ 7 เกม)", + "detail": "จบมินิเกมทั้ง 7 → ชนะ = แฟนแฟร์ · หมดเวลา = นกหวีด · แพ้ = game-over · BGM หยุด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "hook begin*ResultFlash* ทุกเกม (jdEndSfxFromFile) · MG7 ไม่มีไฟล์ timeup", + "cat": "เสียง (Sound)" + }, + { + "n": 162, + "name": "เสียง: ปุ่มคลิก + MG4 ตอบถูก/ผิด", + "detail": "กดปุ่มจริง → เสียงคลิก · MG4 ตอบถูก = เสียง correct / ผิด = เสียง wrong", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "ปุ่มคลิก = delegated (เฉพาะ element ปุ่มจริง)", + "cat": "เสียง (Sound)" + }, + { + "n": 163, + "name": "เสียง: ปุ่ม Music/SFX (profile) เปิด-ปิด + จำค่า", + "detail": "profile → กด toggle Music / SFX → เปิด/ปิดเสียงได้จริง · รีเฟรช/เปลี่ยนหน้า ค่ายังเดิม", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "localStorage jd_music_on / jd_sfx_on มีผลทุกหน้า · Main-Menu ยังไม่ wire (Phase 2)", + "cat": "เสียง (Sound)" + }, + { + "n": 164, + "name": "เสียง: ไมค์ไม่โดนกลบ (หรี่ BGM ตอนพูด)", + "detail": "เปิดไมค์คุย → BGM หรี่ลงอัตโนมัติ เสียงคนชัด ไม่โดนเพลง/เอฟเฟกต์กลบ (caution)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-02", + "note": "duck ×0.22 ตอน voice-activity · ตอนนี้ duck เฉพาะตอนเราพูด (remote = Phase 2)", + "cat": "เสียง (Sound)" + } +] \ No newline at end of file diff --git a/www/html/testcase/data/bugs.json.bak-20260702-sound b/www/html/testcase/data/bugs.json.bak-20260702-sound new file mode 100644 index 0000000..86d0b7c --- /dev/null +++ b/www/html/testcase/data/bugs.json.bak-20260702-sound @@ -0,0 +1,1212 @@ +[ + { + "n": 2, + "name": "ตัวละครไม่แสดง", + "detail": "หน้าเลือกเกม Quiz battle ภาพตัวละครไม่แสดง", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 3, + "name": "ระบบ join เกมมีปัญหา", + "detail": "เมื่อเข้ามินิเกม : บางคนเล่นเกมกะบอท บางคนเล่นกะเพื่อน แต่ละคนเข้ามินิเกมแล้วเจอกันบ้าง ไม่เจอกันบ้าง", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 5, + "name": "ระบบ Host มีปัญหา - 2", + "detail": "เมื่อกดออกจากเกม Host เปลี่ยนจากพี่ต้อมเป็นพี่คิวอัติโนมัติ", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 6, + "name": "ระบบ Host มีปัญหา - 3", + "detail": "เมื่อเข้ามินิเกม ทุกคนเห็นตัวเองเป็น host หมดเลย แต่กดเริ่มเกมไม่ได้", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 7, + "name": "ระบบสีตัวละคร ยังใช้ไม่ได้", + "detail": "สีตัวละครเปลี่ยนไปเรื่อย ไม่รุ้ว่าเราสีอะไรแน่ งงไปหมด", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 8, + "name": "หน้าเข้าร่วมเกมส์", + "detail": "ห้องไหนที่เริ่มเกมส์ไปแล้ว เมื่อกดเข้าร่วมแสดง popup alert error ปรับดีไซต์ให้สวยงามกว่านี้", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 9, + "name": "เกมวิ่งขึ้นบันได ผิด flow", + "detail": "ปรับ flow ให้ถูกต้อง คือ ใครตกจอตาย แต่ถ้าขึ้นไปอยู่บนสุดแล้วจะกระโดดไม่ไปต่อเฉยๆ แต่ไม่ตาย", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 10, + "name": "เกมวิ่งขึ้นบันได", + "detail": "เวลาหมดแล้ว ให้เล่นรอบ 2 auto / รอบ 3 มี popup ให้กดเล่นต่อ", + "prio": "", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "Minigame 2" + }, + { + "n": 11, + "name": "เกมยิงลูกโป่ง", + "detail": "ฉากเกมยิงลูกโป่ง (MG7 Mega Virus) เล็กไป — ต้องการให้ภาพพื้นหลัง (BG) เต็มจอ แต่การเดิน (movement) และพื้นที่เล่น (area) ต่างๆ คงเดิม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod play.js?v=0.006250401): BG ของ MG7 เป็นภาพ 16:9 (1920x1080) แต่โค้ดเดิม map ภาพเข้า world rect 4:3 (640x480) ทำให้ภาพถูกบีบ + เหลือขอบดำซ้าย-ขวา = ฉากดูเล็ก. เปลี่ยนการวาด BG ของ balloon_boss เป็นแบบ cover เต็มจอ (รักษาสัดส่วน 16:9, crop ส่วนเกิน, จัดกึ่งกลาง) วาดแยกจาก world layer → กล้อง/movement/area ของเกมคงเดิมทุกอย่าง เปลี่ยนเฉพาะพื้นหลังให้เต็มจอ. ไฟล์: Game/public/js/play.js (draw loop, สาขา isBalloonBoss cover-fill).", + "cat": "Minigame 7" + }, + { + "n": 14, + "name": "Room ID", + "detail": "เวลาจอยห้องให้กรอกเเค่ ID กับรหัสที่สร้าง", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 16, + "name": "grid กับ ฉาก ไม่ตรงกัน", + "detail": "เกม Quiz battle เกมที่ 1 ฉากกับกริดไม่ตรงกัน ทำให้เดินทะลุ หรือเดินติดในจุดที่ไม่ควรติด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 1" + }, + { + "n": 17, + "name": "ตัวละครวิ่งเร็วเกินไป", + "detail": "เกม Quiz battle เกมที่ 1 ตัวละครผู้เล่นเร็วเกินไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 18, + "name": "Oauth Social log-in error", + "detail": "ไม่สามารถ log-inด้วย facebook / Google ได้ path: /oauth-public.php", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 19, + "name": "หน้า login", + "detail": "นโยบายความเป็นส่วนตัวกดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 20, + "name": "แสดงเลข version ตรงมุมจอ", + "detail": "แสดงเลข version ตรงมุมจอขวาบนหรือขวาล่างก็ได้", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — badge มุมขวาล่าง อ่าน ?v= อัตโนมัติ", + "cat": "อื่นๆ" + }, + { + "n": 21, + "name": "ลดขนาดตัวละคร", + "detail": "ลดขนาดตัวละครในห้อง lobby ลงอีก 2เท่า", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 22, + "name": "เปลี่ยน ui popup ตอนพูดบนหัว", + "detail": "แก้ png seq บนหัวใหม่ เป็นแบบอื่น", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 23, + "name": "ชื่่อตัวละครทับลำโพง/ทับ bubble", + "detail": "ใน lobby ขยับชื่อลงมาอย่าให้ทับ", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 25, + "name": "เพิ่ม ข้อความแจ้งเตือนอัพเดทกลางจอ", + "detail": "ถ้ามีการอัพเดทซีน อัพเดทสีตัวละครให้มีป็อปอัพเด้งมากลางหน้าจอ ว่ามีการอัพเดทและให้รอโหลด", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 26, + "name": "เอาเงาตัวละครออก 1 อัน", + "detail": "ตอนนี้ตัวละครมีเงา 2 ชั้น เอาออกไป 1 ชั้น", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — ปิด default-shadow overlay เหลือเงาในสไปรต์", + "cat": "อื่นๆ" + }, + { + "n": 27, + "name": "optimize ตัวละครให้เล็กลงอีก มันโหลดช้า", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 28, + "name": "ปุ่ม start ใน lobby", + "detail": "ทำให้ดูโต้ตอบหน่อย เช่นพอเดินเข้าไปใกล้ให้มัน glow ขึ้นมา", + "prio": "กลาง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 32, + "name": "เอา popup ขออนุญาตใช้ไมค์ออกไปเลย น่ารำคาญ", + "detail": "", + "prio": "สูง", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 34, + "name": "เกม ใช่ หรือไม่ : ตอนจบบั๊ค", + "detail": "ตอนสรุปคะแนน ยังนับเวลาอยุ่ และขึ้น gameover ทับหน้าสรุปคะแนน", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG1 ใช่/ไม่: capture mg1AnyScore ก่อนล้าง (gameover) + guard quiz-phase หลังจบ (timer)", + "cat": "Minigame 1" + }, + { + "n": 35, + "name": "เกมวิ่ง : กดปุ่มไม่กระโดด", + "detail": "ปุ่ม jump กดแล้วไม่กระโดด", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "Minigame 5" + }, + { + "n": 36, + "name": "เกมวิ่ง : ตอนจบบั๊ค วิ่งเลยฉาก", + "detail": "ตอนจบบั๊ค วิ่งเลยฉาก (ในบางรอบ)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG2 วิ่งเลยฉาก: walk-to-finish ค่อยๆเดินเข้าเส้น + clamp ไม่เลยเส้น", + "cat": "Minigame 2" + }, + { + "n": 40, + "name": "จำนวนคน ให้เล่นได้ตั้งแต่ 4-6 คน", + "detail": "กรณีเลือกผู้เล่นตั้งแต่ 4 คนขึ้นไปไม่ต้องมี bot", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "", + "note": "", + "cat": "อื่นๆ" + }, + { + "n": 41, + "name": "เกม หยิบแผ่นป้าย", + "detail": "ต้องเล่นให้ครบ 10 ข้อก่อน ค่อยจบเกม (ตอนนี้เล่นถูก 1 ข้อ จบเกมเลย)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG4 หยิบป้าย: ใช้ map จริง (currentPlayMapId) เปิด mission flow + default 10 ข้อ", + "cat": "Minigame 4" + }, + { + "n": 42, + "name": "เกม กระโดด", + "detail": "ท่ากระโดดแปลก ยืนซอยเท้าทำไม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "anim ไม่ sync: ยืนบนแท่นที่เลื่อนลง world Y เปลี่ยนทุกเฟรม → ปลายทางเดา isWalking จาก delta = เห็น ซอยเท้า ตลอด. แก้: ส่ง isWalking จริงใน move (jsWalking) + ปลายทางใช้ค่านั้น (play.js ~19162/20501/24930). v0.006260100", + "cat": "Minigame 5" + }, + { + "n": 43, + "name": "เกม กระโดด", + "detail": "ขั้นบันไดมันถี่ไป บางทีกระโดดแล้วเบิ้ลขั้นเลย ปรับให้ระยะห่างแต่ละขั้นมันเท่ากัน", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "ขั้นถี่/ไม่เท่า → เบิ้ลขั้น+ติดรอแท่ง. แก้ layout mnptfts2: ระยะเท่ากันทุกขั้น (gap 3 แถว, rows 2/5/8/11/14/17, zigzag กว้าง6) ในโซนปลอดภัย (เลี่ยง hazard แถว 0/18/19). reach apex 4.5 > 3 = ถึงทุกขั้น (ไม่ติดรอ #43b) + ขั้นถัดไป-อีกขั้น 6 แถว > reach = กระโดดข้ามไม่ได้ (ไม่เบิ้ล). seam รอยต่อ wrap ห่าง 5 แถว 1 จุด/รอบ (h=20 หาร 3 ไม่ลง). restart service หลังแก้แมป | รอบ2: (ก) แก้ลายแท่นบิด — เดิม width เท่ากัน(6)ทำให้ภาพถูกบีบ → ตั้ง width = ขนาดจริงของรูป (เล็ก=var3 7ช่อง/212px, กลาง=var2 13ช่อง/389px) วาดได้สัดส่วนจริงไม่บิด. (ข) ติดบนสุด = seam รอยต่อ wrap 5แถว >ระยะกระโดด(apex4.5) → bump jumpSurviveJumpHeightMult 1.5→1.7 (apex 5.1) ให้ข้าม seam ได้ แต่ข้าม2ขั้น(6แถว)ยังไม่ได้. var1(ใหญ่ 46ช่อง)ใหญ่เกินแมป35 ใช้ไม่ได้ไม่บิด → ใช้ เล็ก/กลาง", + "cat": "Minigame 5" + }, + { + "n": 44, + "name": "เกมกระโดด", + "detail": "อีเวนท์พิเศษ (ทนาย/เทพความรู้) หายไป", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG5 อีเวนต์พิเศษ: เพิ่ม branch jump_survive วาง icon บน platform", + "cat": "Minigame 5" + }, + { + "n": 46, + "name": "เกมยิิงอุกาบาด", + "detail": "สีตัวละครยังไม่ตรงกับที่เลือกมา", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6 สี: ยาน Rocket-1..8 ตาม lobby theme ของผู้เล่น", + "cat": "Minigame 6" + }, + { + "n": 47, + "name": "เกมยิิงอุกาบาด", + "detail": "อุกาบาดให้พุ่งเร็วกว่านี้ และลูกเล็กกว่านี้เกมง่ายไป", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส) — MG6: อุกาบาตเร็วขึ้น (+~50%) + เล็กลง (48->34/11->8)", + "cat": "Minigame 6" + }, + { + "n": 50, + "name": "ผู้เล่น 6 คน: บางเกมเล่นได้แค่ 5 (คนที่ 6 หายจากสรุป)", + "detail": "สรุปคะแนนฝั่ง client slice(0,5) ตัดคนที่ 6 — แก้เป็น slice(0,6) ทุกมินิเกม (MG1/3/4/5/6/7)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 1" + }, + { + "n": 51, + "name": "ผู้เล่น 6 คน: คะแนนตอนจบไม่ตรงกันแต่ละจอ", + "detail": "MG1 merge คะแนน server / MG6,7 merge คะแนน host -> ครบ 6 ตรงกัน (MG3 ยัง local)", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-24", + "note": "✅ แก้แล้ว 2026-06-24 v0.006242500 (รอเทส)", + "cat": "Minigame 6" + }, + { + "n": 53, + "name": "เพิ่มหน้าโหลด (loading) ตอนเข้าเกม", + "detail": "overlay spinner กันเห็นจอเปล่า/asset แว่บ ซ่อนเมื่อ draw เฟรมแรก + safety 8s", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006242530 (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 54, + "name": "host โหลดนานมากตอนเข้าเกม", + "detail": "optimize asset/char (เกี่ยว row 28) — loading overlay ช่วยซ่อน แต่ยังโหลดช้าจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-06-25", + "note": "loading overlay บังอาการแล้ว · root = canvas tint ภาพตัวละครใหญ่ (source ไม่อยู่ใน repo) → ต้อง optimize ภาพแยก", + "cat": "อื่นๆ" + }, + { + "n": 56, + "name": "ตอนกลับ LobbyB โชว์รูปแรกก่อนแล้วค่อยถูก", + "detail": "เหมือน #1 การ์ด — ต้องใส่ loading state ตอน return LobbyB ด้วย (มี room-loading-overlay อยู่แต่ซ่อนเร็วไป)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0308 — การ์ดมี spinner (#1) + room-loading overlay ใส่ % + รอ char preload เสร็จก่อนซ่อน + safety 12s", + "cat": "อื่นๆ" + }, + { + "n": 58, + "name": "ปุ่ม \"เล่นเกมต่อ\" ไม่รู้ว่าค้างหรือรอคนอื่น", + "detail": "text \"รอผู้เล่นอื่น (X/Y)\" ถูก set อยู่แล้วแต่ CSS font-size:0 ซ่อน → เพิ่ม .is-waiting CSS โชว์ข้อความ+spinner แทนรูปปุ่ม", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 60, + "name": "ทำหน้า testcase บนเซิร์ฟเวอร์", + "detail": "srv1361159.hstgr.cloud/testcase — bug list + filter/search/นับสถานะ generate จาก xlsx", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.0309/server (รอเทส)", + "cat": "อื่นๆ" + }, + { + "n": 62, + "name": "MG1/MG4 user/bot ติดกัน (เดินไม่ผ่าน)", + "detail": "เพิ่ม anti-stuck: ดันชนเพื่อน → เขย่า (shake) + ปลดล็อกเดินผ่านกันชั่วคราว คงกำแพง/area/quiz-lock; ทั้งผู้เล่นและบอท", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ แก้แล้ว 2026-06-25 v0.006250100 (รอเทส)", + "cat": "Minigame 4" + }, + { + "n": 65, + "name": "อีเวนต์พิเศษ (ทนาย/เทพ) = 1 ใน 3 (ตามดีไซน์)", + "detail": "ดีไซน์: 3 เกม/LobbyB เล่นได้เกมละ 3 ครั้ง · อีเวนต์พิเศษ 1/3 ต่อครั้ง (1 ใบ) · Test Mode บังคับ. revert การแก้ \">1 ต่อรอบ\" กลับเป็น 1 ใบ/ครั้งที่ติด", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "✅ revert 2026-06-25 — กลับเป็น 1/3 (1 ใบ/ครั้งที่ติด) ตามดีไซน์ · ยืนยันโครงสร้าง 3เกม×3ครั้ง + disruptor toggle (>4คน/Test Mode) ถูกต้อง", + "cat": "อื่นๆ" + }, + { + "n": 71, + "name": "หน้าผลชนะ (โพเดียม) ตัวละครลอย ไม่ยืนบนพื้น", + "detail": "หน้า \"ยินดีด้วย ทีมนักสืบ ชนะ!\" ตัวละครผู้ชนะลอยอยู่กลางอากาศ ไม่ยืนบนพื้นเวที", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-25", + "note": "แก้แล้ว (deploy prod room-lobby.js?v=0.0312): พื้นหลัง win-bg เดิม center/cover ทำให้พื้นเวทีถูก crop เลื่อน + podium ตั้ง bottom:9% ตัวละครเลยลอย. แก้เป็น (1) ปักพื้นหลังไว้ขอบล่าง background-position:center bottom -> พื้นเวทีติดขอบล่างจอเสมอทุกสัดส่วน (2) ลด podium bottom 9% -> 2% ให้ตัวละครยืนบนพื้น. ไฟล์: Game/public/js/room-lobby.js (injectFinalResultStyle, #trial-final-podium).", + "cat": "อื่นๆ" + }, + { + "n": 74, + "name": "แถวหน้าตัวละคร (ready ใต้ปุ่ม HOW TO) ค้างบนจอระหว่างเล่นเกม", + "detail": "หลังกด HOW TO/Ready แล้วเริ่มเกม กล่องแถว avatar คนที่กด ready (ใต้ปุ่ม) ไม่ถูกซ่อน ค้างอยู่กลางจอล่างทับฉากเกม (เห็นชัดใน MG3 stack) — กระทบ MG1/3/5/6", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "กล่องลอย #gch-ready-avatars-float ถูกซ่อนเฉพาะ MG2 (dismissGauntletCrownHowtoOverlayPlay) + MG4 (quiz_carry) เท่านั้น. begin*CountdownThenRun ของ MG1/3/5/6 (stack/jump/space/quiz) ตั้ง gauntletCrownHowtoVisible=false แต่ไม่เรียก hideReadyAvatarsFloat() → กล่องค้าง display:flex ทั้งเกม (ไม่มี driver เรียกใหม่ตอน live). แก้: เพิ่ม hideReadyAvatarsFloat() ในทั้ง 4 ฟังก์ชันเริ่มเกม (play.js). v0.006260101", + "cat": "อื่นๆ" + }, + { + "name": "block สีไม่ตรงในฝั่ง client", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 75 + }, + { + "name": "บางที client บางคนเล่นไม่ได้", + "detail": "", + "prio": "กลาง", + "status": "เปิด", + "note": "", + "cat": "Minigame 3", + "updated": "2026-06-26", + "n": 76 + }, + { + "n": 82, + "name": "MG1: หน้า How-to ใหม่ (SAFE/SCAM)", + "detail": "เปลี่ยน how-to เป็น \"วิเคราะห์ภัยร้าย\" (เดิมเป็นดีไซน์ MG4)", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เข้า MG1 → หน้า HOW TO PLAY ต้องเป็น \"วิเคราะห์ภัยร้าย ปกป้องระบบให้ปลอดภัย\" มี SAFE/SCAM + การ์ด COMMON/RARE/LEGENDARY", + "cat": "Minigame 1" + }, + { + "n": 83, + "name": "MG1: ปุ่ม READY 2 สถานะ", + "detail": "ยังไม่กด=ฟ้า / กดแล้ว=เขียว+เครื่องหมายถูก", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: หน้า ready MG1 → ปุ่มฟ้า · กด → เขียวมี ✓ · กดอีกที → กลับฟ้า", + "cat": "Minigame 1" + }, + { + "n": 84, + "name": "MG1: auto-start เมื่อทุกคน ready", + "detail": "ครบ ready → server เริ่มเอง ไม่ต้องรอ host กด START", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: ทุกคนกด READY ครบ → เกมเริ่มเอง (นับ 3-2-1) ไม่ต้องมีใครกด START · ลองให้ host ออกก่อนคนสุดท้าย ready → ยังเริ่มได้", + "cat": "Minigame 1" + }, + { + "n": 85, + "name": "MG1: คน/บอท วิ่งเร็วเท่ากัน", + "detail": "movement time-based", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 พร้อมกัน → คนวิ่งเร็วเท่ากัน · บอทวิ่งเข้าโซน SAFE/SCAM สม่ำเสมอ", + "cat": "Minigame 1" + }, + { + "n": 86, + "name": "MG1: ผีหาย (avatar ไม่ซ้ำ)", + "detail": "server ส่ง play-roster ทุก 1.5s ตัดตัวซ้ำระหว่างเล่น", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 แล้วมีคนหลุด/reconnect → ต้องไม่มีตัวซ้ำขยับไม่ได้ค้างในสนาม", + "cat": "Minigame 1" + }, + { + "n": 87, + "name": "MG1: บอทเดินต่อแม้ host หลุด", + "detail": "server เป็นเจ้าของการเดินบอท quiz", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: เล่น MG1 กับบอท · host รีเฟรช/ออกกลางเกม → บอทต้องเดิน+วิ่งเข้าโซนต่อ ไม่หยุดนิ่ง", + "cat": "Minigame 1" + }, + { + "n": 88, + "name": "MG1: คะแนนบอทตรงกันทุกจอ", + "detail": "server คิดคะแนนบอท (เดิม host คิดเอง ไม่ sync)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-26", + "note": "วิธีเทส: 2 เครื่องเล่น MG1 กับบอท → คะแนนบอทบนกระดานต้องตรงกันทั้ง 2 จอทุกข้อ", + "cat": "Minigame 1" + }, + { + "n": 89, + "name": "MG1: ผีบอทอยู่ในโซนผี ไม่ทับ SAFE/SCAM", + "detail": "บอทตอบผิด=ผี ตำแหน่งจริงอยู่ในโซนผี (กลาง-บน) — แก้การวาดภาพผีที่เคยห้อยลงทับสนามคำตอบ", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท ให้มีบอทตอบผิด → ตัวผีต้องลอยอยู่แถบกลาง-บน (เหนือ SAFE/SCAM) ไม่ห้อยลงทับช่อง SAFE/SCAM", + "cat": "Minigame 1" + }, + { + "n": 90, + "name": "MG1: บอทเดินลื่น ไม่วาป", + "detail": "บอท quiz เดินจาก server (time-based) + lerp นุ่ม", + "prio": "กลาง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 กับบอท → บอทเดินเข้าโซน SAFE/SCAM ลื่น ไม่กระตุก ไม่วาปไปมา", + "cat": "Minigame 1" + }, + { + "n": 92, + "name": "Admin: ตัวเลขผู้ใช้ออนไลน์ + ความจุ", + "detail": "widget 👥 N/500 สบาย/เริ่มหนัก/หนัก (เขียว/เหลือง/แดง) บนแถบ Admin", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เปิดหน้า Admin → มุมขวาบนมี 👥 ตัวเลขผู้เล่นออนไลน์ · hover เห็น CPU/RAM/ห้อง/socket · อัปเดตทุก 10 วิ", + "cat": "อื่นๆ" + }, + { + "n": 93, + "name": "MG1: หลุดกลางเกม → บอท takeover ในที่เดิม", + "detail": "บอทแทนทันที สี/ตำแหน่ง/สถานะเดิม (คะแนนต่อ, ตกรอบ→ผีต่อ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 หลายคน → คนนึงปิดแท็บกลางเกม → ตัวละครต้องไม่หาย กลายเป็นบอทสีเดิม ตำแหน่งเดิม เล่นต่อ + มี \"(บอท)\" หลังชื่อ", + "cat": "Minigame 1" + }, + { + "n": 94, + "name": "MG1: หลุดแล้วกลับมาใน 10วิ → คืนสิทธิ์เล่น", + "detail": "reconnect-undo: ถอนบอท takeover คืนตัวจริง (กันตัวซ้ำ)", + "prio": "สูง", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เล่น MG1 → refresh กลางเกม (กลับมาใน 10วิ) → ต้องเล่นเองต่อได้ ไม่มีบอทซ้ำกับตัวเรา", + "cat": "Minigame 1" + }, + { + "n": 95, + "name": "MG1: host หลุดในหน้า how-to → เล่นต่อได้", + "detail": "expectedActive ลดตอนคนออกถาวร → host ใหม่กด START ได้ (เดิมค้าง \"รอ x/y\")", + "prio": "วิกฤต", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: เข้า MG1 how-to หลายคน → host ปิดแท็บ → รอ ~10วิ → host ใหม่กด START ได้ + บอทสีเดิมเล่นแทน", + "cat": "Minigame 1" + }, + { + "n": 98, + "name": "MG how-to: เห็น countdown ตอนมีคนหลุด", + "detail": "grace banner z-index ขึ้นเหนือ how-to overlay", + "prio": "ต่ำ", + "status": "แก้ไขแล้ว", + "updated": "2026-06-27", + "note": "วิธีเทส: ในหน้า how-to มีคนหลุด → ต้องเห็นแถบ \"🔌 ... หลุด — รอกลับมา N วิ\" ลอยเหนือ how-to (ไม่ถูกบัง)", + "cat": "Minigame 1" + }, + { + "n": 99, + "name": "Login + ตั้งชื่อผู้เล่น", + "detail": "ล็อกอิน/ตั้งชื่อได้ ชื่อไปแสดงในห้อง/เกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เปิดเกม → login → ตั้งชื่อ → เข้า Main Menu → ชื่อตรงทุกหน้า", + "cat": "อื่นๆ" + }, + { + "n": 100, + "name": "Main Menu นำทางครบ", + "detail": "ปุ่มทุกอันใน Main Menu พาไปหน้าถูก (สร้าง/เข้าห้อง, โปรไฟล์, how-to)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กดทุกปุ่มใน Main Menu → ไปหน้าตรงป้าย + ปุ่มย้อนกลับได้", + "cat": "อื่นๆ" + }, + { + "n": 101, + "name": "สร้างห้อง Public", + "detail": "สร้างห้องสาธารณะได้ เข้า LobbyA", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Public → ตั้งชื่อ → ยืนยัน → เข้า LobbyA เป็น host", + "cat": "อื่นๆ" + }, + { + "n": 102, + "name": "สร้างห้อง Private (PIN 4 หลัก)", + "detail": "ห้องส่วนตัวต้องใส่ PIN 4 หลัก ล็อก 🔒", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → Private → ใส่ PIN 4 หลัก → ยืนยัน → ห้องขึ้น 🔒 · ใส่ PIN ไม่ครบ = ยืนยันไม่ได้", + "cat": "อื่นๆ" + }, + { + "n": 103, + "name": "สรุปจำนวนบอทถูก (1-3 คน)", + "detail": "เลือก 1/2/3 คน → บอท 5/4/3 (รวม 6)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือกจำนวนผู้เล่น 1,2,3 → summary แสดง '+5/+4/+3 Bot'", + "cat": "อื่นๆ" + }, + { + "n": 104, + "name": "เลือก ≥ 4 คน = ไม่มีบอท", + "detail": "เลือก 4/5/6 คน → 0 Bot ทั้ง summary และในเกมจริง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Create Room → เลือก 4,5,6 → summary '+0 Bot' → เข้าเกมจริงต้องไม่มีบอทเติม (รอคนจริง)", + "cat": "อื่นๆ" + }, + { + "n": 105, + "name": "เข้าห้อง Public จากรายการ", + "detail": "เห็นรายชื่อห้อง public + กดเข้าได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → เห็นห้อง public → กดเข้า → เข้า LobbyA เป็น client", + "cat": "อื่นๆ" + }, + { + "n": 106, + "name": "เข้าห้อง Private ด้วยชื่อ+PIN", + "detail": "เข้าห้องส่วนตัวได้เมื่อใส่ชื่อ+PIN ถูก · ผิด = เข้าไม่ได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Join Room → Private → ใส่ชื่อห้อง+PIN → ถูก=เข้าได้ / ผิด=แจ้ง error", + "cat": "อื่นๆ" + }, + { + "n": 107, + "name": "ห้องเต็ม", + "detail": "ห้องเต็ม (ครบ maxPlayers) เข้าไม่ได้ แจ้งเตือน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ห้องมีคนครบ → คนใหม่กดเข้า → แจ้ง 'ห้องเต็ม (n/max)'", + "cat": "อื่นๆ" + }, + { + "n": 108, + "name": "ฉาก LobbyA โหลดเร็ว", + "detail": "เข้าฉากภายใน ~2 วิ ไม่ค้างขาว", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เข้า LobbyA → จับเวลา loading-hide < ~3 วิ (เดิมเคยค้าง 15-40 วิ)", + "cat": "LobbyA" + }, + { + "n": 109, + "name": "กดพร้อม (Ready)", + "detail": "toggle พร้อม/ไม่พร้อม ได้ ทุกคนเห็นตรงกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: กด Ready → สถานะเปลี่ยน + คนอื่นเห็น · กดซ้ำ = ยกเลิกพร้อม", + "cat": "LobbyA" + }, + { + "n": 110, + "name": "ห้องแต่งตัว (กด F)", + "detail": "เดินไปจุดเขียว กด F เปิดห้องแต่งตัว เปลี่ยนชุด/ตัวละครได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA → เดินไปไอคอนห้องแต่งตัว → กด F → popup เปิด → เปลี่ยนได้ทุกคน", + "cat": "LobbyA" + }, + { + "n": 111, + "name": "Host Console (กด F, เฉพาะ host)", + "detail": "host เดินไปไอคอนชมพู กด F เปิด console · client กดไม่ได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: host → จุดชมพู → F → console เปิด · client → F → แจ้ง 'เฉพาะ Host'", + "cat": "LobbyA" + }, + { + "n": 112, + "name": "ปุ่มกลางแมป (START GAME) + glow", + "detail": "ทุกคน+host พร้อม → ปุ่มกลางเรือง glow เหลืองกระพริบ → host กด F เริ่มเกม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ทุกคนกด Ready ครบ → ปุ่ม START กลางแมป glow เหลือง → host เดินเข้าใกล้ กด F → เข้า wizard เลือกระดับ/คดี", + "cat": "LobbyA" + }, + { + "n": 113, + "name": "บอทเดินในฉาก ไม่หลุดขอบ", + "detail": "บอท LobbyA เดินในฉากหลัก ไม่ติดขอบ/นอกฉาก", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอทใน LobbyA → ต้องเดินวนในฉาก ไม่ค้างขอบ/หลุดออกนอก", + "cat": "LobbyA" + }, + { + "n": 114, + "name": "เสียงพูดใน Lobby (mic)", + "detail": "เปิด/ปิดไมค์ได้ ได้ยินเสียงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → เปิดไมค์ → พูด → คนอื่นได้ยิน · กดปิด = เงียบ", + "cat": "LobbyA" + }, + { + "n": 115, + "name": "MG1 เข้าเกม + how-to", + "detail": "เข้า MG1 เห็น how-to → เริ่มพร้อมกัน", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เริ่มเกม → เข้า MG1 → how-to ขึ้น → ปิด/countdown → เริ่มพร้อมกันทุกคน", + "cat": "Minigame 1" + }, + { + "n": 116, + "name": "MG1 ตอบถูก/ผิด", + "detail": "ตอบถูกได้คะแนน · ตอบผิดกลายเป็นผี/ล็อก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เดินเข้าโซนจริง/ไม่จริง → ถูก=คะแนน+ / ผิด=ล็อก(ผี) ตรงกันทุกเครื่อง", + "cat": "Minigame 1" + }, + { + "n": 117, + "name": "MG1 บอทเล่น + คะแนนตรง", + "detail": "บอทตอบเอง คะแนน/สถานะบอทตรงทุกเครื่อง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG1 → ตอบเอง + คะแนน/ล็อก ตรงทุก client", + "cat": "Minigame 1" + }, + { + "n": 118, + "name": "MG1 จบเกม + สรุปคะแนน", + "detail": "หมดเวลา/ทุกคนตอบผิด → จบ + สรุปคะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ (complete/timeup) + คะแนน/coin ตามอันดับ", + "cat": "Minigame 1" + }, + { + "n": 119, + "name": "MG2 วิ่ง+กระโดดหลบ", + "detail": "เดิน/วิ่ง + กระโดดข้ามสิ่งกีดขวางได้ ชน=เสียแต้ม", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → วิ่งหลบ → กระโดดข้าม obstacle · โดนเลเซอร์/สิ่งกีดขวาง = penalty", + "cat": "Minigame 2" + }, + { + "n": 120, + "name": "MG2 ปรับความสูง/เวลากระโดด (Admin)", + "detail": "ปรับ 'ความสูงกระโดด' + 'เวลากระโดดขึ้น-ลง' ใน Admin → มีผลรอบถัดไป", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: Admin → Minigame-2 → ตั้งความสูง/tick → เริ่มเกมใหม่ → กระโดดสูง/เร็วตามตั้ง", + "cat": "Minigame 2" + }, + { + "n": 121, + "name": "MG2 finish/จบ", + "detail": "วิ่งถึงเส้นชัย/หมดเวลา → จบถูก ไม่ค้าง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG2 → ถึงปลายทาง หรือหมดเวลา → ฉากจบ + คะแนน", + "cat": "Minigame 2" + }, + { + "n": 122, + "name": "MG3 ต่อบล็อกตรง", + "detail": "เล็งตรง support → บล็อกลงตรง ไม่เฉียง ต่อได้", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG3 → กด DROP ตอนบล็อกอยู่เหนือบล็อกก่อน → ลงตรงดิ่ง ทับพอดี (ไม่ไถลเฉียง)", + "cat": "Minigame 3" + }, + { + "n": 123, + "name": "MG3 หัวใจรวม + จบเกม", + "detail": "หัวใจ (System Integrity) เป็นของรวม · ใครพลาดก็ลด · พลาด 3 ครั้ง = จบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน → ผลัดกันวาง · วางเยื้องมาก = หัวใจลด (ทุกคนเห็นเท่ากัน) · ครบ 3 = จบเกม", + "cat": "Minigame 3" + }, + { + "n": 124, + "name": "MG3 บอทวางตรง support", + "detail": "บอทวางบล็อกลงตรงยอดตึก ไม่เฉียง/หลุด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG3 → วางลงตรงบล็อกก่อนหน้า (ตึกไม่เป๋)", + "cat": "Minigame 3" + }, + { + "n": 125, + "name": "MG3 สีบล็อกตรง avatar", + "detail": "สีบล็อกที่วาง = สีตัวละครคนวาง ทุกเครื่องตรงกัน", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: วาง → สีบล็อกตรงสี avatar คนวาง (host/client/บอท ตรงกัน)", + "cat": "Minigame 3" + }, + { + "n": 126, + "name": "MG3 ตัวห้อยแกว่งลื่น", + "detail": "บล็อกที่ห้อยแกว่งลื่น ไม่กระตุก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบล็อกห้อย → แกว่งนุ่ม ไม่กระตุก/วาป", + "cat": "Minigame 3" + }, + { + "n": 127, + "name": "MG4 หยิบคำ-ส่งคำตอบ", + "detail": "หยิบ word option จากโต๊ะข้าง → เดินไปส่งที่โต๊ะกลาง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG4 → เดินไปโต๊ะ option → หยิบคำ → เดินไปโต๊ะ submit → ส่ง → ถูก=คะแนน", + "cat": "Minigame 4" + }, + { + "n": 128, + "name": "MG4 บอทไม่ยืนบนโต๊ะกลาง", + "detail": "บอทไม่ spawn/ยืนติดบนคอนโซลกลาง เดินหยิบ-ส่งปกติ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG4 → ไม่มีบอทค้างยืนบนโต๊ะกลาง · บอทหยิบ-ส่งได้", + "cat": "Minigame 4" + }, + { + "n": 129, + "name": "MG4 จบ + คะแนน", + "detail": "หมดเวลา/ครบข้อ → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผลจบ + คะแนนตามส่ง", + "cat": "Minigame 4" + }, + { + "n": 130, + "name": "MG5 กระโดดขึ้นแท่นเอาตัวรอด", + "detail": "กระโดดขึ้นแท่นตามสายพานเลื่อน ตก= ตกรอบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 → กระโดดขึ้นแท่น หนีพื้นเลื่อน · ตกล่าง = eliminated", + "cat": "Minigame 5" + }, + { + "n": 131, + "name": "MG5 สลับแท็บ (unfocus) ตกเนียน", + "detail": "สลับแท็บออก → ตัวละครตกลงต่อ คนอื่นเห็นลื่น (ไม่ลอยค้าง/กระตุก)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG5 กำลังเล่น → สลับไปแท็บอื่น 5 วิ → คนอื่นเห็นตัวเราไหลลงเนียน", + "cat": "Minigame 5" + }, + { + "n": 132, + "name": "MG5 บอท + จบเกม", + "detail": "บอทกระโดดเอง · จบเกม (all-dead/timeup) + เกรด", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG5 กระโดดเอง → เล่นจนจบ → เกรด/คะแนนถูก", + "cat": "Minigame 5" + }, + { + "n": 133, + "name": "MG6 ยิงอุกาบาต", + "detail": "บังคับยาน + ยิง หลบอุกาบาต โดน 3 ครั้ง=ตาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG6 → ขยับยาน ยิง · โดนอุกาบาต 3 = eliminated", + "cat": "Minigame 6" + }, + { + "n": 134, + "name": "MG6 บอทขยับทุก client", + "detail": "บอททุกตัวขยับเห็นตรงกันทุกเครื่อง (ไม่มีบอทค้าง)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลายคน MG6 → บอททุกตัวต้องขยับบนทุกจอ (ไม่มีตัวค้างนิ่งเฉพาะบางเครื่อง)", + "cat": "Minigame 6" + }, + { + "n": 135, + "name": "MG6 จบ + คะแนน", + "detail": "หมดเวลา/รอด → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน/coin ตามอันดับ", + "cat": "Minigame 6" + }, + { + "n": 136, + "name": "MG7 ยิงบอส (Mega Virus)", + "detail": "เล็ง+ยิงบอส ลด HP บอส · บอสชนเรา=เสียหาย", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: MG7 → หมุนเล็ง ยิงบอส → HP บอสลด · บอสเคลื่อนที่ชน = เด้ง/เสียหาย", + "cat": "Minigame 7" + }, + { + "n": 137, + "name": "MG7 บอทยิง+หลบบอส", + "detail": "บอทยิงบอส + หลบบอส ไม่ค้างนิ่ง", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สังเกตบอท MG7 → ยิงบอส + ดริฟต์หลบ ไม่ติดนิ่ง", + "cat": "Minigame 7" + }, + { + "n": 138, + "name": "MG7 จบ + คะแนน", + "detail": "บอสตาย/หมดเวลา → จบ + คะแนนถูก", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจนจบ → ผล + คะแนน", + "cat": "Minigame 7" + }, + { + "n": 139, + "name": "กลับ LobbyB หลังจบมินิเกม", + "detail": "จบรอบมินิเกม → กลับเข้า LobbyB (หน้าคดี)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → interstitial → เข้า LobbyB", + "cat": "LobbyB" + }, + { + "n": 140, + "name": "หน้าเลือกผู้ต้องสงสัยเปิด", + "detail": "จบเคส → หน้าเลือกผู้ต้องสงสัยเปิดอัตโนมัติทุกคน", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบเคส → LobbyB → overlay เลือกผู้ต้องสงสัยขึ้นทุกเครื่อง (ไม่ค้าง/ไม่ขึ้น)", + "cat": "LobbyB" + }, + { + "n": 141, + "name": "ปุ่มกลาง (F) เปิดผู้ต้องสงสัยซ้ำ", + "detail": "ปิด overlay แล้ว → ปุ่มกลางแมป glow → กด F เปิดกลับ (แทนปุ่มลอยเดิม)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ปิดหน้าผู้ต้องสงสัย → ปุ่มกลาง glow → เดินเข้าใกล้ กด F → overlay เปิดกลับ", + "cat": "LobbyB" + }, + { + "n": 142, + "name": "เสนอบทตัวป่วน", + "detail": "≥4 คน (หรือเปิด force) → มีการเสนอบทตัวป่วน กดรับ/ปฏิเสธได้ในเวลาที่ตั้ง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบเคส → ระบบเสนอบทตัวป่วนให้ 1 คน → countdown (ตาม Admin) → กดรับ/ปฏิเสธ", + "cat": "LobbyB" + }, + { + "n": 143, + "name": "สืบผู้ต้องสงสัย (เก็บหลักฐาน)", + "detail": "สืบครบ 3 ผู้ต้องสงสัย (เล่นมินิเกม) → เก็บหลักฐานได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เลือกผู้ต้องสงสัย → เล่นมินิเกม → ได้หลักฐาน +1 ต่อคน", + "cat": "LobbyB" + }, + { + "n": 144, + "name": "โหวตชี้ตัวคนร้าย (พิจารณาคดี)", + "detail": "สืบครบ → โหวตชี้คนร้าย → ผลตัดสินถูก", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: สืบครบ 3 → เปิดพิจารณาคดี → โหวต → เฉลยถูก/ผิด", + "cat": "LobbyB" + }, + { + "n": 145, + "name": "หน้า Cyber Awareness หลังตัดสิน", + "detail": "หลังตัดสินคดี → หน้าให้ความรู้กฎหมายไซเบอร์ ก่อนกลับ Main Lobby", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ตัดสินคดีจบ → interstitial cyber-law → กลับ Main-Lobby", + "cat": "LobbyB" + }, + { + "n": 146, + "name": "Host หลุด → migration", + "detail": "host ปิด/หลุด → เลือก host ใหม่อัตโนมัติ เกมไปต่อได้", + "prio": "วิกฤต", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างเกม host ปิดแท็บ → ~10วิ → host ใหม่ถูกตั้ง เกมไม่ค้าง", + "cat": "ระบบ disconnect" + }, + { + "n": 147, + "name": "ผู้เล่นหลุด → บอทแทน", + "detail": "คนหลุดกลางมินิเกม → บอทเล่นแทน (สีเดิม) ไม่ค้าง", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม ผู้เล่นปิดแท็บ → บอท takeover เล่นแทน (สีตามคนออก)", + "cat": "ระบบ disconnect" + }, + { + "n": 148, + "name": "แถบ grace ตอนมีคนหลุด", + "detail": "มีคนหลุด → แถบ '🔌 ... หลุด รอกลับ N วิ' ขึ้น (เหนือ how-to)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: คนหลุด → แถบ grace countdown ขึ้นทุกเครื่อง (ลอยเหนือ overlay)", + "cat": "ระบบ disconnect" + }, + { + "n": 149, + "name": "Reconnect กลับเข้าเกม", + "detail": "หลุดแล้วต่อกลับ → กลับเข้าห้อง/มินิเกมเดิมได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: หลุดเน็ตชั่วคราว → เชื่อมกลับ → auto re-join ห้องเดิม ไม่ต้องเริ่มใหม่", + "cat": "ระบบ disconnect" + }, + { + "n": 150, + "name": "คะแนน/COINS ตามอันดับ", + "detail": "จบมินิเกม → รับ coin ตามอันดับ (ผู้รอด)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: จบมินิเกม → ผู้รอดได้ coin [10,8,6,4,2,0] ตามอันดับ + คะแนนสะสมขึ้น leaderboard", + "cat": "อื่นๆ" + }, + { + "n": 151, + "name": "กระดานผู้นำ (Leaderboard)", + "detail": "คะแนนสะสม/high score แสดงถูกในกระดานผู้นำ", + "prio": "ต่ำ", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: เล่นจบ → เช็ค leaderboard → คะแนนอัปเดตถูก", + "cat": "อื่นๆ" + }, + { + "n": 152, + "name": "เสียงพูดในมินิเกม (MG1-7)", + "detail": "เปิดไมค์ในมินิเกม → ได้ยินกัน (WebRTC mesh)", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: ระหว่างมินิเกม → กดไมค์ → พูด → คนอื่นได้ยิน", + "cat": "อื่นๆ" + }, + { + "n": 153, + "name": "ออกจากห้อง (Exit)", + "detail": "ออกจาก Lobby → host ไป Create Room / client ไป Join Room", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "🧪 Flow TC · วิธีเทส: LobbyA/B → กด Exit → host→Create, client→Join", + "cat": "อื่นๆ" + }, + { + "n": 154, + "name": "LobbyA: ล็อก unready ระหว่าง host เลือกคดี", + "detail": "ทุกคน ready + host เปิด wizard เลือกระดับ/คดี → client กด unready ไม่ได้ (ปุ่ม disable + ป้าย \"host กำลังเลือกคดี\")", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: ทุกคนกด Ready ครบ → host กด F เปิด wizard → อีกเครื่องลองกดยกเลิก Ready → ต้องกดไม่ได้", + "cat": "LobbyA" + }, + { + "n": 155, + "name": "LobbyB: ต้องรอทุกคนเข้าครบก่อน \"ชี้ตัวคนร้าย\"", + "detail": "ปุ่มชี้ตัวคนร้าย (พิจารณาคดี) เปิดได้เฉพาะเมื่อผู้เล่นเข้า LobbyB ครบทุกคน · ยังไม่ครบ = disable + ป้าย \"รอผู้เล่น (n/total)\"", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: จบเคส → คนบางคนยังไม่เข้า LobbyB → host/ระบบต้องยังชี้ตัวไม่ได้ จนครบทุกคน", + "cat": "LobbyB" + }, + { + "n": 156, + "name": "LobbyA→B: gate เลือกผู้ต้องสงสัยจนกว่าทุกคนอ่าน story ครบ", + "detail": "มีหน้าอ่านเนื้อเรื่อง story 1-2 (cutscene) ก่อน LobbyB · host เลือกผู้ต้องสงสัยไม่ได้จนกว่าทุกคนกด \"ไปต่อ\" อ่านจบครบ", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: เข้า LobbyB → หน้า story ขึ้น → บางคนยังไม่กดอ่านจบ → host ต้องเลือกผู้ต้องสงสัยไม่ได้ (ป้าย \"รออ่านเนื้อเรื่อง n/total\")", + "cat": "LobbyA" + }, + { + "n": 157, + "name": "Vote Ban: เริ่มโหวตเมื่อทุกคนเข้า+โหลดเสร็จ · ยังไม่โหวต=เลือกผู้ต้องสงสัยไม่ได้", + "detail": "โหวตแบน/ปิดปากผู้เล่น เริ่มเมื่อทุกคนเข้า LobbyB + โหลดเสร็จเท่านั้น · ถ้ายังไม่โหวตเสร็จ host เลือกผู้ต้องสงสัยไม่ได้ (ตาม flow)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: จบเคสที่มีการ์ด ban/silence → ต้องรอทุกคนเข้า+โหลดเสร็จ แล้วค่อยเริ่มโหวต → ระหว่างโหวต host เลือกผู้ต้องสงสัยไม่ได้จนโหวตจบ", + "cat": "LobbyB" + }, + { + "n": 158, + "name": "MG4: บอทว๊าป/กระโดดข้ามโต๊ะกลาง", + "detail": "บอทต้องเดินอ้อมโต๊ะกลาง ไม่ว๊าป/กระโดดข้าม (ลื่นไม่ teleport)", + "prio": "สูง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: MG4 หลายรอบ → สังเกตบอท: ต้องไม่กระโดด/วาปข้ามโต๊ะกลาง · เดินอ้อมเนียน", + "cat": "Minigame 4" + }, + { + "n": 159, + "name": "เพิ่ม BG Music + Sound Effect", + "detail": "มีเพลงพื้นหลังต่อฉาก (เมนู/LobbyA/B/มินิเกม) + SFX เหตุการณ์ (ปุ่ม, ready, drop, ยิง, ถูก/ผิด, ชนะ/แพ้) + ปุ่ม 🔊/🔇 ปรับ/ปิดได้", + "prio": "กลาง", + "status": "เปิด", + "updated": "2026-07-01", + "note": "📋 PLAN (flow/sound) · วิธีเทส: เข้าแต่ละฉาก → มีเพลง BG · กดปุ่ม/ยิง/ตอบ → มี SFX · กดปุ่มเสียง → ปรับ/ปิดได้ (จำค่าไว้)", + "cat": "อื่นๆ" + } +] \ No newline at end of file diff --git a/www/html/testcase/data/cats.json b/www/html/testcase/data/cats.json index cb77d7f..bb8c0cb 100644 --- a/www/html/testcase/data/cats.json +++ b/www/html/testcase/data/cats.json @@ -9,5 +9,6 @@ "Minigame 5", "Minigame 6", "Minigame 7", + "เสียง (Sound)", "อื่นๆ" ] \ No newline at end of file diff --git a/www/html/testcase/data/cats.json.bak-20260702-sound b/www/html/testcase/data/cats.json.bak-20260702-sound new file mode 100644 index 0000000..cb77d7f --- /dev/null +++ b/www/html/testcase/data/cats.json.bak-20260702-sound @@ -0,0 +1,13 @@ +[ + "LobbyA", + "LobbyB", + "ระบบ disconnect", + "Minigame 1", + "Minigame 2", + "Minigame 3", + "Minigame 4", + "Minigame 5", + "Minigame 6", + "Minigame 7", + "อื่นๆ" +] \ No newline at end of file