diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json
index 70991f9..92bc6fb 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": 897,
+ "coins": 945,
"createdAt": "2026-04-02T05:52:21+00:00",
- "updatedAt": "2026-06-19T09:42:28+00:00",
+ "updatedAt": "2026-06-22T16:52:22+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -65,9 +65,9 @@
false,
false
],
- "lockUntilMs": 1781888400000
+ "lockUntilMs": 0
},
- "score": 792,
+ "score": 840,
"scoreByCase": {
"1": 16,
"10": 320,
@@ -76,7 +76,7 @@
"13": 40,
"14": 20,
"9": 40,
- "12": 60,
+ "12": 108,
"15": 20,
"4": 10,
"5": 20,
@@ -145,14 +145,24 @@
{
"id": "0f14afd3ee469abd185d026c",
"email": "",
- "displayName": "Guest",
+ "displayName": "MONE",
"loginType": "guest",
"providerUserId": "p_1778099867825_vd4pphrr5a",
"notes": "auto: player-coins",
"blocked": false,
- "coins": 0,
+ "coins": 72,
"createdAt": "2026-05-06T20:37:46+00:00",
- "updatedAt": "2026-05-06T20:37:46+00:00"
+ "updatedAt": "2026-06-22T17:04:52+00:00",
+ "lobbyColorThemeIndex": 5,
+ "lobbySkinToneIndex": 1,
+ "score": 72,
+ "scoreByCase": {
+ "12": 72
+ },
+ "lbName": "MONE",
+ "achievements": {
+ "d1_minigame_solver": 9
+ }
},
{
"id": "7c655e167f8dd9f927cd94b2",
@@ -364,7 +374,7 @@
"blocked": false,
"coins": 10,
"createdAt": "2026-06-19T06:23:24+00:00",
- "updatedAt": "2026-06-19T06:24:28+00:00",
+ "updatedAt": "2026-06-22T13:54:04+00:00",
"daily": {
"anchorMs": 1781802000000,
"claimedDays": [
@@ -376,7 +386,7 @@
false,
false
],
- "lockUntilMs": 1781888400000
+ "lockUntilMs": 0
}
},
{
@@ -615,6 +625,114 @@
"achievements": {
"d1_minigame_solver": 2
}
+ },
+ {
+ "id": "9940a0de4693908fab29c349",
+ "email": "",
+ "displayName": "MONE",
+ "loginType": "guest",
+ "providerUserId": "p_1782122326943_7p2fnvivscm",
+ "notes": "auto: player-coins",
+ "blocked": false,
+ "coins": 20,
+ "createdAt": "2026-06-22T09:58:46+00:00",
+ "updatedAt": "2026-06-22T10:04:04+00:00",
+ "lobbyColorThemeIndex": 3,
+ "lobbySkinToneIndex": 1,
+ "score": 20,
+ "scoreByCase": {
+ "10": 20
+ },
+ "lbName": "MONE",
+ "achievements": {
+ "d1_minigame_solver": 1
+ }
+ },
+ {
+ "id": "053be00ea728989cff210a11",
+ "email": "",
+ "displayName": "ผู้เล่น",
+ "loginType": "guest",
+ "providerUserId": "p_1782122328583_4mqrfx5txyo",
+ "notes": "auto: player-coins",
+ "blocked": false,
+ "coins": 26,
+ "createdAt": "2026-06-22T09:58:48+00:00",
+ "updatedAt": "2026-06-22T12:17:11+00:00",
+ "lobbyColorThemeIndex": 1,
+ "lobbySkinToneIndex": 1,
+ "score": 26,
+ "scoreByCase": {
+ "10": 8,
+ "8": 18
+ },
+ "lbName": "MONE",
+ "achievements": {
+ "d1_minigame_solver": 1
+ }
+ },
+ {
+ "id": "3d78f962ad8309e86cf2b4c4",
+ "email": "",
+ "displayName": "ผู้เล่น",
+ "loginType": "guest",
+ "providerUserId": "p_1782130171358_cwsd1t9lt2c",
+ "notes": "auto: player-coins",
+ "blocked": false,
+ "coins": 18,
+ "createdAt": "2026-06-22T12:09:30+00:00",
+ "updatedAt": "2026-06-22T12:17:11+00:00",
+ "score": 18,
+ "scoreByCase": {
+ "8": 18
+ },
+ "lbName": "ผู้เล่น",
+ "achievements": {
+ "d1_minigame_solver": 1
+ }
+ },
+ {
+ "id": "2a1635c13b08c05b62dbb53c",
+ "email": "",
+ "displayName": "ผู้เล่น",
+ "loginType": "guest",
+ "providerUserId": "p_1778097234255_htbbepc8x3m",
+ "notes": "auto: player-coins",
+ "blocked": false,
+ "coins": 50,
+ "createdAt": "2026-06-22T13:52:23+00:00",
+ "updatedAt": "2026-06-22T14:43:06+00:00",
+ "score": 50,
+ "scoreByCase": {
+ "12": 20,
+ "13": 30
+ },
+ "lbName": "ผู้เล่น",
+ "achievements": {
+ "d1_minigame_solver": 5
+ }
+ },
+ {
+ "id": "07361aa244ad0d88dc486d5f",
+ "email": "",
+ "displayName": "MONE",
+ "loginType": "guest",
+ "providerUserId": "p_1782136893353_1wlj7nbaoli",
+ "notes": "auto: player-coins",
+ "blocked": false,
+ "coins": 16,
+ "createdAt": "2026-06-22T14:01:32+00:00",
+ "updatedAt": "2026-06-22T14:28:00+00:00",
+ "lobbyColorThemeIndex": 8,
+ "lobbySkinToneIndex": 1,
+ "score": 16,
+ "scoreByCase": {
+ "13": 16
+ },
+ "lbName": "MONE",
+ "achievements": {
+ "d1_minigame_solver": 2
+ }
}
]
}
\ 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 befa624..9ab5ccd 100644
--- a/www/html/Game/data/game-timing.json
+++ b/www/html/Game/data/game-timing.json
@@ -89,9 +89,9 @@
"balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png",
"balloonBossBalloonsPerPlayer": 3,
"forcedMinigameKeys": [
+ "quiz",
"quiz_carry",
- "space_shooter",
- "balloon_boss"
+ "jump_survive"
],
"testSpecialCardByMap": {},
"troublesomeForceOffer": false,
diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js
index 966d080..58eba2c 100644
--- a/www/html/Game/public/js/play.js
+++ b/www/html/Game/public/js/play.js
@@ -383,8 +383,15 @@
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', {}, function (res) {
+ 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; });
@@ -425,6 +432,25 @@
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) {
@@ -589,42 +615,119 @@
let stackPreviewTurnIndex = 0;
let stackPreviewBotThinkUntil = 0;
+ /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower)
+ (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */
function rebuildStackPreviewTurnOrder() {
if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
stackPreviewTurnOrder = null;
return;
}
- const bots = [...others.keys()].filter(isPreviewBotId).sort();
- stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
- for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
- stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ 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;
}
- stackPreviewTurnIndex = 0;
+ /* เล่นจริง: 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 = [];
+ if (myId != null && !playBannedSpectator) parts.push({ kind: 'human', id: String(myId) });
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) { parts.push({ kind: 'bot', id: String(id), botId: String(id) }); return; }
+ if (o && o.bannedSpectator) return; /* คนถูกแบน = ดูเฉยๆ ไม่เข้าคิววาง */
+ parts.push({ kind: 'human', id: String(id) });
+ });
+ 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 (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return;
+ try {
+ socket.emit('stack-state', {
+ idx: stackPreviewTurnIndex,
+ order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null })),
+ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** order พร้อมใช้ (มีอย่างน้อย 1 ที่นั่ง) */
+ function stackTurnOrderReadyPlay() {
+ return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1);
+ }
+ 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,
+ });
+ } catch (e) { /* ignore */ }
}
function advanceStackPreviewTurn() {
- if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return;
- stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT;
+ if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return;
+ /* เล่นจริง: index คุมโดย host เท่านั้น (non-host รับจาก stack-state) กันลำดับตาเหลื่อมกัน */
+ if (detectiveCaseFillBots && !isMePlayHost()) return;
+ stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackPreviewTurnOrder.length;
stackPreviewBotThinkUntil = 0;
markStackNextDropVisualDirty();
+ broadcastStackStatePlay();
}
function isStackPreviewHumanTurn() {
- if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true;
- const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return true;
+ const cur = stackCurrentTurnEntryPlay();
return !!(cur && cur.kind === 'human');
}
function getStackPreviewCurrentSeat() {
- if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1;
- const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ if (!stackTurnOrderReadyPlay()) return 1;
+ const cur = stackCurrentTurnEntryPlay();
return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
}
function getStackDropActorSeat() {
- if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ if (playBotsEnabled() && stackTurnOrderReadyPlay()) {
return getStackPreviewCurrentSeat();
}
return 1;
@@ -735,6 +838,9 @@
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;
@@ -2882,7 +2988,9 @@
}
function getAvatarImg(characterId, direction, now, isWalking) {
- const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
+ /* '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;
}
@@ -2935,6 +3043,8 @@
/** วาดตัวละคร — ทิศลง + 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) {
@@ -3260,7 +3370,14 @@
});
if (anyAlive) return;
mg1AllDeadEndScheduled = true;
- setTimeout(function () { try { tryFinishDetectiveMinigameAndReturnLobby(); } catch (e) { /* ignore */ } }, 1600);
+ /* ทุกคนตอบผิดหมด → 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';
@@ -3585,6 +3702,10 @@
/* 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();
@@ -5994,7 +6115,12 @@
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;
@@ -7068,13 +7194,24 @@
const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0;
const timeOut = elapsed >= timeLim;
if (pct < 100 && !timeOut && !missesOut) return;
- stackTowerMissionEndedOnce = true;
- stackTowerMissionPhase = 'ended';
- applyStackTowerMissionPanelImages();
let outcome = 'time_up';
if (pct >= 100) outcome = 'decrypt_complete';
else if (missesOut) outcome = 'misses_exhausted';
- beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome }));
+ /* เล่นจริง: 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() {
@@ -9097,6 +9234,14 @@
if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons;
if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated;
if (o.quizCarryHeld !== undefined) 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;
+ 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) {
@@ -9562,7 +9707,7 @@
}
function playQuizCarryAvatarUrlForMission(characterId) {
- if (!characterId) return '';
+ if (!characterId || characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) return '';
return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png';
}
@@ -11174,6 +11319,7 @@
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
: autoW;
stackFall = null;
+ stackRemoteDropQueue = [];
const towerMission = isStackTowerMissionUiMapPlay();
const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul;
@@ -11534,11 +11680,10 @@
function stackHumanDropGateOkPlay() {
if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false;
- if (playBotsEnabled() && mapData && mapData.gameType === 'stack' &&
- (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ if (playBotsEnabled() && mapData && mapData.gameType === 'stack' && !stackTurnOrderReadyPlay()) {
rebuildStackPreviewTurnOrder();
}
- if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false;
+ if (playBotsEnabled() && !isMyStackTurn()) return false; /* กดได้เฉพาะตาตัวเอง (shared tower) */
if (Date.now() - stackMini.lastDropAt < 480) return false;
return true;
}
@@ -11549,6 +11694,8 @@
ensureStackNextDropVisual();
const hit = computeStackDropResult(stackMini);
if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false;
+ /* broadcast ผลการวางของเรา → ทุกเครื่องวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */
+ if (playBotsEnabled()) broadcastStackDropPlay(hit, getStackDropActorSeat(), !!stackMini.pendingDropHeavy, myId, false);
if (playBotsEnabled()) {
lastStackPreviewActorId = '__human__';
lastStackPreviewActorUntil = Date.now() + 900;
@@ -11565,6 +11712,8 @@
previewBotAlignStackSwingPhasePlay(stackMini, o);
const hit = computeStackDropResult(stackMini);
if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return;
+ /* host เป็นเจ้าของบอท → broadcast ผลการวางของบอทให้ทุกเครื่องวางตรงกัน */
+ broadcastStackDropPlay(hit, getStackDropActorSeat(), !!stackMini.pendingDropHeavy, botId, true);
o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
lastStackPreviewActorId = botId;
lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
@@ -11575,14 +11724,15 @@
/** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
function stepPreviewStackTurnBased(nowMs) {
if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return;
+ /* เล่นจริง: host เท่านั้นจำลองบอท (แล้ว broadcast) — non-host รอรับ stack-drop; editor preview (previewFillBots) จำลองเองได้ */
+ if (detectiveCaseFillBots && !isMePlayHost()) return;
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return;
- if (mapData && mapData.gameType === 'stack' &&
- (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ if (mapData && mapData.gameType === 'stack' && !stackTurnOrderReadyPlay()) {
rebuildStackPreviewTurnOrder();
}
const order = stackPreviewTurnOrder;
- if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return;
- const cur = order[stackPreviewTurnIndex];
+ if (!order || !order.length) return;
+ const cur = order[stackPreviewTurnIndex % order.length];
if (!cur || cur.kind !== 'bot') return;
if (!cur.botId) {
advanceStackPreviewTurn();
@@ -11605,11 +11755,29 @@
simulatePreviewBotStackDrop(cur.botId, o);
}
+ /** apply ดรอปถัดไปในคิว (จาก host/คนอื่น) — เริ่มแอนิเมชันร่วงเหมือนเดิม → settle apply ชั้น + เลื่อนตา */
+ function applyNextRemoteStackDropPlay() {
+ if (!stackMini || stackFall || stackMini.settling) return false;
+ const item = stackRemoteDropQueue.shift();
+ if (!item || !item.hit) return false;
+ ensureStackNextDropVisual();
+ stackMini.pendingDropSeat = item.seat || 1;
+ stackMini.pendingDropHeavy = !!item.heavy;
+ stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!item.heavy);
+ return startStackFallAnimation(item.hit, item.actor || { human: true });
+ }
+
function stackTickFrame() {
stackTowerMissionMaybeEndPlay();
const now = performance.now();
const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
lastStackTickMs = now;
+ /* เล่นจริง: host ส่งลำดับตา+index เป็นระยะ → non-host adopt (ครอบ late-join: client โหลดช้า host จะรวมเข้า order ให้) */
+ if (detectiveCaseFillBots && isMePlayHost()) {
+ if (!stackTurnOrderReadyPlay()) rebuildStackPreviewTurnOrder();
+ const ts = Date.now();
+ if (ts - stackLastStateEmit > 300) { stackLastStateEmit = ts; broadcastStackStatePlay(); }
+ }
if (stackMini && stackMini.settling) {
const s = stackMini.settling;
const u = (now - s.t0) / s.dur;
@@ -11675,8 +11843,25 @@
if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
}
}
+ } else if (stackMini && stackRemoteDropQueue.length && applyNextRemoteStackDropPlay()) {
+ /* ว่าง (ไม่มีบล็อกร่วง/settle) + มีดรอปในคิวจาก host/คนอื่น → apply ทีละอัน (ไม่แกว่งเฟรมนี้) */
} else if (stackMini) {
- stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
+ /* เล่นจริง: แกว่งเฉพาะตาคนที่คุม (เรา หรือ host คุมบอท) แล้ว broadcast เฟสให้คนอื่น mirror;
+ editor preview / เล่นเดี่ยว: แกว่งเองตลอดเหมือนเดิม */
+ let advancePhase = true;
+ if (detectiveCaseFillBots) {
+ advancePhase = isMyStackTurn() || (isMePlayHost() && isStackCurrentBotTurnPlay());
+ }
+ if (advancePhase) {
+ stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
+ if (detectiveCaseFillBots && socket && myId != null) {
+ const tnow = Date.now();
+ if (tnow - stackLastSwingEmit > 50) {
+ stackLastSwingEmit = tnow;
+ try { socket.emit('stack-swing', { phase: stackMini.phase }); } catch (e) { /* ignore */ }
+ }
+ }
+ }
}
stepPreviewStackTurnBased(Date.now());
}
@@ -12666,6 +12851,8 @@
balloonBossBossBullets = [];
balloonBossBossFireAcc = 0;
balloonBossScorePopups = [];
+ balloonBossHostBossDmg = null;
+ balloonBossLastSyncEmit = 0;
me.balloonBossScore = 0;
me.balloonBossBossDmg = 0;
me.balloonBossBalloons = balloonBossBalloonsStartPlay();
@@ -16389,8 +16576,12 @@
return s;
}
- /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */
+ /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง)
+ non-host: ใช้ค่าที่ host เป็นเจ้าของ (single source of truth) ถ้าได้รับแล้ว — HP บอสตรงกันทุกเครื่อง */
function balloonBossTeamBossDamagePlay() {
+ if (!isMePlayHost() && balloonBossHostBossDmg != null) {
+ return Math.max(0, balloonBossHostBossDmg | 0);
+ }
let s = Math.max(0, me.balloonBossBossDmg | 0);
others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 0); });
return s;
@@ -16733,17 +16924,30 @@
continue;
}
let hit = false;
- aliveRefs.forEach((entry) => {
- if (hit) return;
- const ent = entry.ref;
- if (!ent || ent.balloonBossCx == null) return;
- const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ /* การชนกระสุนบอส: เจ้าของตัดสินยานตัวเองเท่านั้น (+ บอทพรีวิว = host) — เหมือน MG6
+ คนอื่นรอสถานะลูกโป่ง/ตายจาก move ของเจ้าตัว + balloon-boss-sync ของ host
+ (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่ง interpolate ที่ต่างกัน → ลูกโป่งเพี้ยน/ตายไม่ตรงกัน) */
+ if (me && !me.balloonBossEliminated && me.balloonBossCx != null) {
+ const cc = balloonBossBalloonClusterWorldPlay(me, bossC.cx, bossC.cy);
const dx = b.x - cc.cx, dy = b.y - cc.cy;
if (dx * dx + dy * dy <= (36 * 36)) {
hit = true;
- applyBalloonBossHitToEntity(ent);
+ applyBalloonBossHitToEntity(me);
}
- });
+ }
+ if (!hit && isMePlayHost()) {
+ aliveRefs.forEach((entry) => {
+ if (hit || !isPreviewBotId(entry.id)) return;
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) return;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const dx = b.x - cc.cx, dy = b.y - cc.cy;
+ if (dx * dx + dy * dy <= (36 * 36)) {
+ hit = true;
+ applyBalloonBossHitToEntity(ent);
+ }
+ });
+ }
if (hit) balloonBossBossBullets.splice(bi, 1);
}
@@ -16917,6 +17121,21 @@
balloonBossEliminated: !!me.balloonBossEliminated,
});
}
+ /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวม + สถานะผู้เล่นคนอื่น ~7Hz
+ → ทุกเครื่องแสดง HP บอส/ลูกโป่ง/การตาย ตรงกับ host (single source of truth) */
+ if (isMePlayHost() && socket && myId != null && tEmit - balloonBossLastSyncEmit > 140) {
+ balloonBossLastSyncEmit = tEmit;
+ const syncRefs = buildBalloonBossParticipantRefsPlay();
+ socket.emit('balloon-boss-sync', {
+ bossDmg: Math.max(0, balloonBossTeamBossDamagePlay() | 0),
+ players: syncRefs.map((e) => ({
+ id: e.id,
+ d: Math.max(0, (e.ref && (e.ref.balloonBossBossDmg | 0)) || 0),
+ b: Math.max(0, (e.ref && (e.ref.balloonBossBalloons | 0)) || 0),
+ e: !!(e.ref && e.ref.balloonBossEliminated),
+ })),
+ });
+ }
}
function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
@@ -18135,6 +18354,8 @@
jumpSurviveVy = 0;
jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; });
+ /* broadcast การตายทันที — เดิมพอตายแล้วบล็อก emit move (เงื่อนไข !eliminated) → คนอื่นไม่เคยรู้ว่าเราตาย = "ไม่ยอมตาย" */
+ try { if (socket && myId != null) socket.emit('move', { x: me.x, y: me.y, direction: me.direction, jumpSurviveEliminated: true }); } catch (eJ) { /* ignore */ }
} else {
jumpSurviveVy = r.vy;
jumpSurviveOnGround = r.onGround;
@@ -18966,6 +19187,8 @@
bannedSpectator: !!data.bannedSpectator,
});
if (playBotsEnabled()) rebalancePreviewBots();
+ /* MG3: มีคนเข้าใหม่ → host รวมเข้าลำดับตา (preserve คนที่กำลังถึงตา) แล้ว broadcast ใหม่ — กัน late-join ตกหล่น */
+ if (isStack() && detectiveCaseFillBots && isMePlayHost()) rebuildStackPreviewTurnOrder();
if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
@@ -19115,6 +19338,7 @@
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
if (!hostBotIds.has(String(bid))) others.delete(bid);
});
+ let quizBotScoreChanged = false;
data.bots.forEach((rb) => {
if (!rb || !isPreviewBotId(rb.id)) return;
let o = others.get(rb.id);
@@ -19136,8 +19360,9 @@
if (o.characterId) { try { preloadPlayTintForAvatar(o.characterId, o.playTint); } catch (_e) { /* ignore */ } }
}
if (rb.cid != null && o.characterId == null) o.characterId = rb.cid;
- if (Number.isFinite(rb.x)) { o.tx = rb.x; if (snapPos || !Number.isFinite(o.x)) o.x = rb.x; }
- if (Number.isFinite(rb.y)) { o.ty = rb.y; if (snapPos || !Number.isFinite(o.y)) o.y = rb.y; }
+ /* delta ใหญ่ = teleport/respawn (เช่น บอทควิซตอบผิดถูกดีดกลับจุดเกิด) → สแนปเลย ไม่งั้น lerp ไถลทะลุกำแพง */
+ if (Number.isFinite(rb.x)) { const bigX = Number.isFinite(o.x) && Math.abs(rb.x - o.x) > 3; o.tx = rb.x; if (snapPos || bigX || !Number.isFinite(o.x)) o.x = rb.x; }
+ if (Number.isFinite(rb.y)) { const bigY = Number.isFinite(o.y) && Math.abs(rb.y - o.y) > 3; o.ty = rb.y; if (snapPos || bigY || !Number.isFinite(o.y)) o.y = rb.y; }
if (rb.direction) o.direction = rb.direction;
/* สีบอทจาก host → ใช้ตามให้ตรงกันทุกเครื่อง (อัปเดตเฉพาะเมื่อเปลี่ยน กัน churn ทุกเฟรม) */
if (rb.tint && rb.tint.body) {
@@ -19164,7 +19389,68 @@
if (rb.bbBalloons != null) o.balloonBossBalloons = rb.bbBalloons;
if (rb.bbElim != null) o.balloonBossEliminated = !!rb.bbElim;
if (rb.qcHeld !== undefined) o.quizCarryHeld = rb.qcHeld;
+ /* MG1 quiz: รับสถานะล็อก + คะแนนบอทจาก host (single source of truth) */
+ if (rb.qct != null) o.quizCannotTrue = !!rb.qct;
+ if (rb.qcf != null) o.quizCannotFalse = !!rb.qcf;
+ /* ghost overlay (วาดผี) อ่านจาก playQuizEverWrong — non-host ไม่ได้รันบล็อกตัดสินบอท (host-only)
+ จึงต้อง bridge จากสถานะล็อกที่ host ส่งมา ไม่งั้น client ไม่เห็นบอทเป็นผีตอนตอบผิด */
+ if (o.quizCannotTrue && o.quizCannotFalse) playQuizEverWrong[String(rb.id)] = true;
+ if (rb.qsc != null) {
+ const k = String(rb.id);
+ const nv = Math.max(0, rb.qsc | 0);
+ if (playLiveQuizScores[k] !== nv) { playLiveQuizScores[k] = nv; quizBotScoreChanged = true; }
+ }
});
+ if (quizBotScoreChanged && isQuiz()) renderPlayQuizScoreboard(playLiveQuizScores);
+ });
+
+ /* MG3 stack (shared tower): รับผลการวางจากเจ้าของตา → เข้าคิว แล้วทยอย apply ทีละอัน (กันทิ้งดรอปตอนบล็อกกำลังร่วง = ตึก/% ต่างกัน) */
+ socket.on('stack-drop', (d) => {
+ if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return;
+ const hit = {
+ miss: !!d.miss, perfect: !!d.perfect, pts: Number(d.pts) || 0, progressDelta: Number(d.progressDelta) || 0,
+ landOffsetTiles: Number(d.landOffsetTiles) || 0,
+ placedW: Number(d.placedW), placedCx: Number(d.placedCx),
+ overlap: Number(d.overlap),
+ supportRatio: (d.supportRatio == null ? null : Number(d.supportRatio)),
+ delta: Number(d.delta) || 0,
+ };
+ stackRemoteDropQueue.push({
+ hit: hit, seat: Number(d.seat) || 1, heavy: !!d.heavy,
+ actor: d.isBot ? { botId: String(d.actorId || '') } : { human: true },
+ });
+ });
+
+ /* MG3 stack (shared tower): รับเฟสแกว่งจากเจ้าของตา → mirror บล็อกที่กำลังแกว่ง (เห็นเหมือนกันจนกว่าจะถึงตาตัวเอง) */
+ socket.on('stack-swing', (d) => {
+ if (!d || !mapData || !isStack() || !stackMini || !detectiveCaseFillBots) return;
+ if (isMyStackTurn()) return; /* ตาเรา → คุมเฟสเอง */
+ if (isMePlayHost() && isStackCurrentBotTurnPlay()) return; /* host คุมบอทเอง */
+ const ph = Number(d.phase);
+ if (Number.isFinite(ph)) stackMini.phase = ph;
+ });
+
+ /* MG3 stack — non-host รับลำดับตา+index จาก host (single source of truth) → เลิกแยกตึก/ตาเหลื่อม */
+ socket.on('stack-state', (d) => {
+ if (!d || !mapData || !isStack() || !detectiveCaseFillBots || isMePlayHost()) return;
+ if (Array.isArray(d.order)) {
+ stackPreviewTurnOrder = d.order.map((e) => ({
+ kind: e.kind, seat: e.seat,
+ id: e.id != null ? String(e.id) : null,
+ botId: e.botId != null ? String(e.botId) : null,
+ }));
+ }
+ if (stackPreviewTurnOrder && stackPreviewTurnOrder.length && Number.isFinite(Number(d.idx))) {
+ const n = stackPreviewTurnOrder.length;
+ stackPreviewTurnIndex = ((Math.floor(Number(d.idx)) % n) + n) % n;
+ }
+ });
+
+ /* MG3 stack — host ตัดสินจบรอบ → non-host จบพร้อมกันด้วย outcome เดียวกัน (กัน "จบไม่พร้อมกัน") */
+ socket.on('stack-over', (d) => {
+ if (!mapData || !isStack() || !detectiveCaseFillBots) return;
+ const outcome = (d && typeof d.outcome === 'string') ? d.outcome : 'time_up';
+ finishStackTowerMissionLocalPlay(outcome);
});
/* MG7 balloon_boss — รับกระสุนของผู้เล่น/บอทคนอื่น แล้วเพิ่มเข้า array ให้เห็นตรงกันทุกเครื่อง
@@ -19194,6 +19480,26 @@
endBalloonBossGame(reason);
});
+ /* MG7 — host เป็นเจ้าของ HP บอส: non-host รับดาเมจรวม + สถานะผู้เล่นคนอื่น (ลูกโป่ง/ตาย) มาตรง ๆ
+ host เพิกเฉย (ตัวเองคำนวณเอง) · ตัวเราเองไม่ทับ (เป็นเจ้าของ balloon/dmg ของตัวเอง broadcast ผ่าน move) */
+ socket.on('balloon-boss-sync', (d) => {
+ if (!d || !mapData || mapData.gameType !== 'balloon_boss' || balloonBossGameEnded) return;
+ if (isMePlayHost()) return;
+ const bd = Number(d.bossDmg);
+ if (Number.isFinite(bd)) balloonBossHostBossDmg = Math.max(0, bd);
+ if (Array.isArray(d.players)) {
+ d.players.forEach((p) => {
+ if (!p || p.id == null) return;
+ if (myId != null && String(p.id) === String(myId)) return; /* ตัวเองเป็นเจ้าของ ไม่ทับ */
+ const o = others.get(String(p.id)) || others.get(p.id);
+ if (!o) return;
+ const dd = Number(p.d); if (Number.isFinite(dd)) o.balloonBossBossDmg = Math.max(0, dd);
+ const bb = Number(p.b); if (Number.isFinite(bb)) o.balloonBossBalloons = Math.max(0, bb);
+ if (typeof p.e === 'boolean') o.balloonBossEliminated = p.e;
+ });
+ }
+ });
+
/* MG6 space_shooter — รับกระสุนผู้เล่น/บอทคนอื่น (ตรงดิ่งขึ้น vy คงที่) → ตำแหน่ง = y + vy*elapsed เป๊ะทุกเครื่อง */
socket.on('space-shooter-shot', (d) => {
if (!d || !mapData || mapData.gameType !== 'space_shooter') return;
@@ -19304,13 +19610,16 @@
} else {
o.tx = data.x;
o.ty = data.y;
- /* quiz (ยืนบนช่องคงที่ + move มาห่าง) → สแนปทันที กัน host ค้าง/หาย
- quiz_carry → ปล่อยให้ lerp (move ถี่ทุก 80ms) เพื่อให้ "ตัวละครคนอื่นมี animation เดิน" (เดิมสแนป = ไม่มี delta = ยืนนิ่ง) */
+ /* quiz: ปล่อยให้ lerp (24025) เลื่อน o.x→o.tx เอง → มี delta = "ตัวละครคนอื่นเดินมี animation"
+ (เดิมสแนปทันที = o.x==o.tx = ไม่มี delta = ยืนนิ่งไม่เดิน) ; สแนปเฉพาะ delta ใหญ่ (เทเลพอร์ต/คลิกไกล) กันลากช้า/ค้าง */
if (mapData && isQuiz() && !isQuizCarry()) {
const px = Number(data.x);
const py = Number(data.y);
- if (Number.isFinite(px)) o.x = px;
- if (Number.isFinite(py)) o.y = py;
+ const bigJump = (Number.isFinite(px) && Math.abs(px - o.x) > 2.5) || (Number.isFinite(py) && Math.abs(py - o.y) > 2.5);
+ if (bigJump) {
+ if (Number.isFinite(px)) o.x = px;
+ if (Number.isFinite(py)) o.y = py;
+ }
}
}
o.direction = data.direction || o.direction;
@@ -19330,6 +19639,10 @@
/* ตาย/รอด มาจากเจ้าของยานเท่านั้น → ทุกเครื่องเห็นตรงกัน */
if (data.spaceShooterEliminated != null) o.spaceShooterEliminated = !!data.spaceShooterEliminated;
}
+ /* MG5: การตายของผู้เล่นมนุษย์มาจากเจ้าตัว (broadcast ผ่าน move) → ทุกเครื่องเห็นตรงกัน (เดิมไม่ส่ง = เพื่อน "ไม่ยอมตาย") */
+ if (mapData && mapData.gameType === 'jump_survive' && data.jumpSurviveEliminated != null) {
+ o.jumpSurviveEliminated = !!data.jumpSurviveEliminated;
+ }
if (mapData && mapData.gameType === 'balloon_boss') {
if (data.balloonBossScore != null) {
const s = Number(data.balloonBossScore);
@@ -19349,8 +19662,15 @@
});
socket.on('user-left', (data) => {
if (!data || isPreviewBotId(data.id)) return;
+ const leftWasCurrentStack = !!(isStack() && detectiveCaseFillBots && isMePlayHost()
+ && stackCurrentTurnEntryPlay() && String(stackCurrentTurnEntryPlay().id) === String(data.id));
others.delete(data.id);
if (playBotsEnabled()) rebalancePreviewBots();
+ /* MG3: คนออก → host รื้อลำดับตาใหม่; ถ้าคนที่ออกกำลังถึงตา → เลื่อนตา (กันค้างเพราะไม่มีใครวางแทน) */
+ if (isStack() && detectiveCaseFillBots && isMePlayHost()) {
+ rebuildStackPreviewTurnOrder();
+ if (leftWasCurrentStack && !(stackFall || (stackMini && stackMini.settling))) advanceStackPreviewTurn();
+ }
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
@@ -19395,8 +19715,9 @@
}
});
}
- /** เกมจริง: bot ไม่อยู่ในผล server → เช็คโซนยืนเอง ผิด = เป็นผี (เหมือน path preview) */
- if (isQuiz() && Array.isArray(r.results) && playBotsEnabled()) {
+ /** เกมจริง: bot ไม่อยู่ในผล server → HOST ตัดสินบอทคนเดียว (เช็คโซน/respawn/คะแนน) แล้ว broadcast ผ่าน fill-bot-state
+ (เดิมทุก client ตัดสินเอง + respawn ด้วย Math.random แยกกัน → ตำแหน่ง/ล็อก/คะแนนบอทคนละแบบทุกจอ) */
+ if (isQuiz() && Array.isArray(r.results) && playBotsEnabled() && isMePlayHost()) {
const correctTrueBot = !!r.correctTrue;
others.forEach(function (o, id) {
if (!isPreviewBotId(id) || !o) return;
@@ -19951,6 +20272,8 @@
balloonBossPlayerFireCd = 0;
balloonBossLastMoveEmit = 0;
balloonBossBossFireAcc = 0;
+ balloonBossHostBossDmg = null;
+ balloonBossLastSyncEmit = 0;
me.balloonBossScore = 0;
me.balloonBossBossDmg = 0;
me.balloonBossBalloons = balloonBossBalloonsStartPlay();
@@ -21533,7 +21856,8 @@
/** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */
function setCyberHudScoreAvatarImg(avImg, row, skipReuse) {
- const cid = row.characterId ? String(row.characterId) : '';
+ /* 'Chatest' = placeholder ไม่มี sprite จริง → ปฏิบัติเป็นว่าง ใช้ default (กัน 404 + retry วน ×16) */
+ const cid = (row.characterId && row.characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID) ? String(row.characterId) : '';
if (!cid) {
avImg.src = defaultAvatarImg.src;
return;
@@ -23714,8 +24038,11 @@
others.forEach((o, id) => {
/* host เดินบอทเองตรงๆ (o.x/o.y) → ข้าม lerp; แต่ client อื่นต้อง lerp ตามตำแหน่งที่ host ส่งมา (o.tx/o.ty) */
if (playBotsEnabled() && isPreviewBotId(id) && isMePlayHost()) return;
- if (o.tx != null) o.x += (o.tx - o.x) * LERP;
- if (o.ty != null) o.y += (o.ty - o.y) * LERP;
+ /* MG4 (quiz_carry) บอทเดินต่อเนื่องเร็ว → non-host ตามด้วย lerp 0.2 จะตามหลัง host ~1-2 ช่องตลอด ("เดินไม่ตรงกัน")
+ เร่ง lerp เฉพาะบอท quiz_carry ให้ตามตำแหน่ง host ทันขึ้น (ไม่แตะเกมอื่น/ผู้เล่นจริง) */
+ const lerpF = (isQuizCarry() && isPreviewBotId(id)) ? 0.5 : LERP;
+ if (o.tx != null) o.x += (o.tx - o.x) * lerpF;
+ if (o.ty != null) o.y += (o.ty - o.y) * lerpF;
});
tickQuizCarryEmbedCountdown();
tickQuizCarryRoundTimers();
@@ -24033,7 +24360,7 @@
if (myId == null) return;
const humansReady = gauntletCrownAllReadyPlay();
if (humansReady) {
- socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); });
return;
}
const sidH = String(myId);
@@ -24065,7 +24392,7 @@
if (myId == null) return;
const humansReadySs = gauntletCrownAllReadyPlay();
if (humansReadySs) {
- socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); });
return;
}
const sidHostSs = String(myId);
@@ -24096,7 +24423,7 @@
if (myId == null) return;
const humansReady = gauntletCrownAllReadyPlay();
if (humansReady) {
- socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); });
return;
}
const sidH = String(myId);
@@ -24128,7 +24455,7 @@
if (myId == null) return;
const humansReadySt = gauntletCrownAllReadyPlay();
if (humansReadySt) {
- socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (r && r.ok === false) showCrownStartBlockedNoticePlay(r); });
return;
}
const sidHostSt = String(myId);
@@ -24160,7 +24487,7 @@
return;
}
socket.emit('gauntlet-crown-lobby-start', {}, (r) => {
- if (!r || !r.ok) { /* ignore */ }
+ if (r && r.ok === false) showCrownStartBlockedNoticePlay(r);
});
return;
}
diff --git a/www/html/Game/server.js b/www/html/Game/server.js
index f222b7c..c4e7533 100644
--- a/www/html/Game/server.js
+++ b/www/html/Game/server.js
@@ -1800,6 +1800,10 @@ function 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 — ผู้ถูกแบนเข้ามาดูเฉยๆ ไม่ได้เล่น → ไม่นับเป็นผู้รอด/ไม่ได้คะแนน */
@@ -1810,8 +1814,11 @@ function minigamePlayerScoreSurvived(space, p, gameType) {
return { score: st ? Math.max(0, Number(st.score) | 0) : 0, survived: st ? !st.eliminated : true };
}
case 'gauntlet':
- case 'jump_survive':
return { score: Math.max(0, p.gauntletScore | 0), survived: !p.gauntletEliminated };
+ case 'jump_survive':
+ /* jump_survive ไม่มี gauntlet ticker → gauntletScore/Eliminated ฝั่ง server เป็น 0/false เสมอ
+ ใช้ค่าที่ client รายงานตอนจบแทน (reportedMiniSurvived = ไม่ตาย) ; เดิมเข้า case gauntlet → ทุกคน survived เสมอ = coin มั่ว */
+ return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false };
case 'space_shooter':
return { score: Math.max(0, p.spaceShooterScore | 0), survived: true };
case 'balloon_boss':
@@ -4143,8 +4150,12 @@ function endQuizGame(sid, space, message) {
clearSpaceQuizTimers(space);
if (space.quizSession) space.quizSession.active = false;
if (space.detectiveMinigameActive) {
- returnDetectiveSpaceToLobbyB(sid, space, message, { allPlayers: true });
- space.quizSession = null;
+ /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น)
+ เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด"
+ การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */
+ io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true });
+ /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้
+ (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */
return;
}
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' });
@@ -7886,6 +7897,87 @@ io.on('connection', (socket) => {
socket.to(sid).emit('balloon-boss-over', { reason, scores });
});
+ /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ
+ → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */
+ socket.on('balloon-boss-sync', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */
+ const bossDmg = Math.max(0, Number(data.bossDmg) || 0);
+ const 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 });
+ });
+
+ /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */
+ socket.on('stack-drop', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !data) return;
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0;
+ socket.to(sid).emit('stack-drop', {
+ placedCx: num(data.placedCx), placedW: num(data.placedW),
+ miss: !!data.miss, perfect: !!data.perfect,
+ pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles),
+ overlap: num(data.overlap),
+ supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)),
+ delta: num(data.delta),
+ seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy,
+ actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot,
+ });
+ });
+ /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */
+ socket.on('quiz-end-request', () => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */
+ if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return;
+ const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
+ if (!md || md.gameType !== 'quiz') return;
+ endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด');
+ });
+
+ /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */
+ socket.on('stack-swing', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || !data) return;
+ const ph = Number(data.phase);
+ if (!Number.isFinite(ph)) return;
+ socket.to(sid).emit('stack-swing', { phase: ph });
+ });
+ /* MG3 stack — host เป็นเจ้าของลำดับตา+index relay ให้ non-host (กัน host/client มองลำดับตาไม่ตรง) */
+ socket.on('stack-state', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */
+ const order = Array.isArray(data.order)
+ ? data.order.filter((e) => e && (e.kind === 'human' || e.kind === 'bot')).slice(0, 8).map((e) => ({
+ kind: e.kind === 'bot' ? 'bot' : 'human',
+ seat: Math.max(1, Math.min(8, Math.floor(Number(e.seat)) || 1)),
+ id: e.id != null ? String(e.id).slice(0, 64) : null,
+ botId: e.botId != null ? String(e.botId).slice(0, 64) : null,
+ }))
+ : [];
+ const idx = Math.max(0, Math.floor(Number(data.idx)) || 0);
+ socket.to(sid).emit('stack-state', { order, idx });
+ });
+
+ /* MG3 stack — host ตัดสินจบรอบ relay ให้ทุกคนจบพร้อมกัน */
+ socket.on('stack-over', (data) => {
+ const sid = socket.data.spaceId;
+ const space = sid ? spaces.get(sid) : null;
+ if (!space) return;
+ const outcome = (data && typeof data.outcome === 'string') ? data.outcome.slice(0, 32) : 'time_up';
+ socket.to(sid).emit('stack-over', { outcome });
+ });
+
/* MG6 space_shooter — host ตัดสินจบเกม + แนบคะแนนสุดท้าย → ทุกคนจบ/อันดับตรงกัน */
socket.on('space-shooter-over', (data) => {
const sid = socket.data.spaceId;
@@ -8062,7 +8154,16 @@ io.on('connection', (socket) => {
/* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */
const finPeer = space.peers.get(socket.id);
if (finPeer && data && typeof data === 'object') {
- if (Number.isFinite(Number(data.score))) finPeer.reportedMiniScore = Math.max(0, Math.floor(Number(data.score)));
+ /* คะแนน 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) {
@@ -8870,6 +8971,13 @@ io.on('connection', (socket) => {
moveForceEmit = true;
}
}
+ if (p && md && md.gameType === 'jump_survive' && data) {
+ /* MG5: การตายมาจากเจ้าตัว → relay แม้ตำแหน่งไม่เปลี่ยน (ตอนตายไม่ขยับ) ไม่งั้นคนอื่นไม่รู้ = "ไม่ยอมตาย" */
+ if (data.jumpSurviveEliminated && !p.jumpSurviveEliminated) {
+ p.jumpSurviveEliminated = true;
+ moveForceEmit = true;
+ }
+ }
if (p && md && md.gameType === 'balloon_boss' && data) {
const bbDefaultBalloons = balloonBossBalloonsForMap(md);
if (data.balloonBossScore != null) {
@@ -8934,6 +9042,9 @@ io.on('connection', (socket) => {
if (md && md.gameType === 'quiz_carry') {
out.quizCarryHeld = (p.quizCarryHeld === undefined ? null : p.quizCarryHeld);
}
+ if (md && md.gameType === 'jump_survive') {
+ out.jumpSurviveEliminated = !!p.jumpSurviveEliminated;
+ }
if (nx !== data.x || ny !== data.y) socket.emit('user-move', out);
socket.to(sid).emit('user-move', out);
}
diff --git a/www/html/Main-Lobby/index.html b/www/html/Main-Lobby/index.html
index 094ba76..ad1cd60 100644
--- a/www/html/Main-Lobby/index.html
+++ b/www/html/Main-Lobby/index.html
@@ -231,6 +231,6 @@
-
+