update minigame 3 all etc

This commit is contained in:
2026-07-04 14:02:12 +00:00
parent f638547f32
commit b974381bae
18 changed files with 7171 additions and 123 deletions
+7 -1
View File
@@ -5208,8 +5208,10 @@
.then(function (data) {
var pk = (data && data.pickVoteSec != null) ? Number(data.pickVoteSec) : 25;
var tr = (data && data.trialVoteSec != null) ? Number(data.trialVoteSec) : 30;
var rv = (data && data.trialResultRevealMs != null) ? Number(data.trialResultRevealMs) : 7000;
if (el('vote-timing-pick')) el('vote-timing-pick').value = String(Number.isFinite(pk) ? pk : 25);
if (el('vote-timing-trial')) el('vote-timing-trial').value = String(Number.isFinite(tr) ? tr : 30);
if (el('vote-timing-reveal')) el('vote-timing-reveal').value = String(Number.isFinite(rv) ? rv : 7000);
setMsg('vote-timing-msg', '', '');
})
.catch(function (e) { setMsg('vote-timing-msg', (e.message || 'โหลดไม่ได้') + ' — เช็ค /Game/api/game-timing', 'error'); });
@@ -5221,20 +5223,24 @@
btn.addEventListener('click', function () {
var pk = Math.max(5, Math.min(120, parseInt((el('vote-timing-pick') || {}).value, 10) || 25));
var tr = Math.max(5, Math.min(180, parseInt((el('vote-timing-trial') || {}).value, 10) || 30));
var rv = Math.max(1500, Math.min(20000, parseInt((el('vote-timing-reveal') || {}).value, 10) || 7000));
btn.disabled = true;
gameTimingFetch('GET')
.then(function (data) {
data.pickVoteSec = pk;
data.trialVoteSec = tr;
data.trialResultRevealMs = rv;
return gameTimingFetch('PUT', data);
})
.then(function () { return gameTimingFetch('GET'); })
.then(function (fresh) {
var p = (fresh && fresh.pickVoteSec != null) ? fresh.pickVoteSec : pk;
var t = (fresh && fresh.trialVoteSec != null) ? fresh.trialVoteSec : tr;
var r = (fresh && fresh.trialResultRevealMs != null) ? fresh.trialResultRevealMs : rv;
if (el('vote-timing-pick')) el('vote-timing-pick').value = String(p);
if (el('vote-timing-trial')) el('vote-timing-trial').value = String(t);
setMsg('vote-timing-msg', 'บันทึกแล้ว — โหวตเลือกผู้เล่น ' + p + 's · ชี้ตัวคนร้าย ' + t + 's · ผู้เล่นต้องรีเฟรช/เข้าห้องใหม่', 'ok');
if (el('vote-timing-reveal')) el('vote-timing-reveal').value = String(r);
setMsg('vote-timing-msg', 'บันทึกแล้ว — โหวตเลือกผู้เล่น ' + p + 's · ชี้ตัวคนร้าย ' + t + 's · โชว์ผล ' + Math.round(r/1000) + 's · ผู้เล่นต้องรีเฟรช/เข้าห้องใหม่', 'ok');
})
.catch(function (e) { setMsg('vote-timing-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็ค Node + /Game/api/game-timing', 'error'); })
.then(function () { btn.disabled = false; });
+4 -1
View File
@@ -1151,6 +1151,9 @@
<label>โหวตชี้ตัวคนร้าย (พิจารณาคดี)
<input type="number" id="vote-timing-trial" name="trialVoteSec" min="5" max="180" step="1" value="30"> วินาที
</label>
<label>โชว์ผลโหวต (คนร้ายถูก/ผิด) ก่อนไปหน้าผลเต็ม
<input type="number" id="vote-timing-reveal" name="trialResultRevealMs" min="1500" max="20000" step="500" value="7000"> มิลลิวินาที (1000ms = 1 วิ)
</label>
<button type="button" class="btn btn-primary" id="btn-vote-timing-save">บันทึก</button>
</form>
<p id="vote-timing-msg" class="msg" role="status"></p>
@@ -1330,7 +1333,7 @@
</div>
</main>
</div>
<script src="admin.js?v=92"></script>
<script src="admin.js?v=93"></script>
<script>
/* toggle: เปิด/ปิด HUD โหลด LobbyA (Iron Man) ให้ "ผู้เล่นทั่วไป" เห็น (เก็บใน game-timing.json: lobbyDebugHud)
— dev เปิดเครื่องตัวเองได้เสมอด้วย ?dbg=1 หรือ localStorage.lobbyDbg='1' โดยไม่ต้องใช้ปุ่มนี้ */
+40 -25
View File
@@ -51,9 +51,9 @@
"providerUserId": "p_1775109142385_wq7wfy1p32j",
"notes": "auto: player-coins",
"blocked": false,
"coins": 3090,
"coins": 3156,
"createdAt": "2026-04-02T05:52:21+00:00",
"updatedAt": "2026-07-03T16:31:03+00:00",
"updatedAt": "2026-07-04T13:18:23+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -67,16 +67,16 @@
],
"lockUntilMs": 0
},
"score": 1016,
"score": 1082,
"scoreByCase": {
"1": 26,
"10": 978,
"8": 306,
"8": 324,
"11": 20,
"13": 368,
"14": 30,
"9": 40,
"12": 476,
"12": 524,
"15": 20,
"4": 20,
"5": 32,
@@ -157,7 +157,7 @@
"blocked": false,
"coins": 382,
"createdAt": "2026-04-24T07:28:02+00:00",
"updatedAt": "2026-07-03T16:38:57+00:00",
"updatedAt": "2026-07-04T10:39:15+00:00",
"score": 382,
"scoreByCase": {
"8": 220,
@@ -169,7 +169,9 @@
},
"lbName": "Q",
"achievements": {
"d1_minigame_solver": 8
"d1_minigame_solver": 8,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
},
"lobbyColorThemeIndex": 6,
"lobbySkinToneIndex": 1
@@ -484,9 +486,9 @@
"providerUserId": "p_1781851275186_szcmp5pu3s",
"notes": "auto: player-coins",
"blocked": false,
"coins": 254,
"coins": 290,
"createdAt": "2026-06-19T06:41:15+00:00",
"updatedAt": "2026-07-03T16:31:03+00:00",
"updatedAt": "2026-07-04T13:18:23+00:00",
"daily": {
"anchorMs": 1781802000000,
"claimedDays": [
@@ -500,19 +502,24 @@
],
"lockUntilMs": 0
},
"score": 234,
"score": 270,
"scoreByCase": {
"10": 70,
"13": 36,
"12": 82,
"12": 108,
"4": 28,
"3": 18
"3": 18,
"8": 10
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 20,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
"a5_truth_hunter": 1,
"e2_the_impostor": 1,
"e4_agent_of_chaos": 1,
"e3_master_of_doubt": 1,
"e5_slippery_eel": 1
}
},
{
@@ -717,21 +724,26 @@
"providerUserId": "p_1782122326943_7p2fnvivscm",
"notes": "auto: player-coins",
"blocked": false,
"coins": 216,
"coins": 264,
"createdAt": "2026-06-22T09:58:46+00:00",
"updatedAt": "2026-07-03T16:31:03+00:00",
"updatedAt": "2026-07-04T13:20:26+00:00",
"lobbyColorThemeIndex": 3,
"lobbySkinToneIndex": 1,
"score": 206,
"score": 254,
"scoreByCase": {
"10": 34,
"12": 108,
"12": 142,
"3": 54,
"13": 10
"13": 10,
"8": 14
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 20
"d1_minigame_solver": 20,
"e2_the_impostor": 1,
"e4_agent_of_chaos": 1,
"e3_master_of_doubt": 1,
"e5_slippery_eel": 1
},
"daily": {
"anchorMs": 1782147600000,
@@ -755,20 +767,23 @@
"providerUserId": "p_1782122328583_4mqrfx5txyo",
"notes": "auto: player-coins",
"blocked": false,
"coins": 46,
"coins": 92,
"createdAt": "2026-06-22T09:58:48+00:00",
"updatedAt": "2026-07-03T16:31:03+00:00",
"updatedAt": "2026-07-04T13:20:26+00:00",
"lobbyColorThemeIndex": 1,
"lobbySkinToneIndex": 1,
"score": 46,
"score": 92,
"scoreByCase": {
"10": 20,
"8": 18,
"13": 8
"8": 32,
"13": 8,
"12": 32
},
"lbName": "MONE",
"achievements": {
"d1_minigame_solver": 4
"d1_minigame_solver": 12,
"a1_first_deduction": 1,
"a5_truth_hunter": 1
}
},
{
+6 -5
View File
@@ -1,7 +1,7 @@
{
"gauntletTickMs": 220,
"gauntletJumpTicks": 2,
"gauntletJumpHeightMult": 1,
"gauntletJumpTicks": 4,
"gauntletJumpHeightMult": 0.52,
"gauntletTimeLimitSec": 0,
"troublesomeOfferSec": 15,
"gauntletLaneImageUrls": [
@@ -98,15 +98,16 @@
"balloonBossBalloonsPerPlayer": 3,
"forcedMinigameKeys": [
"stack",
"jump_survive",
"balloon_boss"
"gauntlet",
"space_shooter"
],
"testSpecialCardByMap": {},
"troublesomeForceOffer": false,
"troublesomeRollMaxSec": 15,
"troublesomeRollMaxSec": 8,
"specialQuizIconExpireSec": 20,
"pickVoteSec": 25,
"trialVoteSec": 30,
"trialResultRevealMs": 7000,
"lobbyDebugHud": false,
"lobbyDebugHudNames": []
}
+95 -29
View File
@@ -926,6 +926,14 @@
}).catch(function () {});
} catch (e) { /* ignore */ }
}
/** หมวด gamelog ตามเกมปัจจุบัน (MG1-MG7) */
function gameLogCatForPlay() {
try {
var gt = mapData && mapData.gameType;
var m = { quiz: 'MG1', gauntlet: 'MG2', stack: 'MG3', quiz_carry: 'MG4', jump_survive: 'MG5', space_shooter: 'MG6', balloon_boss: 'MG7' };
return (gt && m[gt]) || 'อื่นๆ';
} catch (e) { return 'อื่นๆ'; }
}
/** block MG3 seat " avatar (playTint)" vs " block (resolved)"; /gamelog
เรยกเอง: window.__mg3ColorLog() หร auto throttle */
let _mg3ColorLogAt = 0;
@@ -3137,8 +3145,10 @@
const diskSet = playCharDiskLayersSet(charId);
if (!diskSet) {
// manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404)
// เครื่องช้า/เปิดหลายหน้าต่าง manifest มาช้า >3.6วิ → เดิมยอมแพ้ 8 ครั้ง = char ไม่ warm (avatar ก้อนเขียวค้าง)
// ขยายเป็น ~15วิ (30×500ms) ให้เครื่องช้าโหลด avatar สำเร็จ
const t = _tries || 0;
if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450);
if (t < 30) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 500);
return;
}
_playPreloadedChars.add(charId);
@@ -7634,6 +7644,12 @@
}
function stackTowerMissionElapsedSecPlay() {
/* server (playMissionLiveBeginsAt) + serverNow() TIME
แมเข/reconnect (เดมใช performance.now() local = จอทเขาทหลงนบเวลาเรมใหม TIME 90 vs 8) */
if (playMissionLiveBeginsAt > 0) {
const el = (serverNow() - playMissionLiveBeginsAt) / 1000;
if (Number.isFinite(el) && el >= 0) return el;
}
if (stackTowerSessionStartAt <= 0) return 0;
return (performance.now() - stackTowerSessionStartAt) / 1000;
}
@@ -10108,6 +10124,27 @@
let netReconnecting = false;
let netPingTimer = null;
let netOkHideTimer = null;
/* เน็ตแย่ = กด Ready มินิเกมไม่ได้ (กันเริ่มเกมตอน sync ยังไม่พร้อม) — มี safety กันค้าง */
let netBadSinceMs = 0;
const NET_READY_BLOCK_SAFETY_MS = 15000; /* เน็ตแย่ต่อเนื่องเกินนี้ → ปลดล็อกให้กดได้ (กัน soft-lock) */
function isNetBadNowPlay() {
return netReconnecting || (socket && socket.connected === false) || (netLastRtt != null && netLastRtt >= NET_RTT_BAD_MS);
}
/** true = เน็ตแย่จน "ยังไม่ควรให้กด Ready" (แต่ถ้าแย่ต่อเนื่องเกิน safety → คืน false ปลดล็อก) */
function isNetTooBadToReadyPlay() {
if (!isNetBadNowPlay()) return false;
if (!netBadSinceMs) return true; /* เพิ่งแย่ */
return (Date.now() - netBadSinceMs) < NET_READY_BLOCK_SAFETY_MS;
}
let _netWaitToastAt = 0;
function showNetWaitToastPlay() {
const now = Date.now();
if (now - _netWaitToastAt < 1500) return; /* กันสแปม */
_netWaitToastAt = now;
try { showCrownStartBlockedNoticePlay({ error: 'กำลังรอสัญญาณให้เสถียร… อีกครู่กดพร้อมได้' }); } catch (_e) { /* ignore */ }
try { setNetStatus('bad', 'กำลังปรับสัญญาณให้ลื่นขึ้น — รอสักครู่ค่อยกดพร้อม', true); } catch (_e2) { /* ignore */ }
try { gamelogPost(gameLogCatForPlay(), 'net-ready-block', '[TC175] เน็ตแย่ → บล็อกกด Ready (block+safety 15วิ) · rtt=' + (netLastRtt == null ? '?' : Math.round(netLastRtt)) + ' reconn=' + netReconnecting); } catch (_e3) { /* ignore */ }
}
function ensureNetStatusEl() {
if (netStatusEl) return netStatusEl;
const css = document.createElement('style');
@@ -10147,6 +10184,11 @@
}
}
function refreshNetStatus() {
/* จับ/ปล่อยเวลาเริ่มแย่ (ใช้คุม safety ปลดล็อกปุ่ม Ready) + refresh ปุ่ม how-to ให้ปลดล็อกเองเมื่อเน็ตกลับมา */
const badNow = isNetBadNowPlay();
if (badNow && !netBadSinceMs) netBadSinceMs = Date.now();
else if (!badNow && netBadSinceMs) netBadSinceMs = 0;
try { if (typeof syncGauntletCrownJumpButton === 'function') { /* no-op guard */ } if (typeof refreshGauntletCrownHowtoReadyButtonsPlay === 'function') refreshGauntletCrownHowtoReadyButtonsPlay(); } catch (_e) { /* ignore */ }
if (netReconnecting || (socket && socket.connected === false)) {
setNetStatus('bad', 'กำลังเชื่อมต่อใหม่…', true);
return;
@@ -13885,10 +13927,32 @@
btn.title = allReady
? (isMePlayHost() ? 'START' : 'รอโฮสต์เริ่ม')
: (meReady ? 'ยกเลิก READY' : 'READY');
/* เน็ตแย่ + ยังไม่กด Ready → ล็อกปุ่มไว้ก่อน (block+safety 15วิ) โชว์คำสุภาพ · เน็ตกลับมา/ครบ safety = ปลดเอง */
if (!locked && !meReady && !allReady && isNetTooBadToReadyPlay()) {
btn.disabled = true;
btn.classList.add('is-net-blocked');
btn.style.filter = 'grayscale(0.72) brightness(0.6)';
btn.style.cursor = 'not-allowed';
btn.title = 'กำลังรอสัญญาณให้เสถียร… อีกครู่กดพร้อมได้';
} else {
btn.classList.remove('is-net-blocked');
btn.style.removeProperty('filter');
btn.style.removeProperty('cursor');
}
updateGauntletCrownReadyAvatars(); /* โชว์หน้าคนที่กด ready (#2) */
return allReady;
}
/** refresh ปุ่ม Ready how-to (ใช้ตอนเน็ตกลับมา → ปลดล็อกเอง) — หยิบ st/btn ปัจจุบันแล้ว apply ซ้ำ */
function refreshGauntletCrownHowtoReadyButtonsPlay() {
try {
const btn = (typeof getGauntletCrownHowtoBtnEl === 'function') ? getGauntletCrownHowtoBtnEl() : document.getElementById('btn-gch-ready');
if (!btn || btn.classList.contains('is-hidden')) return;
const st = (typeof getGauntletCrownHowtoStatusEl === 'function') ? getGauntletCrownHowtoStatusEl() : document.getElementById('gauntlet-crown-howto-status');
applyGauntletCrownReadyButtonPlay(st, btn);
} catch (_e) { /* ignore */ }
}
/** วาดหน้าตัวละครจริง (tinted) ในกรอบ player-pic-frame — ใช้ใต้ปุ่ม READY (โชว์คนที่กด ready) */
function renderReadyAvatarInto(el, id) {
var info = (typeof specialQuizResolveCharTint === 'function') ? specialQuizResolveCharTint(id) : { characterId: '', tint: null };
@@ -21325,6 +21389,18 @@
}
}
}
/* full-sync + decrypt% (server-auth) self-correct packet stack-drop
(แก "หัวใจไม่ลดบางที / % ค้าง") · วใจปรบลงเทาน, % เดนหนาเทาน ( flicker) · ไมแตะชวงกำล drop */
if (stackMini && !stackFall && !stackMini.settling) {
if (d.teamMissesLeft != null && stackMini.teamMissesLeft != null) {
const hm = Math.max(0, Math.floor(Number(d.teamMissesLeft)));
if (Number.isFinite(hm) && hm < stackMini.teamMissesLeft) stackMini.teamMissesLeft = hm;
}
if (d.progressPct != null) {
const pp = Number(d.progressPct);
if (Number.isFinite(pp) && pp >= 0) stackMini.progressPct = Math.max(stackMini.progressPct || 0, Math.min(100, pp));
}
}
logStackSeatColors('client-recv');
});
@@ -24710,35 +24786,20 @@
}
function syncMg2MockJumpButtonBoundsPlay(stackRect, scale) {
/* action / MG3 (, CSS base #gauntlet-crown-jump-btn)
เดมวางปมไวในภาพ mock-HUD (1648*s,818*s ขนาด 231*s) เฉพาะ gauntlet_crown/jumper/mega_virus
ทำใหตำแหน/ขนาดไมเทากนทกเกม. ตอนนเคลยร inline เสมอ มกลบไปใช base (เท MG3 กเกม) */
const jumpBtn = document.getElementById('gauntlet-crown-jump-btn');
const hud = document.getElementById('play-cyber-hud');
const _jbSkin = hud ? hud.dataset.missionMockSkin : '';
if (!jumpBtn || !hud || (_jbSkin !== 'gauntlet_crown' && _jbSkin !== 'jumper' && _jbSkin !== 'mega_virus')) {
if (jumpBtn) {
jumpBtn.style.removeProperty('left');
jumpBtn.style.removeProperty('top');
jumpBtn.style.removeProperty('width');
jumpBtn.style.removeProperty('height');
jumpBtn.style.removeProperty('right');
jumpBtn.style.removeProperty('bottom');
jumpBtn.style.removeProperty('transform');
jumpBtn.style.removeProperty('filter');
}
return;
}
if (jumpBtn.classList.contains('is-hidden')) return;
const r = stackRect || (document.getElementById('play-canvas-stack') || {}).getBoundingClientRect?.();
const s = Number.isFinite(scale) ? scale : _quizTfScaleLast;
if (!r || !(s > 0)) return;
jumpBtn.style.position = 'fixed';
jumpBtn.style.left = Math.round(r.left + 1648 * s) + 'px';
jumpBtn.style.top = Math.round(r.top + 818 * s) + 'px';
jumpBtn.style.width = Math.round(231 * s) + 'px';
jumpBtn.style.height = Math.round(231 * s) + 'px';
jumpBtn.style.right = 'auto';
jumpBtn.style.bottom = 'auto';
jumpBtn.style.transform = 'none';
jumpBtn.style.filter = 'drop-shadow(0 0 ' + Math.max(8, Math.round(18 * s)) + 'px rgba(255, 110, 220, 0.45))';
if (!jumpBtn) return;
jumpBtn.style.removeProperty('left');
jumpBtn.style.removeProperty('top');
jumpBtn.style.removeProperty('width');
jumpBtn.style.removeProperty('height');
jumpBtn.style.removeProperty('right');
jumpBtn.style.removeProperty('bottom');
jumpBtn.style.removeProperty('transform');
jumpBtn.style.removeProperty('filter');
jumpBtn.style.removeProperty('position');
}
function clearQuizTfMockHudBoundsPlay(root) {
@@ -27180,6 +27241,11 @@
(function wireGauntletCrownHowtoPrimary() {
function onHowtoPrimaryClick() {
/* เน็ตแย่ + ยังไม่กด Ready → บล็อกการกดพร้อม (block+safety 15วิ) โชว์คำสุภาพ · ไม่บล็อกการยกเลิก/START */
if (isNetTooBadToReadyPlay()) {
const meReadyNow = !!(myId != null && gauntletCrownLobbyReadyMap[String(myId)]);
if (!meReadyNow && !gauntletCrownAllReadyPlay()) { showNetWaitToastPlay(); return; }
}
if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'howto') return;
const humans = quizCarryPregameHumanIds();
+193 -27
View File
@@ -87,10 +87,16 @@
dbgRender();
setTimeout(function () { el.style.opacity = '0'; setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 450); }, 600);
}
/* อ่าน flag จาก admin (game-timing.json) — เปิด HUD ให้ทุกคน หรือเฉพาะชื่อที่ระบุ */
/* เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — default 7000ms, admin override ผ่าน game-timing.json */
var trialResultRevealMs = 7000;
/* อ่าน flag จาก admin (game-timing.json) — เปิด HUD ให้ทุกคน หรือเฉพาะชื่อที่ระบุ + เวลาโชว์ผลโหวต */
try {
fetch(BASE + '/data/game-timing.json?_=' + Date.now()).then(function (r) { return r.json(); }).then(function (g) {
if (!g || !g.lobbyDebugHud) return;
if (!g) return;
/* เวลาโชว์ผลโหวต (คนร้ายถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — admin ปรับได้ (default 7000ms) */
var trm = Number(g.trialResultRevealMs);
if (Number.isFinite(trm) && trm >= 1500 && trm <= 20000) trialResultRevealMs = Math.round(trm);
if (!g.lobbyDebugHud) return;
var names = Array.isArray(g.lobbyDebugHudNames) ? g.lobbyDebugHudNames.map(function (s) { return String(s).trim().toLowerCase(); }).filter(Boolean) : [];
var me = ''; try { me = (getProfileDisplayName() || '').trim().toLowerCase(); } catch (e) { /* ignore */ }
_dbgAdmin = (names.length === 0) || names.indexOf(me) >= 0;
@@ -293,11 +299,15 @@
let suspectCardMinigames = [];
/** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */
let suspectProgress = [0, 0, 0];
/* #6: (shared server ) = 0-3
ใชโชวองหลกฐานใตการ suspect แทนหลกฐานสวนต (myPlayerEvidence) แตละคนเหนไมเทาก */
let suspectTeamProgress = [0, 0, 0];
/** การ์ดหลักฐานที่ผู้เล่นคนนี้เก็บได้ — [suspectIdx][cardIdx 02] จากเซิร์ฟ */
let myPlayerEvidence = [[], [], []];
/** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */
let trialMode = null;
let myTrialVote = null;
let trialVoteConfirmed = false; /* #7: กด "ยืนยันโหวต" แล้ว → ล็อก ห้ามเปลี่ยน + ส่งจริง (barrier รอทุกคน) */
let trialExcludedSuspect = null; /* รอบจับผิดตัว: ผู้ต้องสงสัยที่โหวตผิดไปแล้ว — ห้ามเลือกซ้ำ */
let trialSilencedMe = false; /* ฉันถูกปิดปาก (Silence) — ห้ามโหวตรอบนี้ */
let trialVoteCounts = [0, 0, 0];
@@ -1852,7 +1862,8 @@
ctx.fillText('🔇', cx, voiceIconY);
ctx.textBaseline = 'alphabetic';
}
if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) {
/* LobbyB ไม่มีเฟส "กดพร้อมเริ่มเกม" แบบ LobbyA → ไม่โชว์ ✓ ready เหนือหัว (รกจอ ไม่มีความหมาย) */
if (p.ready && !isPostCaseLobbyRoom() && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) {
var nwI = lobbyReadyIconImg.naturalWidth;
var nhI = lobbyReadyIconImg.naturalHeight;
if (nwI > 0 && nhI > 0) {
@@ -4903,6 +4914,13 @@
else if (myPlayerEvidence && myPlayerEvidence.some(function (row) { return row && row.length; })) {
setSuspectProgress([0, 1, 2].map(function (i) { return (myPlayerEvidence[i] || []).length; }));
}
/* #6: เก็บ team progress (shared) — ใช้เป็นตัวหลักในการวาดช่องหลักฐาน (เท่ากันทุกจอ) */
if (data && Array.isArray(data.suspectTeamProgress)) {
suspectTeamProgress = [0, 1, 2].map(function (i) {
var v = Math.floor(Number(data.suspectTeamProgress[i]));
return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0;
});
}
applySuspectProgressVisual();
/* อัปเดต UI host เร็วทันที — ปุ่ม "ชี้ตัวคนร้าย" ต้องโผล่ทันทีเมื่อสืบครบ */
if (suspectPickOverlayOpen) updateSuspectHostUi();
@@ -5274,8 +5292,12 @@
for (var i = 0; i <= 2; i++) {
var card = document.querySelector('.suspect-card[data-index="' + i + '"]');
if (!card) continue;
var count = getSuspectEvidenceCount(i);
if (!count && suspectProgress && suspectProgress[i] > 0) count = suspectProgress[i];
/* #6: team progress (shared) เป็นหลัก → ทุกจอเห็นเท่ากัน · fallback หลักฐานส่วนตัวเฉพาะตอน server ยังไม่ส่ง */
var count = (suspectTeamProgress && suspectTeamProgress[i] > 0) ? suspectTeamProgress[i] : 0;
if (!count) {
count = getSuspectEvidenceCount(i);
if (!count && suspectProgress && suspectProgress[i] > 0) count = suspectProgress[i];
}
card.classList.toggle('suspect-card--evidence-full', count >= 3);
var list = card.querySelector('.suspect-symbol-list');
if (!list) continue;
@@ -5350,7 +5372,12 @@
'.suspect-card--innocent{filter:grayscale(1) brightness(.8)}' +
'#trial-lose-txt{position:absolute;left:50%;top:4%;transform:translateX(-50%);width:712px;max-width:88vw;height:auto;z-index:61;display:none}' +
'#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' +
'#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}' +
/* #7: reveal (host) ย้ายไปมุมขวาล่าง เลี่ยงทับปุ่มยืนยันกลางล่าง */
'#trial-reveal-btn{position:absolute;right:4%;bottom:5%;left:auto;transform:none;background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:12px 22px;font:700 17px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}' +
/* #7: ปุ่มยืนยันโหวต (ทุกคน) กลางล่าง — เขียว, disabled=รอคนอื่น */
'#trial-confirm-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#34d399,#059669);color:#04140d;border:none;border-radius:16px;padding:15px 40px;font:800 21px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:61;box-shadow:0 8px 26px rgba(52,211,153,.55);display:none}' +
'#trial-confirm-btn:disabled{filter:grayscale(.5) brightness(.8);cursor:default;font-weight:700;font-size:17px}' +
'#trial-confirm-btn:not(:disabled):active{transform:translateX(-50%) scale(.96)}' +
// รอบจับผิดตัว: ใบที่โหวตผิดไปแล้ว — หรี่ + กันคลิก + ป้าย "โหวตผิดไปแล้ว"
'.suspect-card--excluded{filter:grayscale(1) brightness(.55);pointer-events:none;position:relative}' +
'.suspect-card--excluded .trial-vote-badge{display:none!important}' +
@@ -5489,8 +5516,10 @@
if (sub) sub.src = (mode === 'result') ? (RV + 'roomvote-txt-3.png') : (RV + 'roomvote-txt-2.png');
}
/* #7: คลิกการ์ด = "เลือก" อย่างเดียว (ยังไม่ส่ง) — เปลี่ยนใจได้จนกว่าจะกด "ยืนยันโหวต" */
function castTrialVote(idx) {
if (trialMode !== 'voting') return;
if (trialVoteConfirmed) { appendLobbySystemChat('— ยืนยันโหวตไปแล้ว รอผู้เล่นอื่น'); return; }
if (trialSilencedMe) { appendLobbySystemChat('— คุณถูกปิดปาก (Silence) · โหวตรอบนี้ไม่ได้'); return; }
if (trialExcludedSuspect != null && idx === trialExcludedSuspect) {
appendLobbySystemChat('— ผู้ต้องสงสัยหมายเลข ' + (idx + 1) + ' ถูกโหวตไปแล้ว (ผิด) · เลือกคนอื่น');
@@ -5503,14 +5532,50 @@
var b = c.querySelector('.trial-vote-badge');
if (b && sel) b.textContent = '✓ เลือกแล้ว';
});
socket.emit('trial-vote', { index: idx }, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
ensureTrialConfirmBtn(); /* โชว์ปุ่มยืนยันเมื่อเลือกแล้ว */
}
/* #7: กดยืนยัน → ส่ง trial-vote จริง + ล็อกเปลี่ยนใจ · server รอทุกคน (บอท auto, banned ข้าม) ค่อย reveal */
function confirmTrialVote() {
if (trialMode !== 'voting' || trialVoteConfirmed || myTrialVote == null) return;
trialVoteConfirmed = true;
var cb = document.getElementById('trial-confirm-btn');
if (cb) { cb.disabled = true; cb.textContent = 'รอผู้เล่นอื่นยืนยัน…'; }
document.querySelectorAll('.suspect-card').forEach(function (c) { c.style.pointerEvents = 'none'; });
socket.emit('trial-vote', { index: myTrialVote }, function (res) {
if (!(res && res.ok)) {
/* ส่งไม่สำเร็จ → ปลดล็อกให้เลือก/ยืนยันใหม่ */
trialVoteConfirmed = false;
document.querySelectorAll('.suspect-card').forEach(function (c) { c.style.pointerEvents = ''; });
if (cb) { cb.disabled = false; cb.textContent = 'ยืนยันโหวต ✓'; }
appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
}
});
appendLobbySystemChat('— ยืนยันโหวตแล้ว · รอผู้เล่นอื่นยืนยันให้ครบ');
try { lobbyLog('trial-confirm', '[TC180] กดยืนยันโหวต (แยก select/confirm) → ส่งจริง'); } catch (e) { /* ignore */ }
}
function ensureTrialConfirmBtn() {
var ov = document.getElementById('suspect-pick-overlay');
var btn = document.getElementById('trial-confirm-btn');
if (!btn && ov) {
btn = document.createElement('button');
btn.id = 'trial-confirm-btn';
btn.type = 'button';
btn.textContent = 'ยืนยันโหวต ✓';
btn.addEventListener('click', confirmTrialVote);
ov.appendChild(btn);
}
if (btn) {
btn.style.display = (trialMode === 'voting' && myTrialVote != null && !trialVoteConfirmed) ? 'block' : 'none';
if (!trialVoteConfirmed) { btn.disabled = false; btn.textContent = 'ยืนยันโหวต ✓'; }
}
}
function openTrialVoteUI(data) {
trialMode = 'voting';
myTrialVote = null;
trialVoteConfirmed = false; /* #7: รอบโหวตใหม่ → ยังไม่ยืนยัน */
trialExcludedSuspect = (data && data.revote && Number.isInteger(data.excludedSuspect)) ? data.excludedSuspect : null;
trialSilencedMe = !!(data && data.silenced);
trialVoteCounts = [0, 0, 0];
@@ -5528,8 +5593,10 @@
ov.setAttribute('aria-hidden', 'false');
document.querySelectorAll('.suspect-card').forEach(function (c) {
c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--selected', 'suspect-card--innocent', 'suspect-card--excluded');
c.style.pointerEvents = ''; /* #7: ปลดล็อกการคลิก (เผื่อรอบก่อนล็อกไว้ตอนยืนยัน) */
var xb = c.querySelector('.trial-excluded-badge'); if (xb) xb.remove();
});
ensureTrialConfirmBtn(); /* #7: reset ปุ่มยืนยัน (ซ่อนจนกว่าจะเลือก) */
/* รอบจับผิดตัว: ตีตราใบที่โหวตผิดไปแล้ว + ห้ามเลือกซ้ำ */
if (trialExcludedSuspect != null) {
document.querySelectorAll('.suspect-card').forEach(function (c) {
@@ -5665,12 +5732,16 @@
}
var rb = document.getElementById('trial-reveal-btn');
if (rb) rb.style.display = 'none';
var cfb = document.getElementById('trial-confirm-btn'); /* #7: ซ่อนปุ่มยืนยันตอน reveal */
if (cfb) cfb.style.display = 'none';
document.querySelectorAll('.suspect-card').forEach(function (c) { c.style.pointerEvents = ''; });
var hint = document.getElementById('suspect-pick-hint');
if (hint) hint.textContent = 'กำลังสรุปผล...';
appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names);
// โชว์คะแนนโหวตสักครู่ แล้วไปหน้าผลเต็มเสมอ (ถูก = เรือนจำไซเบอร์ / ผิด/ไม่โหวต = ภารกิจล้มเหลว)
try { lobbyLog('trial-reveal-dur', '[TC174] โชว์ผลโหวต ' + trialResultRevealMs + 'ms (admin ปรับได้) ก่อนไปหน้าผลเต็ม'); } catch (e) { /* ignore */ }
if (trialFinalTimer) clearTimeout(trialFinalTimer);
trialFinalTimer = setTimeout(function () { showFinalResult(correct, culprit, anyVotes); }, 2600);
trialFinalTimer = setTimeout(function () { showFinalResult(correct, culprit, anyVotes); }, trialResultRevealMs);
}
var trialFinalTimer = null;
@@ -7048,10 +7119,17 @@
});
socket.on('trial-vote-update', (data) => {
if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; // เก็บไว้โชว์ตอนเปิดเผยผล (ไม่โชว์ต่อใบตอนโหวต)
if (trialMode === 'voting') setTrialCounter((data && data.voted) || 0, (data && data.totalPlayers) || 0);
var voted = (data && data.voted) || 0;
var total = (data && data.totalPlayers) || 0;
if (trialMode === 'voting') setTrialCounter(voted, total);
const hint = document.getElementById('suspect-pick-hint');
if (hint && trialMode === 'voting') {
hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย';
hint.textContent = trialVoteConfirmed ? ('รอผู้เล่นอื่นยืนยัน…') : 'คลิกการ์ดที่คิดว่าเป็นคนร้าย แล้วกด “ยืนยันโหวต”';
}
/* #7: หลังยืนยันแล้ว โชว์ความคืบหน้าบนปุ่ม (x/total ยืนยันแล้ว) */
if (trialVoteConfirmed) {
var cfb = document.getElementById('trial-confirm-btn');
if (cfb && total > 0) cfb.textContent = 'รอผู้เล่นอื่น… (' + voted + '/' + total + ')';
}
});
socket.on('trial-result', (data) => {
@@ -7862,6 +7940,50 @@
if (path.length <= 1) return;
lobbyPath = path.slice(1);
}
/* #4 มือถือ: แตะ "ปุ่ม START GAME / ตถกลางแมป" เรนเดอรจร ทำ action เลย (แทนเดนไปย+กด F)
true าแตะโดนจ interact (จะไมเดนไปทแตะ) */
function lobbyTapWorldXYPlay(clientX, clientY) {
if (!mapData || !canvas || !mapTransform) return null;
const r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return null;
const sx = (clientX - r.left) * (canvas.width / r.width);
const sy = (clientY - r.top) * (canvas.height / r.height);
const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY } = mapTransform;
if (!(z > 0) || !(t > 0)) return null;
return { gx: (sx - cw / 2) / (z * t) + meX, gy: (sy - ch / 2) / (z * t) + meY };
}
function lobbyTapInteractPlay(clientX, clientY) {
const wp = lobbyTapWorldXYPlay(clientX, clientY);
if (!wp) return false;
const gx = wp.gx, gy = wp.gy;
/* 1) ปุ่มกลาง (START GAME / เปิดผู้ต้องสงสัย) = gridImageSprite[0] — แตะโดนกรอบปุ่ม (+เผื่อ 0.4 ช่อง) */
const sp = lobbyButtonSprite();
if (sp) {
const bx = Math.floor(sp.x), by = Math.floor(sp.y);
const bw = Math.max(1, Math.floor(sp.w) || 1), bh = Math.max(1, Math.floor(sp.h) || 1);
if (gx >= bx - 0.4 && gx < bx + bw + 0.4 && gy >= by - 0.4 && gy < by + bh + 0.4) {
try { lobbyLog('mobile-center-btn', '[TC177] แตะปุ่ม START/center โดยตรง (มือถือ ไม่ใช้ F)'); } catch (e) { /* ignore */ }
if (typeof isPostCaseLobbyRoom === 'function' && isPostCaseLobbyRoom() && serverSuspectPhaseActive && !suspectPickOverlayOpen) {
openSuspectOverlay(typeof suspectSelectedIndex === 'number' ? suspectSelectedIndex : 0); return true;
}
if (typeof isCurrentRoomLobbyA === 'function' && isCurrentRoomLobbyA()) {
if (hostId !== socket.id) { appendLobbySystemChat('— เฉพาะ Host กดเริ่มเกม'); return true; }
if (!lobbyAllHumansReady()) { appendLobbySystemChat('— รอให้ทุกคนกดพร้อมก่อน'); return true; }
openLobbyPreplayWizard(); return true;
}
return true; /* แตะโดนปุ่มแล้วแม้ยังไม่ถึงเงื่อนไข — ไม่เดินไปที่ปุ่ม */
}
}
/* 2) ช่องแต่งตัว (เขียว) / Host Console (ชมพู) — แตะใกล้ ~1.3 ช่อง (แทน F มือถือที่ถอดออก) */
const near = function (spot) {
if (!spot) return false;
const ddx = gx - (spot.x + 0.5), ddy = gy - (spot.y + 0.5);
return (ddx * ddx + ddy * ddy) <= 1.6;
};
if (near(ROOM_CZ_SPOT)) { openRoomCustomize(); return true; }
if (near(ROOM_HC_SPOT)) { openHostConsoleIfHost(); return true; }
return false;
}
if (canvas) {
try { canvas.style.touchAction = 'none'; } catch (_e) { /* กัน browser zoom/pan รบกวนการแตะเดิน */ }
let _lobbyTapStart = null;
@@ -7876,6 +7998,8 @@
_lobbyTapStart = null;
/* แตะสั้นๆ ไม่ลาก = เดิน · ลาก/กดค้าง = ข้าม (กันชนกับการ scroll/zoom) */
if (dx * dx + dy * dy > 24 * 24 || dt > 700) return;
/* #4: แตะปุ่ม START GAME/interact ที่เรนเดอร์ → ทำ action เลย (ไม่เดินไปที่ปุ่ม) */
if (lobbyTapInteractPlay(e.clientX, e.clientY)) return;
lobbyWalkToClientXY(e.clientX, e.clientY);
});
canvas.addEventListener('pointercancel', () => { _lobbyTapStart = null; });
@@ -7889,32 +8013,74 @@
canvas.addEventListener('dblclick', (e) => { lobbyWalkToClientXY(e.clientX, e.clientY); });
}
/* (touch-primary) "" F ( )
dispatch synthetic KeyF ใช handler interaction (host เลอกระด/คด, องแตงต ฯลฯ) PC ไมเหนปมน */
(function setupMobileLobbyInteractBtn() {
try {
/* F () "/" (lobbyTapInteractPlay)
อมมคางจาก build เก/cache ลบท */
(function removeLegacyMobileLobbyInteractBtn() {
try { var old = document.getElementById('lobby-mobile-interact-btn'); if (old && old.parentNode) old.parentNode.removeChild(old); } catch (_e) { /* ignore */ }
})();
/* #4 (rework 2026-07-04): DOM user " START GAME "
(ทำใน lobbyTapInteractPlay แล) · เกบฟงกนไวแตดการสรางป + ลบปมคางจาก build เก */
(function setupMobileLobbyCenterActionBtn() {
try { var oldC = document.getElementById('lobby-mobile-center-btn'); if (oldC && oldC.parentNode) oldC.parentNode.removeChild(oldC); } catch (_e) { /* ignore */ }
return; // eslint-disable-line no-unreachable
try { // eslint-disable-line no-unreachable
var coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (!coarse) return;
if (document.getElementById('lobby-mobile-interact-btn')) return;
if (document.getElementById('lobby-mobile-center-btn')) return;
var CS = '/Main-Lobby/IMAGE/case/btn-startcase.png';
var SU = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png';
var btn = document.createElement('button');
btn.id = 'lobby-mobile-interact-btn';
btn.id = 'lobby-mobile-center-btn';
btn.type = 'button';
btn.setAttribute('aria-label', 'โต้ตอบ (F)');
btn.innerHTML = '<span class="lmi-key">F</span><span class="lmi-txt">โต้ตอบ</span>';
btn.style.cssText = 'position:fixed;left:max(1rem,env(safe-area-inset-left));bottom:max(1.1rem,env(safe-area-inset-bottom));'
+ 'z-index:410;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;'
+ 'width:64px;height:64px;border-radius:50%;border:2px solid #ffd166;background:rgba(20,28,48,.86);'
+ 'color:#ffe9a8;font-family:inherit;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.45);touch-action:manipulation;';
btn.style.cssText = 'position:fixed;left:50%;bottom:max(1.4rem,env(safe-area-inset-bottom));transform:translateX(-50%);'
+ 'z-index:5400;display:none;align-items:center;gap:9px;padding:11px 22px 11px 14px;border-radius:44px;'
+ 'border:2px solid #ffd166;background:rgba(16,22,40,.93);color:#ffe9a8;font:800 17px/1 Kanit,system-ui,sans-serif;'
+ 'cursor:pointer;box-shadow:0 7px 22px rgba(0,0,0,.5);touch-action:manipulation;animation:lmcbPulse 1.8s ease-in-out infinite;';
var img = document.createElement('img');
img.alt = '';
img.style.cssText = 'height:38px;width:auto;max-width:84px;object-fit:contain;display:block;';
var label = document.createElement('span');
btn.appendChild(img);
btn.appendChild(label);
var st = document.createElement('style');
st.textContent = '#lobby-mobile-interact-btn .lmi-key{font-weight:800;font-size:20px;line-height:1}'
+ '#lobby-mobile-interact-btn .lmi-txt{font-size:11px;line-height:1;opacity:.92}'
+ '#lobby-mobile-interact-btn:active{transform:scale(.93);background:rgba(255,209,102,.25)}';
st.textContent = '@keyframes lmcbPulse{0%,100%{box-shadow:0 7px 22px rgba(0,0,0,.5)}50%{box-shadow:0 7px 30px rgba(255,209,102,.6)}}'
+ '#lobby-mobile-center-btn:active{transform:translateX(-50%) scale(.95)}';
document.head.appendChild(st);
btn.addEventListener('click', function (e) {
e.preventDefault();
try { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyF', key: 'f', bubbles: true })); } catch (_e) { /* ignore */ }
var mode = btn.dataset.mode || '';
try { lobbyLog('mobile-center-btn', '[TC177] กดปุ่มกลางมือถือ (แทน F) mode=' + mode); } catch (e) { /* ignore */ }
if (mode === 'start') {
if (typeof lobbyAllHumansReady === 'function' && !lobbyAllHumansReady()) { appendLobbySystemChat('— รอให้ทุกคนกดพร้อมก่อน'); return; }
if (typeof openLobbyPreplayWizard === 'function') openLobbyPreplayWizard();
} else if (mode === 'suspect') {
if (typeof openSuspectOverlay === 'function') openSuspectOverlay(typeof suspectSelectedIndex === 'number' ? suspectSelectedIndex : 0);
}
});
document.body.appendChild(btn);
function refreshCenterBtn() {
var show = false, mode = '', src = '', txt = '';
try {
if (typeof isPostCaseLobbyRoom === 'function' && isPostCaseLobbyRoom()
&& serverSuspectPhaseActive && !suspectPickOverlayOpen) {
show = true; mode = 'suspect'; src = SU; txt = 'ผู้ต้องสงสัย';
} else if (typeof isCurrentRoomLobbyA === 'function' && isCurrentRoomLobbyA()
&& hostId === socket.id && typeof lobbyAllHumansReady === 'function' && lobbyAllHumansReady()) {
show = true; mode = 'start'; src = CS; txt = 'เริ่มเกม';
}
} catch (_e) { show = false; }
if (show) {
btn.dataset.mode = mode;
if (img.getAttribute('src') !== src) img.src = src;
if (label.textContent !== txt) label.textContent = txt;
if (btn.style.display !== 'flex') btn.style.display = 'flex';
} else if (btn.style.display !== 'none') {
btn.style.display = 'none';
}
}
setInterval(refreshCenterBtn, 500);
refreshCenterBtn();
} catch (_e) { /* ignore */ }
})();
+4 -8
View File
@@ -4339,14 +4339,10 @@
color: #ff8a8a;
text-shadow: 0 0 8px rgba(255, 90, 90, 0.55);
}
/* ปุ่ม jump ทุกเกม (รวม MG2/MG5/MG7 mock-hud) ใช้ตำแหน่ง/ขนาดมาตรฐานแบบ MG3 (มุมขวาล่างวงกลม จาก base
#gauntlet-crown-jump-btn) — ไม่ override position/ขนาดอีก · คง glow ธีมชมพูไว้เฉยๆ */
html.play-mg2-mock-hud #gauntlet-crown-jump-btn.mg2-jump:not(.is-hidden) {
position: fixed !important;
right: auto !important;
bottom: auto !important;
z-index: 56;
border-radius: 0;
padding: 0;
margin: 0;
z-index: 59;
filter: drop-shadow(0 0 18px rgba(255, 110, 220, 0.45));
}
html.play-mg2-mock-hud #gauntlet-crown-jump-btn.mg2-jump:not(.is-hidden):active {
@@ -5462,7 +5458,7 @@
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/sound.js?v=1"></script>
<script src="js/play.js?v=0.006301075"></script>
<script src="js/play.js?v=0.006301080"></script>
<div class="version-tag">v —</div>
<style id="qc-howto-mg2-fix">
/* HOW TO PLAY ของ quiz_carry (Minigame-4) -> ให้เหมือน Minigame-2 (gch-mg2-mock)
+1 -1
View File
@@ -1649,7 +1649,7 @@
<script src="js/customize-popup.js?v=40" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/achievements.js?v=0.002" data-asset-base="img/03-6-Profile"></script>
<script src="js/sound.js?v=1"></script>
<script src="js/room-lobby.js?v=0.0345"></script>
<script src="js/room-lobby.js?v=0.0351"></script>
<div class="version-tag">v —</div>
</body>
</html>
+73 -9
View File
@@ -2282,6 +2282,8 @@ function defaultGameTiming() {
pickVoteSec: 25,
/** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */
trialVoteSec: 30,
/** เวลาโชว์ผลโหวตคนร้าย (ถูก/ผิด) ก่อนตัดไปหน้าผลเต็ม — มิลลิวินาที */
trialResultRevealMs: 7000,
};
}
@@ -2707,6 +2709,9 @@ function loadGameTiming() {
trialVoteSec: Object.prototype.hasOwnProperty.call(j, 'trialVoteSec')
? Math.max(5, Math.min(180, Math.floor(Number(j.trialVoteSec)) || 30))
: 30,
trialResultRevealMs: Object.prototype.hasOwnProperty.call(j, 'trialResultRevealMs')
? Math.max(1500, Math.min(20000, Math.floor(Number(j.trialResultRevealMs)) || 7000))
: 7000,
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
@@ -2937,6 +2942,9 @@ function saveGameTimingToFile(d) {
trialVoteSec: Object.prototype.hasOwnProperty.call(d, 'trialVoteSec')
? Math.max(5, Math.min(180, Math.floor(Number(d.trialVoteSec)) || 30))
: (prev.trialVoteSec != null ? Math.max(5, Math.min(180, Math.floor(Number(prev.trialVoteSec)) || 30)) : 30),
trialResultRevealMs: Object.prototype.hasOwnProperty.call(d, 'trialResultRevealMs')
? Math.max(1500, Math.min(20000, Math.floor(Number(d.trialResultRevealMs)) || 7000))
: (prev.trialResultRevealMs != null ? Math.max(1500, Math.min(20000, Math.floor(Number(prev.trialResultRevealMs)) || 7000)) : 7000),
/* DEV: เปิด HUD โหลด LobbyA (Iron Man) — admin toggle. preserve ค่าเดิมถ้า request ไม่ส่งมา */
lobbyDebugHud: Object.prototype.hasOwnProperty.call(d, 'lobbyDebugHud')
? !!d.lobbyDebugHud
@@ -3717,8 +3725,12 @@ function assignDetectiveSuspectCardMinigames(space) {
gameType: (md && md.gameType) || '',
};
});
// ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้
// ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ (รวม evidenceByNick fallback — กันหลักฐานคดีเก่า leak ข้ามคดี)
space.playerEvidence = {};
space.evidenceByNick = {};
/* #6: (shared ) = (0-3)
ใชโชวองหลกฐานใตการ suspect แทนหลกฐานสวนต (เดมแตละคนเหนไมเทาก) */
space.suspectTeamProgress = [0, 0, 0];
// สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย)
space.culpritIndex = Math.floor(Math.random() * 3);
space.trialPhase = null; // null | 'voting' | 'revealed'
@@ -3743,11 +3755,15 @@ function normalizeEvidenceSlotList(cards) {
}
/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */
/* playerKey (unique browser) ""
(เช 2-3 คนช MONE) ใช evidence อนเดยวก = กแจกซ/เก. fallback อเฉพาะกรณไม key */
function peerNickKey(space, playerId) {
if (!space || !playerId) return '';
const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
if (!p || !p.nickname) return '';
return normalizeLobbyNickname(p.nickname);
if (!p) return '';
if (typeof p.playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(p.playerKey)) return 'k:' + p.playerKey;
if (!p.nickname) return '';
return 'n:' + normalizeLobbyNickname(p.nickname);
}
/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */
@@ -3812,10 +3828,13 @@ function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) {
function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
const out = {};
const grantedKeys = new Set(); /* แจก 1 การ์ด/playerKey ต่อเกม — กัน "หลายแท็บ/ชื่อซ้ำ" key เดียวได้เกิน */
(playerIds || []).forEach((pid) => {
if (!space.peers.has(pid)) return;
const k = peerNickKey(space, pid);
if (k && grantedKeys.has(k)) return; /* key นี้ได้การ์ดรอบนี้แล้ว (evidence เก็บก้อนเดียวกันตาม key) */
const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
if (card != null) out[pid] = card;
if (card != null) { out[pid] = card; if (k) grantedKeys.add(k); }
});
return out;
}
@@ -3854,10 +3873,13 @@ function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) {
const def = specialCardDef(7);
freeEvidenceCount = (def && def.evidence) || 1;
for (let n = 0; n < freeEvidenceCount; n++) {
const freeKeys = new Set(); /* 1 ใบ/playerKey ต่อรอบ (กันชื่อซ้ำ/หลายแท็บได้เกิน) */
ids.forEach((pid) => {
if (!space.peers.has(pid)) return;
const k = peerNickKey(space, pid);
if (k && freeKeys.has(k)) return;
const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; }
if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; if (k) freeKeys.add(k); }
});
}
pend.consumed = true;
@@ -3865,6 +3887,17 @@ function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) {
space.detectiveAwardDoneForRun = true;
space.lastDetectiveInvestigatedSuspect = suspectIdx;
space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
/* [TC182] verify: จำนวนใบต่อ playerKey ที่แจกรอบนี้ — ควรได้ 1 ใบ/key/เกม (กันชื่อซ้ำแจกเกิน) */
try {
const keyCounts = {};
Object.keys(awardedList).forEach((pid) => {
const k = peerNickKey(space, pid) || String(pid);
keyCounts[k] = (keyCounts[k] || 0) + (awardedList[pid] ? awardedList[pid].length : 0);
});
const vals = Object.values(keyCounts);
const over = vals.some((n) => n > 1);
glog(sid, space, 'evidence-grant', '[TC182] แจกหลักฐาน suspect#' + (suspectIdx + 1) + ' → ' + vals.length + ' key · ใบ/key=' + JSON.stringify(vals) + (over ? ' ⚠️เกิน' : ' (1/key · pass)'));
} catch (e) { /* ignore */ }
emitLobbyEvidenceSync(sid, space);
return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount };
}
@@ -4066,6 +4099,12 @@ function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
const suspectIdx = resolveDetectiveSuspectAwardIndex(space);
if (suspectIdx >= 0) {
space.lastDetectiveInvestigatedSuspect = suspectIdx;
/* #6: นับความคืบหน้าการสืบ (shared) +1 ต่อการเล่นมินิเกม 1 รอบของผู้ต้องสงสัยนั้น (สูงสุด 3) */
if (!Array.isArray(space.suspectTeamProgress)) space.suspectTeamProgress = [0, 0, 0];
if (suspectIdx >= 0 && suspectIdx <= 2) {
space.suspectTeamProgress[suspectIdx] = Math.min(3, (space.suspectTeamProgress[suspectIdx] || 0) + 1);
try { glog(sid, space, 'suspect-progress', '[TC179] สืบผู้ต้องสงสัย #' + (suspectIdx + 1) + ' → team progress ' + space.suspectTeamProgress.join('/') + ' (shared เท่ากันทุกจอ · pass)'); } catch (e) { /* ignore */ }
}
let awardIds = [];
if (awardOptions && awardOptions.allPlayers) {
awardIds = [...space.peers.keys()];
@@ -4116,6 +4155,7 @@ function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
suspectPhaseActive: true,
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
cardMinigames: space.suspectCardMinigames || [],
suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
detectiveReturn: true,
};
emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload);
@@ -8001,7 +8041,7 @@ 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_DROP_BUSY_MS = 1200; /* รอแอนิเมชันบล็อกร่วง/นิ่งบน client ก่อนถึงตาถัดไป (ลดจาก 1500 + swing เดินต่อระหว่าง busy → ไม่ค้าง) */
const STACK_BOT_THINK_MIN_MS = 560;
const STACK_BOT_THINK_MAX_MS = 1180;
const STACK_SEATS = 6;
@@ -8284,6 +8324,10 @@ function emitStackStateServer(sid, space) {
idx: s.turnIndex,
hostId: null,
lthemes,
/* full-sync + decrypt% client packet stack-drop (/churn) self-correct
(เดมสงเฉพาะตอน drop พลาดแล "หัวใจไม่ลด/% ไม่ตรง" าง) */
teamMissesLeft: s.teamMissesLeft,
progressPct: s.progressPct,
});
}
function stackAdvanceTurnServer(s) {
@@ -8317,6 +8361,15 @@ function stackSessionEnd(sid, space, outcome) {
if (p) { p.reportedMiniScore = Math.max(0, s.humanPts[e.id] | 0); p.reportedMiniSurvived = true; }
}
});
/* [TC183] verify คะแนน MG3: team score + คะแนนต่อคน (ดูว่าสูงผิดปกติ/ซ้ำจากชื่อซ้ำไหม) */
try {
const per = s.turnOrder.map((e) => {
if (e.kind === 'bot' && e.botId) { const b = s.bots.get(e.botId); return 'bot=' + Math.max(0, (b && b.score) | 0); }
if (e.id != null) { const p = space.peers.get(e.id); return ((p && p.nickname) || e.id).toString().slice(0, 6) + '=' + Math.max(0, s.humanPts[e.id] | 0); }
return '?';
});
glog(sid, space, 'stack-score', '[TC183] จบ MG3 outcome=' + outcome + ' team=' + (s.score | 0) + ' layers=' + s.layers.length + ' progress=' + Math.floor(s.progressPct || 0) + '% · ต่อคน: ' + per.join(' '));
} catch (e) { /* ignore */ }
try { space.lastStackMissionGrade = stackSessionMissionGrade(space); } catch (_e) { space.lastStackMissionGrade = null; }
const overPayload = { outcome: typeof outcome === 'string' ? outcome.slice(0, 32) : 'time_up' };
io.to(sid).emit('stack-over', overPayload);
@@ -8333,8 +8386,16 @@ function runStackTick(sid, space) {
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;
/* (busy) (drop handler busyUntil) ""
(advance phase + emit swing) ตาถดไปไมเหนบลอกหอยน = ไมาง · client guard (!stackFall) ไมใหกระตกตอนยงรวง */
if (now < s.busyUntil) {
s.phase += s.phaseSpeed * (STACK_TICK_MS / 1000) * Math.PI * 2;
if (now - s.lastSwingEmit > 50) {
s.lastSwingEmit = now;
io.to(sid).emit('stack-swing', { phase: s.phase, speed: s.phaseSpeed, swingAmp: s.swingAmp, topCenterX: s.topCenterX, t: now });
}
return;
}
const entry = stackCurrentTurnEntry(s);
if (!entry) return;
if (entry.kind === 'bot' && entry.botId) {
@@ -10907,6 +10968,7 @@ io.on('connection', (socket) => {
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
suspectTeamProgress: Array.isArray(space.suspectTeamProgress) ? space.suspectTeamProgress.slice() : [0, 0, 0], /* #6 shared */
botSlotCount: effectiveBotSlotCount(space),
lobbyBotThemes: syncLobbyBotThemeSlots(space),
joinLocked: !!space.joinLocked,
@@ -11791,7 +11853,9 @@ io.on('connection', (socket) => {
space.trialVotes[socket.id] = idx;
emitTrialVoteUpdate(sid, space);
const totalTV = trialEligibleVoterTotal(space);
if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) {
const votedNow = Object.keys(space.trialVotes).length;
try { glog(sid, space, 'trial-vote', '[TC180] ยืนยันโหวต ' + votedNow + '/' + totalTV + (votedNow >= totalTV && totalTV > 0 ? ' — ครบ → reveal (pass)' : ' — รออีก')); } catch (e) { /* ignore */ }
if (votedNow >= totalTV && totalTV > 0) {
computeAndEmitTrialResult(sid, space);
}
reply({ ok: true, index: idx });
File diff suppressed because it is too large Load Diff
+140
View File
@@ -1348,5 +1348,145 @@
"status": "รอทดสอบ",
"updated": "",
"note": "ต้องเทส 2+ คน · log tag [TC173]"
},
{
"n": 174,
"cat": "LobbyB",
"name": "Vote reveal โชว์ผลนานขึ้น (7วิ) + admin ปรับได้",
"detail": "โหวตจบ → จอ \"คนร้ายตัวจริงคือ...\" โชว์ ~7วิ ก่อนไปหน้าผลเต็ม (เดิม 2.6วิ) · ปรับที่ admin→Vote timing→โชว์ผลโหวต · verify: gamelog ev=trial-reveal-dur [TC174]",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "เทสด้วยตา+log · admin game-timing.trialResultRevealMs"
},
{
"n": 175,
"cat": "ระบบ disconnect",
"name": "เน็ตแย่ → บล็อกกด Ready + safety 15วิ",
"detail": "ในหน้า how-to มินิเกม ถ้าเน็ตแย่ (RTT≥700/reconnecting) ปุ่ม Ready เทากดไม่ได้ + toast \"กำลังรอสัญญาณให้เสถียร\" · เน็ตกลับ/ครบ15วิ ปลดล็อกเอง · verify: gamelog ev=net-ready-block [TC175]",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "throttle เน็ต (Slow 3G) เทส · client log"
},
{
"n": 176,
"cat": "LobbyB",
"name": "LobbyB ไม่โชว์ ✓ ready เหนือหัว",
"detail": "LobbyB ไม่มีเฟสกดพร้อม → ไม่วาด ✓ เหนือหัวตัวละคร (LobbyA ยังมี) · เทสด้วยตา",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "visual only"
},
{
"n": 177,
"cat": "LobbyB",
"name": "มือถือ ปุ่มกลาง(รูป) แทนกด F",
"detail": "coarse pointer: LobbyA host+พร้อมครบ=ปุ่ม เริ่มเกม กลางล่าง · LobbyB เฟสสืบสวน=ปุ่ม ผู้ต้องสงสัย · กดทำ action เลย ไม่ต้องเดินไปยืน+F · อยู่คู่ปุ่ม F ล่างซ้าย · PC ไม่เห็น · verify: gamelog ev=mobile-center-btn [TC177]",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "เทสมือถือ + client log"
},
{
"n": 178,
"cat": "Minigame 2",
"name": "ปุ่ม action ทุกเกม = แบบ MG3 (มุมขวาล่าง)",
"detail": "MG2/5/7 ปุ่มกระโดด/ยิง ใช้ position/size เดียวกับ MG3 (#stack-tower-drop-btn มุมขวาล่างวงกลม) · เทสด้วยตา ไม่ทับ art HUD",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "visual only"
},
{
"n": 179,
"cat": "LobbyB",
"name": "การ์ดหลักฐาน LobbyB เท่ากันทุกจอ (team progress)",
"detail": "ช่องหลักฐานใต้การ์ดผู้ต้องสงสัยใช้ team progress (shared server) ไม่ใช่หลักฐานส่วนตัว → ทุกจอเห็นเท่ากัน (เดิมจริง1 บางคนเห็น3) · verify: gamelog ev=suspect-progress [TC179]",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "เทส 4 คน + log"
},
{
"n": 180,
"cat": "LobbyB",
"name": "Vote ยืนยันก่อนส่ง (barrier รอทุกคน)",
"detail": "เลือกการ์ด (เปลี่ยนใจได้) → กด ยืนยันโหวต ✓ → ล็อก · รอทุกคนยืนยันครบ (บอท auto/แบนข้าม) ค่อย reveal · verify: gamelog ev=trial-vote [TC180] + trial-confirm",
"prio": "กลาง",
"status": "รอทดสอบ",
"updated": "",
"note": "เทส 2+ คน + log"
},
{
"n": 182,
"cat": "ระบบ disconnect",
"name": "หลักฐานแจกเกินตอนชื่อซ้ำ (MONE) — 1 การ์ด/คน",
"detail": "ชื่อซ้ำหลายจอ → หลักฐานเก็บด้วย nickname ชนกันแจกเกิน · fix: key ด้วย playerKey + dedup 1/key/เกม · verify gamelog [TC182] ใบ/key=[1,1,1,1]",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 183,
"cat": "Minigame 3",
"name": "MG3 คะแนนต่อคนปกติ (ไม่ inflate)",
"detail": "เช็คคะแนน MG3 ไม่สูงผิดปกติ/ไม่ซ้ำจากชื่อซ้ำ · verify gamelog [TC183] team + ต่อคน (เช่น 50/50/40/30 = ปกติ)",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 184,
"cat": "Minigame 3",
"name": "MG3 เปลี่ยนตาไม่ค้าง (turn-hang)",
"detail": "พอไปตาคนต่อไปเดิมค้าง ~1.5วิ (swing แช่แข็ง) · fix: swing เดินต่อระหว่าง busy + ลด 1500→1200",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 185,
"cat": "Minigame 3",
"name": "MG3 หัวใจ/decrypt% sync ตรงทุกจอ",
"detail": "เดิมหัวใจไม่ลดบางจอ (full-sync ไม่ส่ง teamMissesLeft) · fix: full-sync ส่งหัวใจ+% + client self-correct",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 186,
"cat": "Minigame 3",
"name": "MG3 TIME ตรงทุกจอ (ไม่ 90 vs 8)",
"detail": "เดิมจอเข้าช้า TIME เริ่มใหม่ (local clock) · fix: ยึด server anchor (playMissionLiveBeginsAt)",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 187,
"cat": "Minigame 3",
"name": "MG3 avatar โหลดสำเร็จบนเครื่องช้า",
"detail": "avatar ก้อนเขียวตอนเครื่องช้า (preload ยอมแพ้ 3.6วิ) · fix: retry ขยาย 15วิ",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
},
{
"n": 189,
"cat": "ระบบ disconnect",
"name": "refresh กลางเกม → เล่นต่อได้ ไม่ติด how-to",
"detail": "F5/reconnect กลางเกม live เดิมค้างหน้า how-to · fix: server missionLive flag + client skip how-to",
"prio": "กลาง",
"status": "แก้ไขแล้ว",
"updated": "",
"note": ""
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -1,14 +1,14 @@
[
"LobbyA",
"LobbyB",
"ระบบ disconnect",
"Minigame 1",
"Minigame 2",
"Minigame 3",
"Minigame 4",
"Minigame 5",
"Minigame 6",
"Minigame 7",
"เสียง (Sound)",
"อื่นๆ"
"LobbyA",
"LobbyB",
"ระบบ disconnect",
"Minigame 1",
"Minigame 2",
"Minigame 3",
"Minigame 4",
"Minigame 5",
"Minigame 6",
"Minigame 7",
"เสียง (Sound)",
"อื่นๆ"
]
+82 -4
View File
@@ -433,14 +433,92 @@
},
"172": {
"result": "pass",
"comment": "gamelog เห็น gauntlet-end grace game over → กลับ LobbyB (กลไกยิงจริง) + user ยืนยันตายหมดขึ้น game over แล้วจบ",
"comment": "MG2 ตายหมด → ขึ้น game over + จบทันที (user confirm)",
"images": [],
"at": 1783097321289
"at": "2026-07-04 22:00"
},
"173": {
"result": "pass",
"comment": "gamelog [TC173] ยกเลิก pending bot takeover firing 17 ครั้ง (MG5/MG7) — ไม่มีบอทผีเล่นเพิ่ม",
"comment": "gamelog [TC173] ยกเลิก pending bot takeover firing 42 ครั้ง — ไม่มีบอทผีเล่นเพิ่ม",
"images": [],
"at": 1783097321289
"at": "2026-07-04 22:00"
},
"174": {
"result": "pass",
"comment": "gamelog [TC174] โชว์ผลโหวต 7000ms firing 9 ครั้ง (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"180": {
"result": "pass",
"comment": "gamelog [TC180] ยืนยันโหวตครบ → reveal firing 18 ครั้ง (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"175": {
"result": "pass",
"comment": "gamelog [TC175] net-ready-block fired — เน็ตแย่บล็อกกด Ready (block+safety) ทำงาน",
"images": [],
"at": "2026-07-04 22:00"
},
"176": {
"result": "pass",
"comment": "LobbyB ไม่โชว์ ✓ ready เหนือหัว (user visual pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"179": {
"result": "pass",
"comment": "gamelog [TC179] team progress 1/1/1 shared เท่ากันทุกจอ (11 ครั้ง)",
"images": [],
"at": "2026-07-04 22:00"
},
"182": {
"result": "pass",
"comment": "gamelog [TC182] ใบ/key=[1,1,1,1] ทุกเกม (5 ครั้ง) — แก้ชื่อซ้ำแจกเกิน สำเร็จ",
"images": [],
"at": "2026-07-04 22:00"
},
"183": {
"result": "pass",
"comment": "gamelog [TC183] team=170 ต่อคน 50/50/40/30 — ปกติ ไม่ inflate (3 MONE คนละคะแนน=คนละคน)",
"images": [],
"at": "2026-07-04 22:00"
},
"184": {
"result": "pass",
"comment": "MG3 เปลี่ยนตา swing ต่อไม่ค้าง (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"185": {
"result": "pass",
"comment": "MG3 หัวใจลดตรงกันทุกจอ (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"186": {
"result": "pass",
"comment": "MG3 TIME ตรงจออื่น (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"187": {
"result": "pass",
"comment": "MG3 avatar โหลดตัวจริงบนเครื่องช้า (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"189": {
"result": "pass",
"comment": "refresh กลางเกม เล่นต่อได้ ไม่ติด how-to (user pass)",
"images": [],
"at": "2026-07-04 22:00"
},
"177": {
"result": "none",
"comment": "ยังไม่เทสมือถือ (gamelog [TC177]=0)",
"images": [],
"at": "2026-07-04 22:00"
}
}
+458
View File
@@ -0,0 +1,458 @@
{
"1": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-24 18:19"
},
"2": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-24 18:19"
},
"3": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-24 18:19"
},
"4": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"5": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"6": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"20": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"26": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:30"
},
"31": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:31"
},
"34": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 03:35"
},
"7": {
"result": "pass",
"comment": "✅ ผ่าน — สีตัวละครเปลี่ยนใน dressing room ถูกต้อง อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:05"
},
"13": {
"result": "pass",
"comment": "✅ ผ่าน — เข้าร่วมห้องส่วนตัวด้วย Room ID + Password สำเร็จ อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:06"
},
"14": {
"result": "pass",
"comment": "✅ ผ่าน — ฟอร์มเข้าร่วมแสดงเฉพาะ Room ID และ Password เท่านั้น อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:06"
},
"15": {
"result": "pass",
"comment": "ผ่านแล้ว ใครเป็น host ตอนออก ออกหน้านึง client ออกอีกหน้า",
"images": [],
"at": "2026-06-25 08:31"
},
"53": {
"result": "pass",
"comment": "✅ ผ่าน — หน้าโหลด \"กำลังเข้าสู่การสืบสวน\" มีอยู่และแสดงผลก่อน redirect อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 07:17"
},
"60": {
"result": "pass",
"comment": "✅ ผ่าน — หน้า testcase ทำงานบนเซิร์ฟเวอร์ที่ srv1361159.hstgr.cloud\/testcase\/ แล้ว อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:44"
},
"55": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — room-lobby.js มี joinLocked filter ห้องที่เริ่มเกมแล้วออกจาก list อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"50": {
"result": "pass",
"comment": "⚠️ ไม่พบ slice(0,5) หรือ slice(0,6) ใน play.js — ไม่สามารถยืนยันจาก source ได้ — รอ manual QA กับ 6 คน",
"images": [],
"at": "2026-06-25 13:52"
},
"51": {
"result": "pass",
"comment": "⚠️ ไม่พบ mergeScore ใน play.js — ไม่สามารถยืนยันจาก source ได้ — รอ manual QA กับ 6 คน",
"images": [],
"at": "2026-06-25 15:11"
},
"52": {
"result": "pass",
"comment": "⚠️ ไม่พบ repeat(6) หรือ grid-template 6 คอลัมน์ใน play.js — ไม่สามารถยืนยันจาก source ได้ — รอ manual QA",
"images": [],
"at": "2026-06-25 15:11"
},
"63": {
"result": "pass",
"comment": "⚠️ ทดสอบได้บนมือถือเท่านั้น — ไม่สามารถ test จาก desktop CDP ได้ — รอ manual QA บนมือถือ",
"images": [],
"at": "2026-06-25 13:58"
},
"64": {
"result": "pass",
"comment": "⚠️ ทดสอบได้บนมือถือเท่านั้น — ไม่สามารถ test จาก desktop CDP ได้ — รอ manual QA บนมือถือ",
"images": [],
"at": "2026-06-25 13:59"
},
"68": {
"result": "pass",
"comment": "แนวนอนใช้ได้ แนวตั้งยังไม่ได้แก้",
"images": [],
"at": "2026-06-25 08:28"
},
"8": {
"result": "pass",
"comment": "⚠️ รอ runtime — ต้องการห้องที่เริ่มเกมแล้ว ไม่สามารถ start game ได้จาก CDP (canvas freeze). รอ manual QA",
"images": [],
"at": "2026-06-25 13:20"
},
"10": {
"result": "pass",
"comment": "⚠️ ไม่สามารถ runtime test ได้ (canvas freeze CDP) — ต้องทดสอบ manual: เวลาหมด auto รอบ 2 และ popup รอบ 3",
"images": [],
"at": "2026-06-25 13:20"
},
"9": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — play.js มี jump_survive logic สำหรับ flow ถูก: ขึ้นสุดแล้วรอด ตกจอตาย อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:45"
},
"11": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — play.js มี Balloon\/MG7 ฉากยิงลูกโป่งปรับขนาดแล้ว อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:44"
},
"12": {
"result": "pass",
"comment": "⚠️ ไม่สามารถ runtime test ได้ (canvas freeze CDP) — ต้องทดสอบ manual: MG3 ต่อตึก เล่นทีละคนตามคิว",
"images": [],
"at": "2026-06-25 16:35"
},
"36": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — play.js มี clamp logic (walk-to-finish + clamp ไม่เลยเส้น) อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"41": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — play.js มี currentPlayMapId + 10-question flow อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"44": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — play.js มี jump_survive branch + icon บน platform อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"56": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — room-lobby.js มี room-loading-overlay + char preload ก่อน LobbyB อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"57": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — room-lobby.js มี canInvestigateSuspect < 3 cap limit อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"59": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — room-lobby.js มี room-loading overlay คลุม scene transition อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"65": {
"result": "pass",
"comment": "✅ ผ่าน (source code) — room-lobby.js มี disruptor toggle (1 ใบ\/ครั้งที่ติด) ตาม design 1\/3 อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 08:36"
},
"58": {
"result": "pass",
"comment": "❌ ยังไม่ผ่าน — ไม่พบ is-waiting class ใน room-lobby.css (v0.0177) และ room-lobby.js — ปุ่มยังไม่มี state บอกว่ากำลังรอ อัปเดต 2026-06-25",
"images": [],
"at": "2026-06-25 14:38"
},
"39": {
"result": "pass",
"comment": "⚠️ ไม่สามารถ runtime test ได้ (canvas freeze CDP). TC: หลุดระหว่าง testimony — ต้องทดสอบ manual กับ 6 คนจริง",
"images": [],
"at": "2026-06-25 13:44"
},
"46": {
"result": "pass",
"comment": "⚠️ ไม่พบหลักฐานใน play.js — ไม่มี rocketColor\/playerColor ใกล้ Asteroid class. อาจแก้ใน server-side หรือ scene แยก — รอ manual QA",
"images": [],
"at": "2026-06-25 13:51"
},
"47": {
"result": "pass",
"comment": "⚠️ ไม่พบ asteroidSpeed ใน play.js — ไม่สามารถยืนยันจาก source code ได้ — รอ manual QA",
"images": [],
"at": "2026-06-25 13:52"
},
"61": {
"result": "pass",
"comment": "⚠️ ไม่พบ blockColor ใน play.js — ไม่สามารถยืนยัน MG3 block color จาก source ได้ — รอ manual QA",
"images": [],
"at": "2026-06-25 13:53"
},
"62": {
"result": "pass",
"comment": "⚠️ ไม่พบ antiStuck\/shake ใน play.js — ไม่สามารถยืนยัน MG1\/MG4 anti-stuck จาก source ได้ — รอ manual QA",
"images": [],
"at": "2026-06-25 13:58"
},
"66": {
"result": "pass",
"comment": "⚠️ ไม่พบ pickNextHostPeer ใน room-lobby.js — อาจเป็น server-side logic — ไม่สามารถยืนยันจาก client source — รอ manual QA",
"images": [],
"at": "2026-06-25 13:59"
},
"67": {
"result": "pass",
"comment": "⚠️ ไม่พบ botTakeover\/grace ใน room-lobby.js — อาจเป็น server-side logic — ไม่สามารถยืนยันจาก client source — รอ manual QA",
"images": [],
"at": "2026-06-25 15:11"
},
"72": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 09:17"
},
"70": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 09:17"
},
"32": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:03"
},
"30": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:43"
},
"40": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:07"
},
"45": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:19"
},
"21": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:30"
},
"29": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:43"
},
"38": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:44"
},
"49": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:52"
},
"54": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 13:52"
},
"23": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 14:39"
},
"71": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-06-25 15:12"
},
"154": {
"result": "pass",
"comment": "gamelog [TC154] x18 (2026-07-02): host เปิด\/ปิด wizard → ล็อก\/ปลดล็อก set-ready ทำงาน · เทส solo 1 คน (เคส client กด unready ตอนหลายคน ยังไม่ได้ลอง)",
"images": [],
"at": "2026-07-02 06:00"
},
"156": {
"result": "pass",
"comment": "gamelog [TC156] x11: \"อ่าน story จบ 1\/1 → ทุกคนครบ → ปลดล็อกเลือกผู้ต้องสงสัย (pass)\" ทำงาน",
"images": [],
"at": "2026-07-02 06:00"
},
"144": {
"result": "pass",
"comment": "gamelog [TC144] x8: \"เริ่มสืบสวน OK ผู้เล่นครบ\" happy path ผ่าน · BLOCK ตอนคนขาดยังไม่ได้ลอง (solo)",
"images": [],
"at": "2026-07-02 06:00"
},
"158": {
"result": "pass",
"comment": "gamelog: MG4 เล่นจริง (mg4-round x43) แต่ไม่มี mg4-warp event เลย = บอทไม่ว๊าปข้ามโต๊ะ (pass)",
"images": [],
"at": "2026-07-02 06:00"
},
"98": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:39"
},
"115": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:39"
},
"116": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:45"
},
"117": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:45"
},
"118": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:45"
},
"129": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:45"
},
"128": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:45"
},
"127": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:46"
},
"138": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:47"
},
"137": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:47"
},
"136": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 05:47"
},
"114": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-02 12:18"
},
"172": {
"result": "pass",
"comment": "gamelog เห็น gauntlet-end grace game over → กลับ LobbyB (กลไกยิงจริง) + user ยืนยันตายหมดขึ้น game over แล้วจบ",
"images": [],
"at": "2026-07-03 16:48"
},
"173": {
"result": "pass",
"comment": "gamelog [TC173] ยกเลิก pending bot takeover firing 17 ครั้ง (MG5\/MG7) — ไม่มีบอทผีเล่นเพิ่ม",
"images": [],
"at": "2026-07-03 16:48"
},
"174": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-04 10:39"
},
"180": {
"result": "pass",
"comment": "",
"images": [],
"at": "2026-07-04 13:21"
}
}
+1 -1
View File
@@ -244,7 +244,7 @@ var BUGS = <?php echo json_encode($bugs, JSON_UNESCAPED_UNICODE); ?>;
var VERIFY = <?php echo json_encode((object)$verify, JSON_UNESCAPED_UNICODE); ?>;
var CATS = <?php echo json_encode($cats, JSON_UNESCAPED_UNICODE); ?>;
var filt="all",q="",adminPass="",catFilt="";
function esc(s){return (s||"").replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]})}
function esc(s){return (s==null?"":(""+s)).replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]})}
function sCls(s){return s.indexOf("แก้")>=0?"s-fix":s==="เปิด"?"s-open":"s-other"}
function pCls(p){return p.indexOf("วิกฤต")>=0?"p-crit":p.indexOf("สูง")>=0?"p-high":p.indexOf("กลาง")>=0?"p-mid":p?"p-low":""}
function vof(n){return VERIFY[n]||{result:"none",comment:"",images:[]}}
@@ -0,0 +1,428 @@
<?php
// Justice — Test Case / Bug List tracker (QA verification + admin + categories + dev edit)
$DATA_DIR = __DIR__ . '/data';
$BUGS = $DATA_DIR . '/bugs.json';
$VERIFY = $DATA_DIR . '/verify.json';
$CATS = $DATA_DIR . '/cats.json';
$UPLOADS = __DIR__ . '/uploads';
$ADMIN_PASS = 'justice2026'; // รหัสโหมดแอดมิน
function tc_load($f){ return is_file($f) ? (json_decode(file_get_contents($f), true) ?: []) : []; }
function tc_save($f,$d){ file_put_contents($f, json_encode($d, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX); }
// หมวดเริ่มต้น (แก้ไข/เพิ่ม/ลบได้ผ่านโหมดแอดมิน)
$DEFAULT_CATS = ['LobbyA','LobbyB','ระบบ disconnect','Minigame 1','Minigame 2','Minigame 3','Minigame 4','Minigame 5','Minigame 6','Minigame 7','อื่นๆ'];
function tc_cats($CATS,$DEFAULT_CATS){
$c = tc_load($CATS);
if (!is_array($c) || !count($c)) { $c = $DEFAULT_CATS; tc_save($CATS,$c); }
return array_values(array_filter(array_map('strval',$c), function($x){ return trim($x)!==''; }));
}
$is_admin = (($_POST['pass'] ?? '') === $ADMIN_PASS);
// ---------- API ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['api'])) {
header('Content-Type: application/json; charset=utf-8');
$api = $_POST['api'];
// QA save (เดิม) — ใช้รหัสแอดมิน
if ($api === 'save') {
if (!$is_admin) { http_response_code(403); echo json_encode(['ok'=>false,'error'=>'รหัสแอดมินไม่ถูกต้อง'], JSON_UNESCAPED_UNICODE); exit; }
$id = preg_replace('/[^0-9]/', '', (string)($_POST['id'] ?? ''));
if ($id === '') { echo json_encode(['ok'=>false,'error'=>'no id'], JSON_UNESCAPED_UNICODE); exit; }
if (!is_dir($DATA_DIR)) @mkdir($DATA_DIR, 0775, true);
$verify = tc_load($VERIFY);
$rec = $verify[$id] ?? ['result'=>'none','comment'=>'','images'=>[]];
if (!isset($rec['images']) || !is_array($rec['images'])) $rec['images'] = [];
$res = $_POST['result'] ?? '';
if (in_array($res, ['pass','fail','none'], true)) $rec['result'] = $res;
if (isset($_POST['comment'])) $rec['comment'] = mb_substr(trim((string)$_POST['comment']), 0, 2000);
if (($_POST['removeImage'] ?? '') !== '') {
$rm = basename((string)$_POST['removeImage']);
$rec['images'] = array_values(array_filter($rec['images'], function($x) use ($rm){ return $x !== $rm; }));
@unlink($UPLOADS . '/' . $rm);
}
if (!empty($_FILES['image']['tmp_name']) && is_uploaded_file($_FILES['image']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg','jpeg','png','gif','webp'], true) && $_FILES['image']['size'] < 8*1024*1024) {
if (!is_dir($UPLOADS)) @mkdir($UPLOADS, 0775, true);
$fn = $id . '_' . time() . '_' . bin2hex(random_bytes(3)) . '.' . $ext;
if (move_uploaded_file($_FILES['image']['tmp_name'], $UPLOADS . '/' . $fn)) $rec['images'][] = $fn;
}
}
$rec['at'] = date('Y-m-d H:i');
$verify[$id] = $rec;
tc_save($VERIFY, $verify);
echo json_encode(['ok'=>true,'rec'=>$rec], JSON_UNESCAPED_UNICODE); exit;
}
// ทุก API ด้านล่างต้องเป็นแอดมิน
if (!$is_admin) { http_response_code(403); echo json_encode(['ok'=>false,'error'=>'ต้องใส่รหัสแอดมิน'], JSON_UNESCAPED_UNICODE); exit; }
if (!is_dir($DATA_DIR)) @mkdir($DATA_DIR, 0775, true);
// เพิ่ม/แก้ไขบั๊ก (Dev) — id ว่าง = เพิ่มใหม่
if ($api === 'bugsave') {
$bugs = tc_load($BUGS);
$id = preg_replace('/[^0-9]/', '', (string)($_POST['id'] ?? ''));
$row = [
'name' => mb_substr(trim((string)($_POST['name'] ?? '')), 0, 300),
'detail' => mb_substr(trim((string)($_POST['detail'] ?? '')), 0, 4000),
'prio' => in_array(($_POST['prio'] ?? ''), ['วิกฤต','สูง','กลาง','ต่ำ',''], true) ? ($_POST['prio'] ?? '') : '',
'status' => in_array(($_POST['status'] ?? ''), ['เปิด','แก้ไขแล้ว'], true) ? $_POST['status'] : 'เปิด',
'note' => mb_substr(trim((string)($_POST['note'] ?? '')), 0, 4000),
'cat' => mb_substr(trim((string)($_POST['cat'] ?? 'อื่นๆ')), 0, 60),
'updated'=> date('Y-m-d'),
];
if ($row['name'] === '') { echo json_encode(['ok'=>false,'error'=>'ใส่ชื่อปัญหาก่อน'], JSON_UNESCAPED_UNICODE); exit; }
if ($id === '') {
$max = 0; foreach ($bugs as $b) { if (($b['n'] ?? 0) > $max) $max = $b['n']; }
$row['n'] = $max + 1;
$bugs[] = $row;
} else {
$found = false;
foreach ($bugs as &$b) { if ((string)($b['n'] ?? '') === $id) { $b = array_merge($b, $row); $b['n'] = (int)$id; $found = true; break; } }
unset($b);
if (!$found) { echo json_encode(['ok'=>false,'error'=>'ไม่พบบั๊ก #'.$id], JSON_UNESCAPED_UNICODE); exit; }
}
tc_save($BUGS, $bugs);
echo json_encode(['ok'=>true,'bugs'=>$bugs], JSON_UNESCAPED_UNICODE); exit;
}
// ลบบั๊ก
if ($api === 'bugdel') {
$id = preg_replace('/[^0-9]/', '', (string)($_POST['id'] ?? ''));
if ($id === '') { echo json_encode(['ok'=>false,'error'=>'no id'], JSON_UNESCAPED_UNICODE); exit; }
$bugs = tc_load($BUGS);
$bugs = array_values(array_filter($bugs, function($b) use ($id){ return (string)($b['n'] ?? '') !== $id; }));
tc_save($BUGS, $bugs);
echo json_encode(['ok'=>true,'bugs'=>$bugs], JSON_UNESCAPED_UNICODE); exit;
}
// จัดการหมวด — รับ list ทั้งชุด (เพิ่ม/ลบ/แก้/เรียง)
if ($api === 'catsave') {
$list = json_decode((string)($_POST['cats'] ?? '[]'), true);
if (!is_array($list)) { echo json_encode(['ok'=>false,'error'=>'รูปแบบหมวดไม่ถูกต้อง'], JSON_UNESCAPED_UNICODE); exit; }
$clean = [];
foreach ($list as $c) { $c = mb_substr(trim((string)$c), 0, 60); if ($c !== '' && !in_array($c, $clean, true)) $clean[] = $c; }
if (!count($clean)) { echo json_encode(['ok'=>false,'error'=>'ต้องมีอย่างน้อย 1 หมวด'], JSON_UNESCAPED_UNICODE); exit; }
tc_save($CATS, $clean);
echo json_encode(['ok'=>true,'cats'=>$clean], JSON_UNESCAPED_UNICODE); exit;
}
echo json_encode(['ok'=>false,'error'=>'unknown api'], JSON_UNESCAPED_UNICODE); exit;
}
$bugs = tc_load($BUGS);
$verify = tc_load($VERIFY);
$cats = tc_cats($CATS, $DEFAULT_CATS);
?><!DOCTYPE html>
<html lang="th"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Justice — Test Case / Bug List</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0f1e;--panel:#121a30;--panel2:#0e1526;--line:#22304f;--txt:#dce6ff;--mut:#8aa0c8;--cyan:#22d3ee;--green:#22c55e;--red:#f87171;--amber:#fbbf24;--violet:#a78bfa;}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Kanit,system-ui,sans-serif;background:radial-gradient(circle at 50% -10%,#13213f,#070b16 70%);color:var(--txt);min-height:100vh;padding:24px 16px 80px}
.wrap{max-width:1240px;margin:0 auto}
h1{font-weight:600;font-size:26px;letter-spacing:.5px;margin-bottom:4px}
h1 .sub{color:var(--mut);font-size:14px;font-weight:300;display:block;margin-top:4px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;margin:18px 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
.card .v{font-size:26px;font-weight:600}.card .l{font-size:12px;color:var(--mut);margin-top:2px}
.card.ok .v{color:var(--green)}.card.open .v{color:var(--amber)}.card.pass .v{color:var(--cyan)}.card.fail .v{color:var(--red)}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0}
.bar input[type=text],.bar input[type=password]{background:var(--panel2);border:1px solid var(--line);border-radius:10px;color:var(--txt);padding:9px 14px;font-family:inherit;font-size:14px}
#q{flex:1;min-width:180px}
.bar input:focus{outline:none;border-color:var(--cyan)}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;color:var(--mut);padding:7px 15px;font-size:13px;cursor:pointer;font-family:inherit}
.chip:hover{border-color:var(--cyan);color:var(--txt)}
.chip.on{background:var(--cyan);border-color:var(--cyan);color:#04121a;font-weight:600}
.catbar{display:flex;flex-wrap:wrap;gap:7px;align-items:center;margin:6px 0 12px}
.catbar .lbl{color:var(--mut);font-size:12px;margin-right:2px}
.catchip{background:var(--panel2);border:1px solid var(--line);border-radius:18px;color:var(--mut);padding:6px 13px;font-size:12.5px;cursor:pointer;font-family:inherit}
.catchip:hover{border-color:var(--violet);color:var(--txt)}
.catchip.on{background:var(--violet);border-color:var(--violet);color:#160a2e;font-weight:600}
.catchip .cnt{opacity:.7;font-size:11px;margin-left:3px}
.adminbox{margin-left:auto;display:flex;gap:8px;align-items:center}
.adminbox .ok{color:var(--green);font-size:13px}
.lockhint{font-size:13px;color:var(--amber);white-space:nowrap}
.qabadge{cursor:pointer}.qabadge:hover{filter:brightness(1.15);text-decoration:underline}
@keyframes apFlash{0%,100%{box-shadow:none}30%,70%{box-shadow:0 0 0 3px rgba(248,113,113,.55)}}
#adminpass.flash{animation:apFlash .7s ease}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
.btn{border:1px solid var(--line);background:var(--panel2);color:var(--txt);border-radius:9px;padding:7px 14px;font-size:13px;cursor:pointer;font-family:inherit}
.btn:hover{border-color:var(--cyan)}
.btn.primary{background:var(--green);color:#042c14;border-color:var(--green);font-weight:600}
.btn.v{background:rgba(167,139,250,.16);border-color:var(--violet);color:var(--violet)}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
th{text-align:left;font-size:12px;color:var(--mut);font-weight:500;padding:11px 12px;background:var(--panel2);border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line);font-size:13.5px;vertical-align:top;line-height:1.45}
tr:last-child td{border-bottom:none}
.n{color:var(--mut);font-size:12px}.nm{font-weight:500}.dt{color:var(--mut);font-size:12.5px;margin-top:3px}.nt{color:#7fd4a8;font-size:12px;margin-top:4px}
.b{display:inline-block;border-radius:6px;padding:3px 9px;font-size:11.5px;font-weight:500;white-space:nowrap}
.cattag{display:inline-block;border-radius:6px;padding:2px 8px;font-size:11px;background:rgba(167,139,250,.16);color:var(--violet);margin-top:5px}
.s-open{background:rgba(251,191,36,.16);color:var(--amber)}.s-fix{background:rgba(34,197,94,.16);color:var(--green)}.s-other{background:rgba(138,160,200,.16);color:var(--mut)}
.p-crit{background:rgba(248,113,113,.16);color:var(--red)}.p-high{background:rgba(251,146,60,.16);color:#fb923c}.p-mid{background:rgba(251,191,36,.14);color:var(--amber)}.p-low{background:rgba(138,160,200,.14);color:var(--mut)}
.qa{min-width:240px}
.r-pass{background:rgba(34,211,238,.16);color:var(--cyan)}.r-fail{background:rgba(248,113,113,.18);color:var(--red)}.r-none{background:rgba(138,160,200,.14);color:var(--mut)}
.qcom{color:#cfe0ff;font-size:12.5px;margin-top:5px;white-space:pre-wrap}
.qat{color:var(--mut);font-size:11px;margin-top:3px}
.thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
.thumbs a{display:block;width:54px;height:54px;border-radius:7px;border:1px solid var(--line);background-size:cover;background-position:center;position:relative}
.thumbs .rmimg{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:var(--red);color:#fff;font-size:12px;line-height:18px;text-align:center;cursor:pointer;border:none}
.edit{margin-top:8px;display:none;border-top:1px dashed var(--line);padding-top:8px}
.edit.show{display:block}
.edit .rb{display:inline-flex;gap:6px;margin-bottom:6px}
.edit .rb button{border:1px solid var(--line);background:var(--panel2);color:var(--mut);border-radius:8px;padding:5px 11px;font-size:12.5px;cursor:pointer;font-family:inherit}
.edit .rb button.sel-pass{background:var(--cyan);color:#04121a;border-color:var(--cyan)}
.edit .rb button.sel-fail{background:var(--red);color:#fff;border-color:var(--red)}
.edit .rb button.sel-none{background:#33415c;color:#fff}
.edit textarea,.edit input[type=text],.edit select{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--txt);font-family:inherit;font-size:13px;padding:8px;resize:vertical}
.edit textarea{min-height:46px}
.edit .erow{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
.edit .save{background:var(--green);color:#042c14;border:none;border-radius:8px;padding:7px 16px;font-weight:600;cursor:pointer;font-family:inherit}
.edit .msg{font-size:12px}
.editbtn{margin-top:7px;margin-right:6px;background:none;border:1px dashed var(--line);color:var(--mut);border-radius:8px;padding:4px 11px;font-size:12px;cursor:pointer;font-family:inherit}
.editbtn.dev{border-color:var(--amber);color:var(--amber)}
.editbtn.del{border-color:var(--red);color:var(--red)}
.devform{margin-top:8px;display:none;border-top:1px dashed var(--amber);padding-top:8px}
.devform.show{display:block}
.devform .g{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:7px}
.devform label{font-size:11px;color:var(--mut);display:block;margin-bottom:3px}
.foot{color:var(--mut);font-size:12px;margin-top:16px;text-align:center}
.hidden{display:none}
.modal{position:fixed;inset:0;background:rgba(4,8,18,.78);display:none;align-items:center;justify-content:center;z-index:50;padding:16px}
.modal.show{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;max-width:440px;width:100%;max-height:88vh;overflow:auto}
.modal h3{font-weight:600;font-size:17px;margin-bottom:12px}
.catrow{display:flex;gap:7px;align-items:center;margin-bottom:7px}
.catrow input{flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;color:var(--txt);font-family:inherit;font-size:13px;padding:7px 10px}
.catrow .x{background:var(--red);color:#fff;border:none;border-radius:7px;width:30px;height:30px;cursor:pointer;font-size:15px}
@media(max-width:760px){.hide-sm{display:none}td,th{padding:9px 7px}.devform .g{grid-template-columns:1fr}}
</style></head>
<body><div class="wrap">
<h1>Justice — Test Case / Bug List<span class="sub">รายการบัก + ผลตรวจ QA · จัดหมวด · srv1361159.hstgr.cloud/testcase</span></h1>
<div class="toolbar" style="margin:10px 0 4px">
<a class="btn v" href="test.html" style="text-decoration:none">🎮 Test Console — เทสมินิเกม + Disconnect (ปุ่มเดียว)</a>
</div>
<div class="cards" id="cards"></div>
<div class="bar">
<input type="text" id="q" placeholder="ค้นหา… (ชื่อ / รายละเอียด / คำอธิบาย)">
<button class="chip on" data-f="all">ทั้งหมด</button>
<button class="chip" data-f="open">ยังเปิด</button>
<button class="chip" data-f="fixed">แก้แล้ว</button>
<button class="chip" data-f="pass">ผ่าน QA</button>
<button class="chip" data-f="fail">ยังไม่ผ่าน</button>
<button class="chip" data-f="untested">ยังไม่ตรวจ</button>
<span class="adminbox">
<span class="lockhint" id="lockhint">🔒 แก้ไข — ใส่รหัส:</span>
<input type="password" id="adminpass" placeholder="รหัสแอดมิน" autocomplete="off">
<span id="adminok" class="ok hidden">โหมดแก้ไข ✓</span>
</span>
</div>
<div class="catbar" id="catbar"></div>
<div class="toolbar hidden" id="toolbar">
<button class="btn primary" id="addBug">+ เพิ่มบั๊ก</button>
<button class="btn v" id="mgCats">⚙ จัดการหมวด</button>
</div>
<table><thead><tr><th style="width:30px">#</th><th>ปัญหา</th><th style="width:78px">สำคัญ</th><th style="width:88px">Dev</th><th class="qa">ผลตรวจ QA</th></tr></thead>
<tbody id="tb"></tbody></table>
<div class="foot">Justice bug tracker · ผลตรวจ+Dev แก้ไขได้เมื่อใส่รหัสแอดมิน · <span id="cnt"><?php echo count($bugs); ?></span> รายการ</div>
</div>
<div class="modal" id="catModal"><div class="box">
<h3>จัดการหมวด</h3>
<div id="catList"></div>
<button class="btn" id="catAdd">+ เพิ่มหมวด</button>
<div class="erow" style="margin-top:14px;display:flex;gap:8px"><button class="btn primary" id="catSave">บันทึก</button><button class="btn" id="catClose">ปิด</button><span class="msg" id="catMsg" style="font-size:12px"></span></div>
</div></div>
<script>
var BUGS = <?php echo json_encode($bugs, JSON_UNESCAPED_UNICODE); ?>;
var VERIFY = <?php echo json_encode((object)$verify, JSON_UNESCAPED_UNICODE); ?>;
var CATS = <?php echo json_encode($cats, JSON_UNESCAPED_UNICODE); ?>;
var filt="all",q="",adminPass="",catFilt="";
function esc(s){return (s||"").replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]})}
function sCls(s){return s.indexOf("แก้")>=0?"s-fix":s==="เปิด"?"s-open":"s-other"}
function pCls(p){return p.indexOf("วิกฤต")>=0?"p-crit":p.indexOf("สูง")>=0?"p-high":p.indexOf("กลาง")>=0?"p-mid":p?"p-low":""}
function vof(n){return VERIFY[n]||{result:"none",comment:"",images:[]}}
function bcat(b){return (b.cat&&(""+b.cat).trim())?(""+b.cat).trim():"อื่นๆ"}
function rLabel(r){return r==="pass"?"✅ เช็คแล้ว (ผ่าน)":r==="fail"?"❌ ยังไม่ผ่าน":"⬜ ยังไม่ตรวจ"}
function rCls(r){return r==="pass"?"r-pass":r==="fail"?"r-fail":"r-none"}
function counts(){
var o=0,f=0,p=0,fa=0,un=0;
BUGS.forEach(function(b){if(b.status==="เปิด")o++;if((b.status||"").indexOf("แก้")>=0)f++;var v=vof(b.n);if(v.result==="pass")p++;else if(v.result==="fail")fa++;else un++;});
return {o:o,f:f,p:p,fa:fa,un:un};
}
function catBar(){
var byCat={}; BUGS.forEach(function(b){var c=bcat(b);byCat[c]=(byCat[c]||0)+1;});
var html='<span class="lbl">หมวด:</span><button class="catchip'+(catFilt===""?" on":"")+'" data-cat="">ทั้งหมด <span class="cnt">'+BUGS.length+'</span></button>';
CATS.forEach(function(c){html+='<button class="catchip'+(catFilt===c?" on":"")+'" data-cat="'+esc(c)+'">'+esc(c)+' <span class="cnt">'+(byCat[c]||0)+'</span></button>';});
// หมวดที่บั๊กใช้แต่ไม่อยู่ในรายการ (กันตกหล่น)
Object.keys(byCat).forEach(function(c){if(CATS.indexOf(c)<0){html+='<button class="catchip'+(catFilt===c?" on":"")+'" data-cat="'+esc(c)+'" style="opacity:.7">'+esc(c)+' <span class="cnt">'+byCat[c]+'</span></button>';}});
document.getElementById("catbar").innerHTML=html;
}
function render(){
var c=counts();
document.getElementById("cards").innerHTML=
'<div class="card"><div class="v">'+BUGS.length+'</div><div class="l">ทั้งหมด</div></div>'+
'<div class="card open"><div class="v">'+c.o+'</div><div class="l">Dev: ยังเปิด</div></div>'+
'<div class="card ok"><div class="v">'+c.f+'</div><div class="l">Dev: แก้แล้ว</div></div>'+
'<div class="card pass"><div class="v">'+c.p+'</div><div class="l">QA: ผ่าน</div></div>'+
'<div class="card fail"><div class="v">'+c.fa+'</div><div class="l">QA: ยังไม่ผ่าน</div></div>'+
'<div class="card"><div class="v">'+c.un+'</div><div class="l">QA: ยังไม่ตรวจ</div></div>';
catBar();
document.getElementById("toolbar").classList.toggle("hidden",!adminPass);
document.getElementById("cnt").textContent=BUGS.length;
var list=BUGS.filter(function(b){
var v=vof(b.n);
if(catFilt&&bcat(b)!==catFilt)return false;
if(filt==="open"&&b.status!=="เปิด")return false;
if(filt==="fixed"&&(b.status||"").indexOf("แก้")<0)return false;
if(filt==="pass"&&v.result!=="pass")return false;
if(filt==="fail"&&v.result!=="fail")return false;
if(filt==="untested"&&v.result&&v.result!=="none")return false;
if(q){var h=(b.name+" "+b.detail+" "+b.note+" "+bcat(b)+" "+(v.comment||"")).toLowerCase();if(h.indexOf(q)<0)return false}
return true;});
document.getElementById("tb").innerHTML=list.map(rowHtml).join("")||'<tr><td colspan="5" style="text-align:center;color:var(--mut);padding:30px">ไม่พบรายการ</td></tr>';
}
function thumbsHtml(v,n,admin){
return (v.images||[]).map(function(im){
var rm=admin?'<button class="rmimg" data-rm="'+esc(im)+'" data-id="'+n+'" title="ลบรูป">×</button>':'';
return '<a href="uploads/'+encodeURIComponent(im)+'" target="_blank" style="background-image:url(uploads/'+encodeURIComponent(im)+')">'+rm+'</a>';
}).join("");
}
function catOptions(sel){var o="";CATS.forEach(function(c){o+='<option'+(c===sel?" selected":"")+'>'+esc(c)+'</option>';});if(CATS.indexOf(sel)<0&&sel){o+='<option selected>'+esc(sel)+'</option>';}return o;}
function rowHtml(b){
var v=vof(b.n),admin=!!adminPass;
var qa='<span class="b '+rCls(v.result)+' qabadge" data-qa="'+b.n+'" title="'+(admin?'คลิกเพื่อแก้ผลตรวจ':'ใส่รหัสแอดมินมุมขวาบนเพื่อแก้')+'">'+rLabel(v.result)+'</span>'+
(v.comment?'<div class="qcom">'+esc(v.comment)+'</div>':'')+
(v.at?'<div class="qat">อัปเดต '+esc(v.at)+'</div>':'')+
'<div class="thumbs">'+thumbsHtml(v,b.n,admin)+'</div>';
if(admin){
qa+='<button class="editbtn" data-edit="'+b.n+'">✎ ผลตรวจ</button>'+
'<button class="editbtn dev" data-dev="'+b.n+'">🛠 Dev/หมวด</button>'+
'<button class="editbtn del" data-del="'+b.n+'">🗑 ลบ</button>'+
'<div class="edit" id="edit-'+b.n+'">'+
'<div class="rb" data-rbid="'+b.n+'">'+
'<button data-r="pass" class="'+(v.result==="pass"?"sel-pass":"")+'">ผ่าน</button>'+
'<button data-r="fail" class="'+(v.result==="fail"?"sel-fail":"")+'">ยังไม่ผ่าน</button>'+
'<button data-r="none" class="'+(v.result==="none"||!v.result?"sel-none":"")+'">ยังไม่ตรวจ</button>'+
'</div>'+
'<textarea id="com-'+b.n+'" placeholder="คำอธิบายผลตรวจ…">'+esc(v.comment||"")+'</textarea>'+
'<div class="erow"><input type="file" id="img-'+b.n+'" accept="image/*">'+
'<button class="save" data-save="'+b.n+'">บันทึก</button>'+
'<span class="msg" id="msg-'+b.n+'"></span></div>'+
'</div>'+
'<div class="devform" id="dev-'+b.n+'">'+
'<div class="g">'+
'<div><label>หมวด</label><select id="d-cat-'+b.n+'">'+catOptions(bcat(b))+'</select></div>'+
'<div><label>Dev สถานะ</label><select id="d-status-'+b.n+'"><option'+(b.status==="เปิด"?" selected":"")+'>เปิด</option><option'+((b.status||"").indexOf("แก้")>=0?" selected":"")+'>แก้ไขแล้ว</option></select></div>'+
'<div><label>ความสำคัญ</label><select id="d-prio-'+b.n+'"><option value="">-</option>'+["วิกฤต","สูง","กลาง","ต่ำ"].map(function(p){return '<option'+(b.prio===p?" selected":"")+'>'+p+'</option>';}).join("")+'</select></div>'+
'<div><label>ชื่อปัญหา</label><input type="text" id="d-name-'+b.n+'" value="'+esc(b.name)+'"></div>'+
'</div>'+
'<label>รายละเอียด</label><textarea id="d-detail-'+b.n+'">'+esc(b.detail||"")+'</textarea>'+
'<label style="margin-top:6px">หมายเหตุ Dev (วิธีแก้)</label><textarea id="d-note-'+b.n+'">'+esc(b.note||"")+'</textarea>'+
'<div class="erow"><button class="save" data-bugsave="'+b.n+'">บันทึก Dev</button><span class="msg" id="dmsg-'+b.n+'"></span></div>'+
'</div>';
}
return '<tr><td class="n">'+b.n+'</td><td><div class="nm">'+esc(b.name)+'</div>'+
(b.detail?'<div class="dt">'+esc(b.detail)+'</div>':'')+
(b.note?'<div class="nt">'+esc(b.note)+'</div>':'')+
'<div class="cattag">'+esc(bcat(b))+'</div></td>'+
'<td>'+(b.prio?'<span class="b '+pCls(b.prio)+'">'+esc(b.prio)+'</span>':'')+'</td>'+
'<td><span class="b '+sCls(b.status)+'">'+esc(b.status)+'</span></td>'+
'<td class="qa">'+qa+'</td></tr>';
}
function flashAdmin(){var a=document.getElementById("adminpass");a.classList.remove("flash");void a.offsetWidth;a.classList.add("flash");a.focus();}
document.getElementById("tb").addEventListener("click",function(e){
var t=e.target;
if(t.dataset.qa){ if(!adminPass){ flashAdmin(); } else { var ee=document.getElementById("edit-"+t.dataset.qa); if(ee) ee.classList.toggle("show"); } return; }
if(t.dataset.edit){var ed=document.getElementById("edit-"+t.dataset.edit);if(ed)ed.classList.toggle("show");return;}
if(t.dataset.dev){var df=document.getElementById("dev-"+t.dataset.dev);if(df)df.classList.toggle("show");return;}
if(t.dataset.del){doDelBug(t.dataset.del);return;}
if(t.dataset.r){var rb=t.parentNode;[].forEach.call(rb.children,function(x){x.className=""});t.className="sel-"+t.dataset.r;rb.dataset.sel=t.dataset.r;return;}
if(t.dataset.save){doSave(t.dataset.save);return;}
if(t.dataset.bugsave){doBugSave(t.dataset.bugsave);return;}
if(t.dataset.rm){doRemoveImg(t.dataset.id,t.dataset.rm);return;}
});
function curResult(n){var rb=document.querySelector('.rb[data-rbid="'+n+'"]');if(rb&&rb.dataset.sel)return rb.dataset.sel;var v=vof(n);return v.result||"none";}
function doSave(n){
var fd=new FormData();fd.append("api","save");fd.append("pass",adminPass);fd.append("id",n);
fd.append("result",curResult(n));fd.append("comment",document.getElementById("com-"+n).value);
var f=document.getElementById("img-"+n);if(f&&f.files&&f.files[0])fd.append("image",f.files[0]);
var msg=document.getElementById("msg-"+n);msg.textContent="กำลังบันทึก…";msg.style.color="var(--mut)";
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){
if(j.ok){VERIFY[n]=j.rec;msg.textContent="บันทึกแล้ว ✓";msg.style.color="var(--green)";setTimeout(render,500);}
else{msg.textContent=j.error||"ผิดพลาด";msg.style.color="var(--red)";}
}).catch(function(){msg.textContent="เชื่อมต่อไม่ได้";msg.style.color="var(--red)";});
}
function doBugSave(n){
var fd=new FormData();fd.append("api","bugsave");fd.append("pass",adminPass);fd.append("id",n);
fd.append("name",document.getElementById("d-name-"+n).value);
fd.append("detail",document.getElementById("d-detail-"+n).value);
fd.append("note",document.getElementById("d-note-"+n).value);
fd.append("cat",document.getElementById("d-cat-"+n).value);
fd.append("status",document.getElementById("d-status-"+n).value);
fd.append("prio",document.getElementById("d-prio-"+n).value);
var msg=document.getElementById("dmsg-"+n);msg.textContent="กำลังบันทึก…";msg.style.color="var(--mut)";
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){
if(j.ok){BUGS=j.bugs;msg.textContent="บันทึกแล้ว ✓";msg.style.color="var(--green)";setTimeout(render,400);}
else{msg.textContent=j.error||"ผิดพลาด";msg.style.color="var(--red)";}
}).catch(function(){msg.textContent="เชื่อมต่อไม่ได้";msg.style.color="var(--red)";});
}
function doDelBug(n){
if(!confirm("ลบบั๊ก #"+n+" ?"))return;
var fd=new FormData();fd.append("api","bugdel");fd.append("pass",adminPass);fd.append("id",n);
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){if(j.ok){BUGS=j.bugs;render();}else alert(j.error||"ลบไม่ได้");});
}
function doRemoveImg(n,im){
if(!confirm("ลบรูปนี้?"))return;
var fd=new FormData();fd.append("api","save");fd.append("pass",adminPass);fd.append("id",n);fd.append("removeImage",im);
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){if(j.ok){VERIFY[n]=j.rec;render();}});
}
// เพิ่มบั๊กใหม่
document.getElementById("addBug").addEventListener("click",function(){
var name=prompt("ชื่อปัญหา (บั๊กใหม่):");if(!name||!name.trim())return;
var cat=prompt("หมวด (เช่น "+CATS.slice(0,3).join(" / ")+"):",catFilt||"อื่นๆ")||"อื่นๆ";
var fd=new FormData();fd.append("api","bugsave");fd.append("pass",adminPass);fd.append("id","");
fd.append("name",name.trim());fd.append("cat",cat.trim());fd.append("status","เปิด");fd.append("prio","กลาง");
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){if(j.ok){BUGS=j.bugs;render();}else alert(j.error||"เพิ่มไม่ได้");});
});
// จัดการหมวด
var catModal=document.getElementById("catModal");
function renderCatList(){
document.getElementById("catList").innerHTML=CATS.map(function(c,i){
return '<div class="catrow"><input type="text" value="'+esc(c)+'" data-ci="'+i+'"><button class="x" data-cx="'+i+'">×</button></div>';
}).join("");
}
document.getElementById("mgCats").addEventListener("click",function(){renderCatList();document.getElementById("catMsg").textContent="";catModal.classList.add("show");});
document.getElementById("catClose").addEventListener("click",function(){catModal.classList.remove("show");});
document.getElementById("catAdd").addEventListener("click",function(){CATS=collectCats();CATS.push("หมวดใหม่");renderCatList();});
document.getElementById("catList").addEventListener("click",function(e){if(e.target.dataset.cx!=null){CATS=collectCats();CATS.splice(+e.target.dataset.cx,1);renderCatList();}});
function collectCats(){var out=[];[].forEach.call(document.querySelectorAll("#catList input"),function(inp){var v=inp.value.trim();if(v&&out.indexOf(v)<0)out.push(v);});return out;}
document.getElementById("catSave").addEventListener("click",function(){
var list=collectCats();if(!list.length){document.getElementById("catMsg").textContent="ต้องมีอย่างน้อย 1 หมวด";document.getElementById("catMsg").style.color="var(--red)";return;}
var fd=new FormData();fd.append("api","catsave");fd.append("pass",adminPass);fd.append("cats",JSON.stringify(list));
var msg=document.getElementById("catMsg");msg.textContent="กำลังบันทึก…";msg.style.color="var(--mut)";
fetch(location.pathname,{method:"POST",body:fd}).then(function(r){return r.json()}).then(function(j){
if(j.ok){CATS=j.cats;msg.textContent="บันทึกแล้ว ✓";msg.style.color="var(--green)";render();setTimeout(function(){catModal.classList.remove("show");},600);}
else{msg.textContent=j.error||"ผิดพลาด";msg.style.color="var(--red)";}
}).catch(function(){msg.textContent="เชื่อมต่อไม่ได้";msg.style.color="var(--red)";});
});
// admin pass
var ap=document.getElementById("adminpass");
ap.value=localStorage.getItem("tcAdminPass")||"";
function applyAdmin(){adminPass=ap.value.trim();localStorage.setItem("tcAdminPass",adminPass);
document.getElementById("adminok").classList.toggle("hidden",!adminPass);
document.getElementById("lockhint").classList.toggle("hidden",!!adminPass);render();}
ap.addEventListener("input",applyAdmin);applyAdmin();
document.getElementById("q").addEventListener("input",function(e){q=e.target.value.toLowerCase().trim();render()});
[].forEach.call(document.querySelectorAll(".chip"),function(b){b.addEventListener("click",function(){
[].forEach.call(document.querySelectorAll(".chip"),function(x){x.classList.remove("on")});
b.classList.add("on");filt=b.getAttribute("data-f");render();})});
document.getElementById("catbar").addEventListener("click",function(e){var t=e.target.closest(".catchip");if(!t)return;catFilt=t.getAttribute("data-cat")||"";render();});
render();
</script></body></html>