updateflow

This commit is contained in:
2026-06-01 16:09:38 +00:00
parent f36d91ef13
commit 20b66b7332
8 changed files with 845 additions and 30 deletions
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* สาธารณะ — บวกเหรียญให้บัญชี guest (ใช้กับการ์ดพิเศษ Fund ในเกม)
* POST JSON: { "playerKey": "...", "amount": 10 }
* จำกัด amount ต่อครั้งกันการยิงมั่ว
*/
require __DIR__ . '/_common.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['ok' => false, 'error' => 'Use POST'], 405);
}
$body = require_json_body();
$key = trim((string)($body['playerKey'] ?? ''));
if (!preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key)) {
json_response(['ok' => false, 'error' => 'playerKey ต้องยาว 8128 (a-z 0-9 _ -)'], 400);
}
$amount = (int)($body['amount'] ?? 0);
// กันยิงมั่ว: ครั้งละ 1–100 เหรียญ
if ($amount < 1 || $amount > 100) {
json_response(['ok' => false, 'error' => 'amount ต้องอยู่ระหว่าง 1–100'], 400);
}
$store = read_store();
$accounts = $store['accounts'] ?? [];
foreach ($accounts as $i => $a) {
if (($a['loginType'] ?? '') !== 'guest') {
continue;
}
if (($a['providerUserId'] ?? '') !== $key) {
continue;
}
if (!empty($a['blocked'])) {
json_response(['ok' => false, 'error' => 'บัญชีถูกระงับ'], 403);
}
$coins = max(0, (int)($a['coins'] ?? 0)) + $amount;
$store['accounts'][$i]['coins'] = $coins;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'coins' => $coins, 'added' => $amount, 'accountId' => $a['id'] ?? null]);
}
// ยังไม่มีบัญชี → สร้างใหม่พร้อมเหรียญ
$new = [
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => 'auto: player-coins-add',
'blocked' => false,
'coins' => $amount,
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
$store['accounts'][] = $new;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'สร้างบัญชีไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'coins' => $amount, 'added' => $amount, 'accountId' => $new['id']]);
+3
View File
@@ -1003,6 +1003,9 @@
<li><kbd>Ctrl</kbd> + <kbd>1</kbd> / <kbd>2</kbd> / <kbd>3</kbd> — ในหน้าเลือกผู้ต้องสงสัย: เก็บหลักฐาน 1 ใบให้ suspect คนนั้นทันที (เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง)<br>
<span class="muted" style="font-size:13px;">กดครบ 3 ปุ่ม → ปุ่ม "ชี้ตัวคนร้าย" โผล่</span>
</li>
<li><kbd>Ctrl</kbd> + <kbd>Q</kbd> — ในเกมตอนคำถามพิเศษ (Special Quiz) เปิดอยู่: ตอบข้อปัจจุบันให้ถูกอัตโนมัติ<br>
<span class="muted" style="font-size:13px;">เซิร์ฟเวอร์เป็นผู้เติมคำตอบที่ถูก (กันโกง) — ใช้ทดสอบ flow รับการ์ดพิเศษได้เร็ว</span>
</li>
</ul>
<p id="test-mode-msg" class="msg" role="status" style="margin-top:14px;"></p>
+12
View File
@@ -101,6 +101,18 @@
"coins": 0,
"createdAt": "2026-05-21T07:52:34+00:00",
"updatedAt": "2026-05-21T07:52:34+00:00"
},
{
"id": "4aded83523028d3837fc9fa0",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "testkey_phase2_demo",
"notes": "auto: player-coins-add",
"blocked": false,
"coins": 10,
"createdAt": "2026-06-01T15:32:12+00:00",
"updatedAt": "2026-06-01T15:32:12+00:00"
}
]
}
+114
View File
@@ -335,6 +335,15 @@
if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence));
else sessionStorage.removeItem('justiceDetectiveMyEvidence');
}
/* โชว์การ์ดหลักฐานที่เพิ่งได้ ตอนกลับถึง LobbyB (เปิดแฟ้มอัตโนมัติ + ไฮไลต์ใบใหม่) */
if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) {
sessionStorage.setItem('justiceEvidenceReveal', JSON.stringify({
suspectIndex: res.suspectIndex,
awardedCard: (typeof res.awardedCard === 'number') ? res.awardedCard : null,
freeEvidenceCount: res.freeEvidenceCount || 0,
ts: Date.now(),
}));
}
} catch (e) { /* ignore */ }
go();
});
@@ -11353,6 +11362,10 @@
* server เปนผดส: มไอคอน 1/3, เปดคำถามใหกคนพรอมก, ตอบถกทกคนทกข -> ไดการ
*/
const SPECIAL_QUIZ_CHOICE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
/** Test Mode (Admin) เก็บใน localStorage — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
function __isTestModeOnPlay() {
try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; }
}
let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center)
let specialQuizCollideSent = false;
let specialQuizAwardedCard = null; // การ์ดที่ได้รอบนี้ (จาก special-quiz-ended)
@@ -11640,6 +11653,86 @@
cancelAnimationFrame(specialQuizTimerRaf);
}
/** การ์ดพิเศษถูก "ใช้" — โชว์ animation + ขยายเวลามินิเกมฝั่ง client (ถ้าเป็น Time Dilation) */
function applySpecialCardClient(data) {
const card = data.card || {};
const addSec = Number(data.addSec) || 0;
const coins = Number(data.coins) || 0;
if (addSec > 0 && card.effectKey === 'time_dilation') {
extendCurrentMinigameTimeClient(addSec);
}
if (coins > 0 && card.effectKey === 'fund') {
addOwnCoinsViaApi(coins);
}
showSpecialCardUseAnimation(card, { addSec: addSec, coins: coins });
}
/** Card 6 Fund — แต่ละ client บวกเหรียญของตัวเอง (อ่าน jdPlayerKey จาก localStorage) */
function addOwnCoinsViaApi(amount) {
let key = '';
try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
if (!key || key.length < 8) return;
const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins-add.php') : '/Admin/api/player-coins-add.php');
try {
fetch(url, {
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerKey: key, amount: amount }),
}).then(function (r) { return r.json(); }).then(function (d) {
if (d && d.ok && typeof d.coins === 'number') {
try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
}
}).catch(function () { /* ignore */ });
} catch (e) { /* ignore */ }
}
/** +วินาที ให้มินิเกมที่กำลังเล่น (jump/shooter/quiz carry) — mng8a80o จัดการฝั่ง server */
function extendCurrentMinigameTimeClient(sec) {
const ms = Math.max(0, sec * 1000);
if (!ms) return;
if (isJumpSurvive() && jumpSurviveSessionStartMs) jumpSurviveSessionStartMs += ms;
if (isSpaceShooter() && spaceShooterSessionStartMs) spaceShooterSessionStartMs += ms;
if (isQuizCarry()) {
if (quizCarryAnswerCloseAt > 0) quizCarryAnswerCloseAt += ms;
if (quizCarryOptionRevealAt > 0) quizCarryOptionRevealAt += ms;
}
}
function showSpecialCardUseAnimation(card, opts) {
opts = opts || {};
let ov = document.getElementById('special-card-use-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'special-card-use-overlay';
ov.className = 'is-hidden';
ov.innerHTML =
'<div class="scu-card"><img class="scu-img" alt="" />' +
'<div class="scu-name"></div><div class="scu-effect"></div></div>';
document.body.appendChild(ov);
}
const img = ov.querySelector('.scu-img');
const nameEl = ov.querySelector('.scu-name');
const fxEl = ov.querySelector('.scu-effect');
if (img) img.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
if (nameEl) nameEl.textContent = 'ใช้การ์ด: ' + (card.en || card.th || 'Special Card');
if (fxEl) {
let fx = card.desc || card.th || '';
if (opts.addSec > 0) fx = '+' + opts.addSec + ' วินาที';
else if (opts.coins > 0) fx = 'ทุกคน +' + opts.coins + ' COINS';
fxEl.textContent = fx;
}
ov.classList.remove('is-hidden');
ov.classList.remove('scu-play');
void ov.offsetWidth;
ov.classList.add('scu-play');
clearTimeout(showSpecialCardUseAnimation._t);
showSpecialCardUseAnimation._t = setTimeout(function () {
ov.classList.add('is-hidden');
ov.classList.remove('scu-play');
}, 2600);
}
function endSpecialQuiz(data) {
cancelAnimationFrame(specialQuizTimerRaf);
specialQuizActiveQuestion = null;
@@ -15544,6 +15637,9 @@
if (specialQuizForceDebug) {
try { socket.emit('special-quiz-force', { on: true }); } catch (eF) { /* ignore */ }
}
if (__isTestModeOnPlay()) {
try { socket.emit('special-quiz-testmode', { on: true }); } catch (eT) { /* ignore */ }
}
let botSlots = parseInt(res.botSlotCount, 10);
if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) {
botSlots = 6 - res.maxPlayers;
@@ -15974,6 +16070,10 @@
if (previewMode) return;
endSpecialQuiz(data);
});
socket.on('special-card-applied', (data) => {
if (previewMode || !data || !data.card) return;
applySpecialCardClient(data);
});
socket.on('gauntlet-sync', (data) => {
if (!data || typeof data !== 'object') return;
@@ -19476,6 +19576,20 @@
}
document.addEventListener('keydown', (e) => {
/* Test Mode shortcut: Ctrl+Q = ตอบถูกใน special quiz (server เติมคำตอบให้) */
if (e.ctrlKey && (e.code === 'KeyQ' || e.key === 'q' || e.key === 'Q')) {
if (specialQuizActiveQuestion && !specialQuizAnswered && __isTestModeOnPlay()) {
e.preventDefault();
specialQuizAnswered = true;
const status = document.getElementById('sq-ov-status');
if (status) status.textContent = '[Test] ส่งคำตอบที่ถูกอัตโนมัติ — รอผู้เล่นคนอื่น...';
if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
const choicesEl = document.getElementById('sq-ov-choices');
if (choicesEl) Array.prototype.forEach.call(choicesEl.children, function (b) { b.disabled = true; });
try { socket.emit('special-quiz-answer', { debugCorrect: true }, function () {}); } catch (eQ) { /* ignore */ }
return;
}
}
if (isMovementKey(e.code) && isChatFocused()) return;
if (previewMode && editorEmbedReturn && mapData && !isChatFocused() && !isQuizQuestionMissionHudActivePlay() && !isStackTowerEmbedZoomLockedPlay()) {
if (e.code === 'BracketLeft' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
+267 -1
View File
@@ -4356,6 +4356,28 @@
'#trial-vote-counter{position:absolute;right:22px;top:14px;color:#7fe9ff;font:800 24px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 12px rgba(34,211,238,.7)}' +
'#trial-vote-counter b{color:#fff}' +
'.suspect-pick--trial #trial-vote-counter{display:block}' +
'#trial-vote-timer{position:absolute;right:22px;top:50px;color:#9ece6a;font:900 34px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 14px rgba(158,206,106,.6)}' +
'.suspect-pick--trial:not(.trial-revealed) #trial-vote-timer{display:block}' +
'#trial-vote-timer.low{color:#f7768e;text-shadow:0 0 14px rgba(247,118,142,.7)}' +
'#trial-card-notice{position:absolute;left:50%;top:9%;transform:translateX(-50%);z-index:63;background:linear-gradient(180deg,#1f2747,#141a33);border:1px solid rgba(255,214,102,.75);color:#ffe7b3;font:800 18px/1.3 Kanit;padding:10px 22px;border-radius:999px;box-shadow:0 10px 30px rgba(0,0,0,.5);display:none;text-align:center}' +
'#pick-vote-overlay{position:fixed;inset:0;z-index:120;background:rgba(6,9,20,.86);display:none;align-items:center;justify-content:center;backdrop-filter:blur(3px)}' +
'#pick-vote-overlay.is-open{display:flex}' +
'.pv-shell{width:min(620px,92vw);background:linear-gradient(180deg,#161d38,#0e1326);border:1px solid rgba(124,154,255,.35);border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.6);padding:22px 24px;text-align:center}' +
'.pv-badge{display:inline-block;font:900 13px/1 Kanit;letter-spacing:1px;padding:6px 14px;border-radius:999px;background:rgba(255,214,102,.16);color:#ffd666;border:1px solid rgba(255,214,102,.5)}' +
'.pv-title{margin:12px 0 2px;font:900 26px/1.2 Kanit;color:#fff}' +
'.pv-sub{margin:0 0 6px;font:600 14px/1.4 Kanit;color:#9fb0d8}' +
'.pv-timer{font:900 30px/1 Kanit;color:#9ece6a;margin:4px 0 12px;text-shadow:0 0 12px rgba(158,206,106,.5)}' +
'.pv-timer.low{color:#f7768e;text-shadow:0 0 12px rgba(247,118,142,.6)}' +
'.pv-list{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:0 0 14px}' +
'.pv-cand{position:relative;min-width:120px;padding:12px 14px;border-radius:14px;background:#1b2240;border:2px solid rgba(124,154,255,.25);color:#dfe6ff;font:800 16px/1.2 Kanit;cursor:pointer;transition:transform .12s,border-color .12s,background .12s}' +
'.pv-cand:hover{transform:translateY(-2px);border-color:rgba(124,154,255,.6)}' +
'.pv-cand.is-picked{border-color:#9ece6a;background:#1f3326;box-shadow:0 0 16px rgba(158,206,106,.35)}' +
'.pv-cand .pv-me{font:700 11px/1 Kanit;color:#7fe9ff;display:block;margin-top:4px}' +
'.pv-cand .pv-bot{font:700 11px/1 Kanit;color:#9fb0d8;display:block;margin-top:4px}' +
'.pv-cand .pv-count{position:absolute;top:-8px;right:-8px;min-width:24px;height:24px;border-radius:999px;background:#f7768e;color:#fff;font:900 13px/24px Kanit;display:none}' +
'.pv-cand .pv-count.show{display:block}' +
'.pv-foot{font:700 14px/1.3 Kanit;color:#9fb0d8}' +
'.pv-result{font:900 22px/1.3 Kanit;color:#ffd666;margin:6px 0}' +
'.suspect-pick--trial .suspect-pick-title-img{display:none!important}' +
'#trial-title{display:none;text-align:center;margin:0 0 4px}' +
'#trial-title img{display:block;margin:0 auto;max-width:90vw}' +
@@ -4430,6 +4452,41 @@
if (el) el.innerHTML = 'ลงติกแล้ว : <b>' + (voted || 0) + '/' + (total || 0) + '</b>';
}
var trialVoteCountdownRaf = 0;
function ensureTrialVoteTimerEl() {
var ov = document.getElementById('suspect-pick-overlay');
var el = document.getElementById('trial-vote-timer');
if (!el && ov) { el = document.createElement('div'); el.id = 'trial-vote-timer'; ov.appendChild(el); }
return el;
}
function stopTrialVoteCountdown() {
if (trialVoteCountdownRaf) { cancelAnimationFrame(trialVoteCountdownRaf); trialVoteCountdownRaf = 0; }
}
function startTrialVoteCountdown(endsAt) {
stopTrialVoteCountdown();
var el = ensureTrialVoteTimerEl();
if (!el || !endsAt) { if (el) el.textContent = ''; return; }
function tick() {
if (trialMode !== 'voting') { stopTrialVoteCountdown(); return; }
var ms = endsAt - Date.now();
var s = Math.max(0, Math.ceil(ms / 1000));
el.textContent = '⏱ ' + s;
el.classList.toggle('low', s <= 5);
if (ms > 0) trialVoteCountdownRaf = requestAnimationFrame(tick);
}
tick();
}
function showTrialCardNotice(text) {
var ov = document.getElementById('suspect-pick-overlay');
if (!ov) return;
var el = document.getElementById('trial-card-notice');
if (!el) { el = document.createElement('div'); el.id = 'trial-card-notice'; ov.appendChild(el); }
el.textContent = text;
el.style.display = 'block';
clearTimeout(showTrialCardNotice._t);
showTrialCardNotice._t = setTimeout(function () { if (el) el.style.display = 'none'; }, 4200);
}
function ensureTrialRevealBtn() {
var ov = document.getElementById('suspect-pick-overlay');
var btn = document.getElementById('trial-reveal-btn');
@@ -4520,6 +4577,13 @@
if (actions) actions.classList.remove('suspect-pick-actions--visible');
var hint = document.getElementById('suspect-pick-hint');
if (hint) { hint.classList.remove('is-hidden'); hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; }
/* ตัวจับเวลาโหวต + แจ้งการ์ดพิเศษ (Extension / Bail re-vote) */
startTrialVoteCountdown((data && data.voteEndsAt) || 0);
if (data && data.revote) {
showTrialCardNotice('ใช้การ์ด Bail Coin — จับผิดตัว! โหวตใหม่อีกครั้ง');
} else if (data && data.extensionCard) {
showTrialCardNotice('ใช้การ์ด Extension — เพิ่มเวลาโหวต +' + ((data.extensionSec) || 10) + ' วินาที');
}
updateSuspectFloatingOpenBtn();
scheduleSuspectPickScale();
}
@@ -4539,6 +4603,7 @@
function showTrialResult(data) {
trialMode = 'revealed';
stopTrialVoteCountdown();
var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0;
if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts;
var counts = trialVoteCounts || [0, 0, 0];
@@ -5222,6 +5287,56 @@
});
}
function ensureEvidenceRevealStyle() {
if (document.getElementById('evidence-reveal-style')) return;
var st = document.createElement('style');
st.id = 'evidence-reveal-style';
st.textContent =
'.ev-card--new{animation:evNewPulse 1.4s ease-out 2;box-shadow:0 0 0 3px #ffd666,0 0 28px rgba(255,214,102,.7)!important;border-radius:10px;}' +
'@keyframes evNewPulse{0%{transform:scale(1)}30%{transform:scale(1.06)}100%{transform:scale(1)}}' +
'#evidence-reveal-banner{position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:30;background:linear-gradient(180deg,#1f2747,#141a33);border:1px solid rgba(255,214,102,.7);color:#ffe7b3;font-weight:800;font-size:18px;padding:10px 22px;border-radius:999px;box-shadow:0 10px 30px rgba(0,0,0,.5);pointer-events:none;animation:evBannerIn .4s ease-out;}' +
'@keyframes evBannerIn{from{opacity:0;transform:translate(-50%,-12px)}to{opacity:1;transform:translateX(-50%)}}';
document.head.appendChild(st);
}
function showEvidenceRevealBanner(n) {
var ov = document.getElementById('lobby-evidence-overlay');
if (!ov) return;
var old = document.getElementById('evidence-reveal-banner');
if (old) old.remove();
var b = document.createElement('div');
b.id = 'evidence-reveal-banner';
b.textContent = 'ได้รับหลักฐานใหม่ +' + Math.max(1, n) + ' ใบ';
ov.appendChild(b);
setTimeout(function () { if (b && b.parentNode) b.remove(); }, 4200);
}
/** โชว์การ์ดหลักฐานที่เพิ่งได้ตอนกลับถึง LobbyB (เปิดแฟ้ม + ไฮไลต์ใบใหม่) */
function showEvidenceRevealOnReturn() {
var raw = null;
try { raw = sessionStorage.getItem('justiceEvidenceReveal'); } catch (e) { raw = null; }
if (!raw) return false;
try { sessionStorage.removeItem('justiceEvidenceReveal'); } catch (e) { /* ignore */ }
var info = null;
try { info = JSON.parse(raw); } catch (e) { return false; }
if (!info || typeof info.suspectIndex !== 'number' || info.suspectIndex < 0) return false;
ensureEvidenceRevealStyle();
setTimeout(function () {
openLobbyEvidenceModal();
syncLobbyEvidenceTabUi(info.suspectIndex);
var root = document.getElementById('lobby-evidence-cards-root');
var newN = 1 + (parseInt(info.freeEvidenceCount, 10) || 0);
if (root && root.children.length) {
var kids = root.children;
for (var i = Math.max(0, kids.length - newN); i < kids.length; i++) {
kids[i].classList.add('ev-card--new');
}
}
showEvidenceRevealBanner(newN);
}, 350);
return true;
}
function applyDetectiveReturnToLobbyB(data) {
quizModeActive = false;
quizPhaseLocal = null;
@@ -5240,14 +5355,16 @@
lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null,
caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null,
});
const revealedEvidence = showEvidenceRevealOnReturn();
if (reopenSuspect) {
serverSuspectPhaseActive = true;
/* ถ้ามีการโชว์การ์ดหลักฐานใหม่ ให้เลื่อนเปิดหน้าเลือกผู้ต้องสงสัยช้าลง (ผู้เล่นได้ดูการ์ดก่อน) */
setTimeout(function () {
openSuspectOverlay(pickIdx);
updateSuspectFloatingOpenBtn();
syncLobbyBUiChrome();
updatePlayersHud();
}, 500);
}, revealedEvidence ? 1600 : 500);
}
syncLobbyBUiChrome();
updatePlayersHud();
@@ -5511,6 +5628,131 @@
showTrialResult(data || {});
});
/* ===== โหวตเลือกผู้เล่น (Silence / Ban) ===== */
var pickVoteState = { open: false, purpose: '', picked: null, raf: 0 };
function pickVoteMeta(purpose) {
if (purpose === 'ban') {
return { badge: 'BAN CARD', title: 'โหวตห้ามเล่นมินิเกม', sub: 'เลือกผู้เล่น 1 คนที่จะถูกห้ามเล่นมินิเกมรอบนี้ (โหวตตัวเองได้ · เสมอ = สุ่ม)' };
}
return { badge: 'SILENCE CARD', title: 'โหวตปิดปาก', sub: 'เลือกผู้เล่น 1 คนที่จะโหวตชี้ตัวคนร้ายไม่ได้ในรอบนี้ (โหวตตัวเองได้ · เสมอ = สุ่ม)' };
}
function ensurePickVoteOverlay() {
var ov = document.getElementById('pick-vote-overlay');
if (ov) return ov;
ov = document.createElement('div');
ov.id = 'pick-vote-overlay';
ov.innerHTML =
'<div class="pv-shell">' +
'<span class="pv-badge" id="pv-badge">CARD</span>' +
'<h2 class="pv-title" id="pv-title"></h2>' +
'<p class="pv-sub" id="pv-sub"></p>' +
'<div class="pv-timer" id="pv-timer"></div>' +
'<div class="pv-list" id="pv-list"></div>' +
'<div class="pv-result" id="pv-result" style="display:none"></div>' +
'<div class="pv-foot" id="pv-foot"></div>' +
'</div>';
document.body.appendChild(ov);
return ov;
}
function stopPickVoteCountdown() {
if (pickVoteState.raf) { cancelAnimationFrame(pickVoteState.raf); pickVoteState.raf = 0; }
}
function startPickVoteCountdown(endsAt) {
stopPickVoteCountdown();
var el = document.getElementById('pv-timer');
if (!el || !endsAt) { if (el) el.textContent = ''; return; }
function tick() {
if (!pickVoteState.open) { stopPickVoteCountdown(); return; }
var ms = endsAt - Date.now();
var s = Math.max(0, Math.ceil(ms / 1000));
el.textContent = '⏱ ' + s;
el.classList.toggle('low', s <= 5);
if (ms > 0) pickVoteState.raf = requestAnimationFrame(tick);
}
tick();
}
function castPickVote(targetId) {
if (!pickVoteState.open) return;
pickVoteState.picked = targetId;
var list = document.getElementById('pv-list');
if (list) {
Array.prototype.forEach.call(list.querySelectorAll('.pv-cand'), function (b) {
b.classList.toggle('is-picked', b.getAttribute('data-id') === targetId);
});
}
socket.emit('player-pick-vote', { targetId: targetId }, function (res) {
if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
}
socket.on('player-pick-vote-open', (data) => {
var ov = ensurePickVoteOverlay();
var meta = pickVoteMeta(data && data.purpose);
pickVoteState.open = true;
pickVoteState.purpose = (data && data.purpose) || '';
pickVoteState.picked = null;
document.getElementById('pv-badge').textContent = meta.badge;
document.getElementById('pv-title').textContent = meta.title;
document.getElementById('pv-sub').textContent = meta.sub;
var resEl = document.getElementById('pv-result');
if (resEl) { resEl.style.display = 'none'; resEl.textContent = ''; }
document.getElementById('pv-foot').textContent = '';
var list = document.getElementById('pv-list');
list.innerHTML = '';
var cands = (data && Array.isArray(data.candidates)) ? data.candidates : [];
cands.forEach(function (c) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pv-cand';
b.setAttribute('data-id', c.id);
var tag = (c.id === socket.id) ? '<span class="pv-me">(คุณ)</span>' : (c.isBot ? '<span class="pv-bot">บอท</span>' : '');
b.innerHTML = '<span>' + (c.nickname || '?') + '</span>' + tag + '<span class="pv-count" data-id="' + c.id + '"></span>';
b.addEventListener('click', function () { castPickVote(c.id); });
list.appendChild(b);
});
ov.classList.add('is-open');
startPickVoteCountdown(data && data.endsAt);
});
socket.on('player-pick-vote-update', (data) => {
if (!pickVoteState.open) return;
var counts = (data && data.counts) || {};
var list = document.getElementById('pv-list');
if (list) {
Array.prototype.forEach.call(list.querySelectorAll('.pv-count'), function (c) {
var n = counts[c.getAttribute('data-id')] || 0;
c.textContent = n;
c.classList.toggle('show', n > 0);
});
}
var foot = document.getElementById('pv-foot');
if (foot) foot.textContent = 'โหวตแล้ว ' + ((data && data.voted) || 0) + '/' + ((data && data.total) || 0);
});
socket.on('player-pick-vote-result', (data) => {
stopPickVoteCountdown();
pickVoteState.open = false;
var resEl = document.getElementById('pv-result');
var name = (data && data.targetName) ? data.targetName : '—';
var word = (data && data.purpose === 'ban') ? 'ถูกห้ามเล่นมินิเกมรอบนี้' : 'ถูกปิดปาก (โหวตไม่ได้)';
if (resEl) {
resEl.style.display = 'block';
resEl.textContent = '🎯 ' + name + ' ' + word;
}
var foot = document.getElementById('pv-foot');
if (foot) foot.textContent = '';
var amTarget = data && data.targetId === socket.id;
setTimeout(function () {
var ov = document.getElementById('pick-vote-overlay');
if (ov) ov.classList.remove('is-open');
}, amTarget ? 2600 : 1900);
});
socket.on('suspect-investigation-start', (data) => {
serverSuspectPhaseActive = false;
closeSuspectOverlay();
@@ -5860,6 +6102,12 @@
sessionStorage.setItem('justicePlayLobbyAvatar:' + cidPlay, avEl.src);
}
} catch (eAv) { /* ignore */ }
/* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้ไม่เข้ามินิเกม (รออยู่ LobbyB) */
if (data && data.bannedPlayerId && data.bannedPlayerId === socket.id) {
appendLobbySystemChat('— คุณถูกแบนรอบนี้ (Ban Card) · รอเพื่อนเล่นมินิเกมจบ');
showBannedNotice();
return;
}
let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(getProfileDisplayName());
if (mid) q += '&map=' + encodeURIComponent(mid);
if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr);
@@ -5868,6 +6116,24 @@
location.href = q;
});
function showBannedNotice() {
var ov = document.getElementById('banned-notice-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'banned-notice-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:118;background:rgba(6,9,20,.82);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)';
ov.innerHTML = '<div style="text-align:center;background:linear-gradient(180deg,#2a1430,#160a1d);border:1px solid rgba(247,118,142,.55);border-radius:18px;padding:26px 34px;box-shadow:0 24px 60px rgba(0,0,0,.6)">' +
'<div style="font:900 15px/1 Kanit;letter-spacing:1px;color:#f7768e">BAN CARD</div>' +
'<div style="font:900 28px/1.3 Kanit;color:#fff;margin:10px 0 6px">คุณถูกแบนรอบนี้</div>' +
'<div style="font:600 15px/1.4 Kanit;color:#e6b8c4">รอเพื่อนเล่นมินิเกมจบ แล้วกลับมา LobbyB ด้วยกัน</div>' +
'</div>';
document.body.appendChild(ov);
}
ov.style.display = 'flex';
clearTimeout(showBannedNotice._t);
showBannedNotice._t = setTimeout(function () { if (ov) ov.style.display = 'none'; }, 4200);
}
document.addEventListener('keydown', (e) => {
if (moveCodes.includes(e.code) && !isChatFocused()) {
if (suspectPickOverlayOpen) { e.preventDefault(); return; }
+16 -1
View File
@@ -3916,6 +3916,21 @@
#special-quiz-overlay .sq-ov-member--wrong .sq-ov-member-dot { background: #f7768e; color: #0e1226; }
#special-quiz-overlay .sq-ov-member--wrong .sq-ov-member-ans { color: #f7768e; }
#special-quiz-overlay .sq-ov-member--me { box-shadow: inset 0 0 0 1px rgba(224,175,104,0.6); }
/* ใช้การ์ดพิเศษ — animation โชว์การ์ดบินเข้ากลางจอ */
#special-card-use-overlay { position: fixed; inset: 0; z-index: 1300; display: flex; align-items: center; justify-content: center; pointer-events: none; }
#special-card-use-overlay.is-hidden { display: none; }
#special-card-use-overlay .scu-card { display: flex; flex-direction: column; align-items: center; gap: 10px; opacity: 0; transform: scale(0.5) translateY(40px); }
#special-card-use-overlay.scu-play .scu-card { animation: scuPop 2.6s cubic-bezier(.18,.9,.28,1.2) forwards; }
#special-card-use-overlay .scu-img { width: 168px; height: auto; border-radius: 12px; filter: drop-shadow(0 18px 40px rgba(0,0,0,0.6)) drop-shadow(0 0 22px rgba(224,175,104,0.55)); }
#special-card-use-overlay .scu-name { font-size: 22px; font-weight: 800; color: #ffe7b3; text-shadow: 0 2px 10px rgba(0,0,0,0.7); letter-spacing: .02em; }
#special-card-use-overlay .scu-effect { font-size: 26px; font-weight: 900; color: #9ece6a; text-shadow: 0 2px 12px rgba(0,0,0,0.7); }
@keyframes scuPop {
0% { opacity: 0; transform: scale(0.4) translateY(60px) rotate(-8deg); }
14% { opacity: 1; transform: scale(1.12) translateY(0) rotate(2deg); }
24% { transform: scale(1) translateY(0) rotate(0); }
80% { opacity: 1; transform: scale(1) translateY(0); }
100% { opacity: 0; transform: scale(0.94) translateY(-26px); }
}
</style>
<div id="special-quiz-overlay" class="is-hidden" role="dialog" aria-modal="true" aria-labelledby="sq-overlay-title">
<div class="sq-ov-backdrop" aria-hidden="true"></div>
@@ -3939,7 +3954,7 @@
<script src="/app-base.js?v=2"></script>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/play.js?v=0.0504"></script>
<script src="js/play.js?v=0.0506"></script>
<div class="version-tag">v —</div>
</body>
</html>
+1 -1
View File
@@ -1613,7 +1613,7 @@
<script src="js/display-name.js?v=2"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/customize-popup.js?v=31" data-customize-triggers="" data-customize-asset-base="img/03-5-Customize"></script>
<script src="js/room-lobby.js?v=0.0255"></script>
<script src="js/room-lobby.js?v=0.0258"></script>
<div class="version-tag">v —</div>
</body>
</html>
+365 -27
View File
@@ -508,6 +508,9 @@ function maybeSpawnSpecialQuizForRun(space) {
clearSpecialQuizTimers(space);
space.specialQuiz = null;
space.specialCardAwardedThisRun = null;
space.timeDilationPendingSec = 0;
/* : pendingSpecialCard trial/lobby
หรอถกแทนดวยการดใบใหม (endSpecialQuizSession เขยนทบเมอไดใบใหม) */
if (!space || !space.detectiveMinigameActive) return;
const mapId = String(space.mapId || '').trim();
if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return;
@@ -683,6 +686,81 @@ function resolveSpecialQuizQuestion(sid, space, fromTimeout) {
space.specialQuizTimers.push(t);
}
/**
* ยามการดพเศษ 7 ใบ when = วงทการดทำงาน:
* minigame = ใชนทในมเกมทเพงเล (Time Dilation)
* now = ใชนทตอนได (Fund coins)
* after_game = ตอนจบมเกมกอนกล LobbyB (Free Evidence)
* pre_trial = อนโหวตพจารณาคด (Silence)
* trial_vote = ระหวางโหวตพจารณาคด (Extension)
* trial_revote= หลงเฉลยถาจบผดต (Bail Coin)
* lobby_next = ใน LobbyB อนเลอกมเกมรอบหน (Ban)
*/
const SPECIAL_CARD_DEFS = {
1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' },
2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' },
3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' },
4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' },
5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' },
6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' },
7: { key: 'free_evidence', when: 'after_game', evidence: 2, th: 'ทุกคนรับหลักฐานฟรี +2 ใบ' },
};
function specialCardDef(cardId) {
return SPECIAL_CARD_DEFS[Number(cardId)] || null;
}
/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */
function specialCardClientPayload(card) {
if (!card) return null;
const def = specialCardDef(card.cardId) || {};
return {
cardId: card.cardId || null,
th: card.th || def.th || '',
en: card.en || '',
rarity: card.rarity || 'common',
imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''),
txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''),
effectKey: def.key || '',
effectWhen: def.when || '',
addSec: def.addSec || 0,
desc: def.th || '',
};
}
/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */
function applySpecialCardImmediate(sid, space, card) {
const def = specialCardDef(card && card.cardId);
if (!def) return;
if (def.when === 'minigame' && def.key === 'time_dilation') {
/* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง */
space.timeDilationPendingSec = (space.timeDilationPendingSec || 0) + (def.addSec || 10);
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(card),
addSec: def.addSec || 10,
});
consumePendingSpecialCard(space, card.cardId);
return;
}
if (def.when === 'now' && def.key === 'fund') {
/* +COINS — แต่ละ client บวกเหรียญของตัวเองผ่าน PHP (server ไม่รู้ playerKey) */
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(card),
coins: def.coins || 10,
});
consumePendingSpecialCard(space, card.cardId);
return;
}
/* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next)
คงไวใน pendingSpecialCard เพอใชภายหล */
}
function consumePendingSpecialCard(space, cardId) {
if (space.pendingSpecialCard && space.pendingSpecialCard.cardId === Number(cardId)) {
space.pendingSpecialCard.consumed = true;
}
}
function endSpecialQuizSession(sid, space, success) {
const sq = space.specialQuiz;
if (!sq) return;
@@ -692,7 +770,9 @@ function endSpecialQuizSession(sid, space, success) {
let cardOut = null;
if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) {
space.specialCardAwardedThisRun = sq.card;
cardOut = sq.card;
cardOut = specialCardClientPayload(sq.card);
space.pendingSpecialCard = { cardId: Number(sq.card.cardId) || null, card: sq.card, consumed: false };
applySpecialCardImmediate(sid, space, sq.card);
}
io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut });
resumeQuizAfterSpecialQuiz(sid, space);
@@ -707,8 +787,8 @@ function startSpecialQuizSession(sid, space) {
.map(specialQuizNormalizeQuestion)
.filter(Boolean);
if (!all.length) return false;
const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10);
const picked = specialQuizShuffle(all).slice(0, Math.max(1, Math.min(cap, all.length)));
/* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */
const picked = specialQuizShuffle(all).slice(0, 1);
sq.triggered = true;
sq.session = {
questions: picked,
@@ -2155,7 +2235,13 @@ function resumeQuizAfterSpecialQuiz(sid, space) {
if (!sess || !sess.active) return;
const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space);
if (!md) return;
sess.phaseEndsAt = Date.now() + remaining;
/* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */
let extraMs = 0;
if (space.timeDilationPendingSec > 0) {
extraMs = space.timeDilationPendingSec * 1000;
space.timeDilationPendingSec = 0;
}
sess.phaseEndsAt = Date.now() + remaining + extraMs;
if (phase === 'read') {
sess.phase = 'read';
const q = sess.questions[sess.qIndex];
@@ -2389,10 +2475,26 @@ function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) {
}
const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()];
const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids);
/* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */
let freeEvidenceCount = 0;
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 7 && !pend.consumed) {
const def = specialCardDef(7);
freeEvidenceCount = (def && def.evidence) || 2;
for (let n = 0; n < freeEvidenceCount; n++) {
ids.forEach((pid) => {
if (!space.peers.has(pid)) return;
const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx);
if (extra != null && awarded[pid] == null) awarded[pid] = extra;
});
}
pend.consumed = true;
}
space.detectiveAwardDoneForRun = true;
space.lastDetectiveInvestigatedSuspect = suspectIdx;
space.lastEvidenceGrant = { suspectIdx, awarded, freeEvidenceCount };
emitLobbyEvidenceSync(sid, space);
return { suspectIdx, awarded, alreadyGranted: false };
return { suspectIdx, awarded, alreadyGranted: false, freeEvidenceCount };
}
function emitLobbyEvidenceSync(sid, space) {
@@ -2432,6 +2534,9 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
space.suspectPhaseActive = false;
space.detectiveMinigameActive = true;
space.detectiveAwardDoneForRun = false;
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
space.bannedPlayerId = null;
space.detectiveMinigameCardIndex = cardEntry.cardIndex;
// จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ
space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2)
@@ -2486,6 +2591,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
peersSnap,
detectiveMinigame: true,
minigameLabel: cardEntry.labelTh,
bannedPlayerId: space.bannedThisRunPlayerId || null,
});
setTimeout(() => {
const spNow = spaces.get(sid);
@@ -2517,6 +2623,7 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
detectiveMinigame: true,
detectiveReturnLobbyB: true,
minigameLabel: cardEntry.labelTh,
bannedPlayerId: space.bannedThisRunPlayerId || null,
};
if (md.gameType === 'gauntlet' && space.gauntletRun) {
gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
@@ -2550,13 +2657,18 @@ function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
} else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) {
awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id));
}
/* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้ไม่ได้เล่น จึงไม่ได้รับหลักฐาน */
if (space.bannedThisRunPlayerId) {
awardIds = awardIds.filter((id) => id !== space.bannedThisRunPlayerId);
}
/* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */
if (!space.detectiveAwardDoneForRun && awardIds.length) {
awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, awardIds);
space.detectiveAwardDoneForRun = true;
/* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */
grantDetectiveEvidenceForCurrentRun(sid, space, awardIds);
}
}
space.suspectActiveIndex = null;
space.bannedThisRunPlayerId = null;
const lb = maps.get(POST_CASE_LOBBY_SPACE_ID);
if (!lb) {
io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true });
@@ -2597,17 +2709,34 @@ function emitTrialVoteUpdate(sid, space) {
io.to(sid).emit('trial-vote-update', {
counts,
voted: Object.keys(votes).length,
totalPlayers: testimonyTotalParticipants(space),
totalPlayers: trialEligibleVoterTotal(space),
});
}
function computeAndEmitTrialResult(sid, space) {
space.trialPhase = 'revealed';
/* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */
autoVoteBotsTrial(space);
clearTrialVoteTimer(space);
const counts = [0, 0, 0];
const votes = space.trialVotes || {};
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0;
/* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */
{
const c2 = [0, 0, 0];
Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; });
let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; }
const anyVotes = (c2[0] + c2[1] + c2[2]) > 0;
const wrong = anyVotes && mostVoted !== culprit;
const pend = space.pendingSpecialCard;
if (wrong && pend && Number(pend.cardId) === 5 && !pend.consumed && !space.trialBailUsed) {
pend.consumed = true;
space.trialBailUsed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' });
openTrialVotingPhase(sid, space, { revote: true });
return;
}
}
space.trialPhase = 'revealed';
const winners = [];
if (!space.trialScores) space.trialScores = {};
Object.keys(votes).forEach((id) => {
@@ -2634,6 +2763,7 @@ function computeAndEmitTrialResult(sid, space) {
winnerNames,
cardMinigames: space.suspectCardMinigames || [],
});
space.silencedPlayerId = null;
}
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */
@@ -2749,10 +2879,168 @@ function doTestimonyReveal(sid, space) {
}
/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */
/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */
const TRIAL_VOTE_MS = 30000;
const TRIAL_VOTE_EXTENSION_MS = 10000;
function clearTrialVoteTimer(space) {
if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; }
}
/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */
function openTrialVotingPhase(sid, space, opts) {
opts = opts || {};
space.testimonyActive = false;
space.trialPhase = 'voting';
space.trialVotes = {};
autoVoteBotsTrial(space);
clearTrialVoteTimer(space);
/* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */
let extraMs = 0;
let extensionCard = false;
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 2 && !pend.consumed) {
extraMs = TRIAL_VOTE_EXTENSION_MS;
extensionCard = true;
pend.consumed = true;
}
const durMs = TRIAL_VOTE_MS + extraMs;
space.trialVoteEndsAt = Date.now() + durMs;
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('trial-open', {
hostId: space.hostId,
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
voteEndsAt: space.trialVoteEndsAt,
voteDurationMs: durMs,
extensionCard,
extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0,
revote: !!opts.revote,
});
});
if (extensionCard) {
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(pend.card),
addSec: TRIAL_VOTE_EXTENSION_MS / 1000,
context: 'trial',
});
}
emitTrialVoteUpdate(sid, space);
space.trialVoteTimer = setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || spNow.trialPhase !== 'voting') return;
computeAndEmitTrialResult(sid, spNow);
}, durMs + 120);
}
/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */
const PICK_VOTE_MS = 15000;
function clearPickVoteTimer(space) {
if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; }
}
function pickVoteCandidates(space) {
const arr = [];
space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }));
testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true }));
return arr;
}
function autoVotePickBots(space) {
const pv = space.pickVote;
if (!pv) return;
testimonyBotIds(space).forEach((bid) => {
if (pv.votes[bid] != null) return;
const choices = pv.candidates.filter((c) => c.id !== bid);
if (!choices.length) return;
pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id;
});
}
function emitPickVoteUpdate(sid, space) {
const pv = space.pickVote;
if (!pv) return;
const counts = {};
Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; });
io.to(sid).emit('player-pick-vote-update', {
voted: Object.keys(pv.votes).length,
total: pv.candidates.length,
counts,
});
}
function startPlayerPickVote(sid, space, purpose, onResolve) {
clearPickVoteTimer(space);
const candidates = pickVoteCandidates(space);
space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PICK_VOTE_MS, resolved: false, onResolve };
autoVotePickBots(space);
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('player-pick-vote-open', {
purpose,
candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })),
endsAt: space.pickVote.endsAt,
durationMs: PICK_VOTE_MS,
});
});
emitPickVoteUpdate(sid, space);
space.pickVoteTimer = setTimeout(() => {
const sp = spaces.get(sid);
if (!sp || !sp.pickVote || sp.pickVote.resolved) return;
resolvePlayerPickVote(sid, sp);
}, PICK_VOTE_MS + 120);
maybeResolvePickVote(sid, space);
}
function maybeResolvePickVote(sid, space) {
const pv = space.pickVote;
if (!pv || pv.resolved) return;
const ids = pv.candidates.map((c) => c.id);
if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space);
}
function resolvePlayerPickVote(sid, space) {
const pv = space.pickVote;
if (!pv || pv.resolved) return;
pv.resolved = true;
clearPickVoteTimer(space);
const counts = {};
Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; });
let max = -1;
Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; });
const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max);
const target = maxIds.length ? maxIds[Math.floor(Math.random() * maxIds.length)] : null;
const tc = pv.candidates.find((c) => c.id === target);
const targetName = tc ? tc.nickname : '';
io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts });
const cb = pv.onResolve;
space.pickVote = null;
if (typeof cb === 'function') cb(target, targetName);
}
function trialSilencedIsParticipant(space) {
const s = space.silencedPlayerId;
if (!s) return false;
if (space.peers.has(s)) return true;
return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0);
}
function trialEligibleVoterTotal(space) {
let t = testimonyTotalParticipants(space);
if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1);
return t;
}
function autoVoteBotsTrial(space) {
if (!space.trialVotes) space.trialVotes = {};
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3);
testimonyBotIds(space).forEach((bid) => {
if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */
if (space.trialVotes[bid] != null) return;
/* 40% โหวตถูก, 60% โหวตสุ่ม */
let pick;
@@ -2768,22 +3056,22 @@ function autoVoteBotsTrial(space) {
function advanceTestimony(sid, space) {
space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1;
if (space.testimonyRound >= 3) {
space.testimonyActive = false;
space.trialPhase = 'voting';
space.trialVotes = {};
/* บอทโหวตทันทีเมื่อเข้าหน้าโหวต */
autoVoteBotsTrial(space);
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('trial-open', {
hostId: space.hostId,
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
space.trialBailUsed = false;
space.silencedPlayerId = null;
/* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 4 && !pend.consumed) {
pend.consumed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' });
startPlayerPickVote(sid, space, 'silence', (targetId) => {
const sp = spaces.get(sid);
if (!sp) return;
sp.silencedPlayerId = targetId || null;
openTrialVotingPhase(sid, sp, { revote: false });
});
});
emitTrialVoteUpdate(sid, space);
return;
}
openTrialVotingPhase(sid, space, { revote: false });
} else {
emitTestimonyOpen(sid, space);
}
@@ -5335,6 +5623,11 @@ io.on('connection', (socket) => {
console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id);
});
/** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
socket.on('special-quiz-testmode', (data) => {
socket.data.testMode = !!(data && data.on);
});
socket.on('special-quiz-collide', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
@@ -5354,9 +5647,16 @@ io.on('connection', (socket) => {
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
const sess = space.specialQuiz && space.specialQuiz.session;
if (!sess || sess.resolving) return reply({ ok: false });
const choice = Number(data && data.choiceIndex);
const q = sess.questions[sess.qIndex];
if (!q || !Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) {
if (!q) return reply({ ok: false });
/* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */
let choice;
if (data && data.debugCorrect && socket.data.testMode) {
choice = q.correctIndex;
} else {
choice = Number(data && data.choiceIndex);
}
if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) {
return reply({ ok: false });
}
if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' });
@@ -5520,6 +5820,22 @@ io.on('connection', (socket) => {
if (!cardEntry || !cardEntry.mapId) {
return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' });
}
/* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 3 && !pend.consumed && !space.pickVote) {
pend.consumed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' });
startPlayerPickVote(sid, space, 'ban', (targetId) => {
const sp = spaces.get(sid);
if (!sp) return;
sp.bannedPlayerId = targetId || null;
const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex);
if (!res || !res.ok) {
io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true });
}
});
return reply({ ok: true, banVote: true });
}
const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex);
reply(started);
});
@@ -5539,6 +5855,7 @@ io.on('connection', (socket) => {
alreadyOnLobbyB: true,
suspectIndex: grant.suspectIdx,
awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
freeEvidenceCount: grant.freeEvidenceCount || 0,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
@@ -5547,8 +5864,12 @@ io.on('connection', (socket) => {
}
// เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
const g = space.lastEvidenceGrant || {};
reply({
ok: true,
suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
freeEvidenceCount: g.freeEvidenceCount || 0,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
@@ -5684,12 +6005,13 @@ io.on('connection', (socket) => {
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' });
if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true });
let idx = Math.floor(Number(data && data.index));
if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' });
space.trialVotes = space.trialVotes || {};
space.trialVotes[socket.id] = idx;
emitTrialVoteUpdate(sid, space);
const totalTV = testimonyTotalParticipants(space);
const totalTV = trialEligibleVoterTotal(space);
if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) {
computeAndEmitTrialResult(sid, space);
}
@@ -5708,6 +6030,22 @@ io.on('connection', (socket) => {
reply({ ok: true });
});
/* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */
socket.on('player-pick-vote', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const pv = space.pickVote;
if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' });
const tid = String((data && data.targetId) || '');
if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' });
pv.votes[socket.id] = tid;
reply({ ok: true, targetId: tid });
emitPickVoteUpdate(sid, space);
maybeResolvePickVote(sid, space);
});
/** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */
socket.on('host-console-apply', (data, cb) => {
const sid = socket.data.spaceId;