update flow design 1.3

This commit is contained in:
2026-05-27 17:44:46 +00:00
parent 121eb695b9
commit 8fb34cde3d
7 changed files with 263 additions and 54 deletions
+30 -22
View File
@@ -358,18 +358,24 @@
#testimony-overlay .es3-cards {
left: calc(200 / var(--es3-w) * 100%);
top: calc(635 / var(--es3-h) * 100%);
width: calc(800 / var(--es3-w) * 100%);
height: calc(380 / var(--es3-h) * 100%);
/* ขยายให้พอ 3 ใบเรียงพอดี = 252*3 + 22*2 = 800px stage */
width: 800px;
height: 380px;
display: flex;
gap: calc(22 / var(--es3-w) * 100%);
justify-content: flex-start;
align-items: stretch;
gap: 22px;
z-index: 3;
}
#testimony-overlay .es3-card {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
/* คงสัดส่วน portrait — ไม่ยืดเต็มจอเมื่อเก็บได้แค่ใบเดียว; 252×380 stage px ตรงตามดีไซน์ */
flex: 0 0 252px;
width: 252px;
min-width: 252px;
max-width: 252px;
height: 380px;
border-radius: 12px;
background: linear-gradient(180deg, #cfd5dd 0%, #9ea7b4 100%);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
@@ -718,16 +724,18 @@
}
#testimony-overlay .es-reveal-wrap .es-players {
left: 120px;
top: 300px;
width: 1680px;
height: 650px;
display: grid;
grid-template-columns: repeat(5, 1fr);
column-gap: 24px;
padding: 0 70px;
left: 200px;
top: 290px;
width: 1520px;
height: 720px;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: flex-start;
column-gap: 32px;
row-gap: 18px;
padding: 0;
box-sizing: border-box;
align-items: start;
z-index: 6;
}
@@ -771,19 +779,19 @@
#testimony-overlay .es-reveal-wrap .es-card {
position: relative;
width: 220px;
max-width: 220px;
height: 275px;
width: 252px;
min-width: 252px;
max-width: 252px;
height: 320px;
flex-shrink: 0;
border-radius: 10px;
background: linear-gradient(180deg, #cfd5dd 0%, #b2bac4 100%);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
border-radius: 12px;
background: linear-gradient(180deg, #cfd5dd 0%, #9ea7b4 100%);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
overflow: hidden;
color: #1b2030;
border: 3px solid rgba(150, 200, 255, 0.7);
flex-shrink: 0;
}
#testimony-overlay .es-reveal-wrap .es-card.rarity-common {
Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

+44 -4
View File
@@ -813,8 +813,8 @@
const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
let lineInArt = Number(raw.finishLineInFinishArtFrac);
if (!Number.isFinite(lineInArt)) {
/* mno9kb07: จบที่ประตูตู้เซฟ (ขวาของ finish art) ไม่ใช่แถบ FINISH ด้านซ้าย */
lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.72 : 0.12;
/* mno9kb07: จบที่ "แถบ FINISH" สีเหลือง (กลาง-ซ้ายของ finish art) ไม่ใช่ตู้เซฟ */
lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.22 : 0.12;
}
lineInArt = Math.max(0, Math.min(0.92, lineInArt));
const finishLineWorldX = g.finishStart + g.w4Safe * lineInArt;
@@ -831,13 +831,15 @@
legacy = Math.max(0.08, Math.min(0.96, legacy));
return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42));
}
return gauntletCrownRunwayBgMapEligiblePlay() ? 0.5 : 0.34;
/* mno9kb07: หยุดเลื่อนที่ ~0.42 (เส้น FINISH เลื่อนมาอยู่กลางจอ) */
return gauntletCrownRunwayBgMapEligiblePlay() ? 0.42 : 0.34;
}
function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() {
const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
let v = Number(raw.obstacleFadeStartViewportFrac);
if (!Number.isFinite(v)) v = 0.9;
/* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */
if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9;
return Math.max(0.45, Math.min(1.15, v));
}
@@ -2695,6 +2697,41 @@
let playLiveQuizScores = {};
/** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */
let playQuizEverWrong = {};
/** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */
const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png';
let quizWrongGhostImg = null;
function getQuizWrongGhostImg() {
if (quizWrongGhostImg) return quizWrongGhostImg;
const img = new Image();
img.decoding = 'async';
img.src = QUIZ_WRONG_GHOST_URL;
quizWrongGhostImg = img;
return img;
}
/** วาด "ผี" คุมหัวผู้เล่นในแมป mng8a80o เมื่อตอบผิด (ใช้กับ me/peers) */
function drawQuizWrongGhostOverlayPlay(ax, ay, entId) {
if (!isQuizQuestionMissionUiMapPlay()) return;
const sid = String(entId == null ? '' : entId);
const isMeEnt = (myId != null && sid === String(myId));
const wrong = isMeEnt
? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse))
: !!playQuizEverWrong[sid];
if (!wrong) return;
const img = getQuizWrongGhostImg();
if (!img || !img.complete || !img.naturalWidth) return;
/* ขนาด: ~1.6 tile (ใหญ่กว่าหัวเล็กน้อย แต่ไม่บัง avatar ทั้งตัว); ลอยเหนือหัว */
const tile = (typeof tileSize === 'number' && tileSize > 0) ? tileSize : 32;
const wPx = tile * 1.6;
const ratio = img.naturalHeight / Math.max(1, img.naturalWidth);
const hPx = wPx * ratio;
const tBob = Math.sin(performance.now() / 280) * (tile * 0.06);
const drawX = ax + (tile - wPx) / 2;
const drawY = ay - hPx * 0.78 + tBob;
ctx.save();
ctx.globalAlpha = 0.92;
ctx.drawImage(img, drawX, drawY, wPx, hPx);
ctx.restore();
}
/** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */
const QUIZ_TF_POINTS_PER_CORRECT = 10;
const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png';
@@ -8340,6 +8377,7 @@
return;
}
const tint = getPlayTintForMissionAvatar(peerId != null ? peerId : ((myId != null && myId !== '') ? myId : 'local'));
const isMePeer = (peerId != null && myId != null && String(peerId) === String(myId));
const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer);
if (out && out.tagName === 'CANVAS' && out.width > 0) {
try {
@@ -18435,6 +18473,7 @@
if (botOut) ctx.save();
if (botOut) ctx.globalAlpha = 0.4;
drawAvatar(axO, ayO, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o), isGauntletCrownHeistMapPlay() ? (o.gauntletCrownPenaltyFxUntil || 0) : 0);
drawQuizWrongGhostOverlayPlay(axO, ayO, id);
if (botOut) ctx.restore();
} else {
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
@@ -18445,6 +18484,7 @@
if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
drawAvatar(axMe, ayMe, true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me), isGauntletCrownHeistMapPlay() ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
drawQuizWrongGhostOverlayPlay(axMe, ayMe, myId);
if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
}
});
+68 -12
View File
@@ -4600,7 +4600,20 @@
}
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (เลือกหลักฐาน 2 ใบ/ปากคำ ก่อนโหวต) ===== */
var tmState = { round: 0, members: [], picks: [], submitted: false, mode: 'select', submittedPicksById: {} };
var tmState = { round: 0, members: [], picks: [], submitted: false, mode: 'select', submittedPicksById: {}, ownedSlots: [], requiredPicks: 2 };
/** สล็อตหลักฐานที่ผู้เล่นเก็บได้สำหรับ suspect ตามรอบ (เช่น [0,2]) */
function tmOwnedSlotsForRound(round) {
var i = Math.floor(Number(round));
if (Number.isNaN(i) || i < 0 || i > 2) i = 0;
var row = (myPlayerEvidence && myPlayerEvidence[i]) || [];
var out = [];
row.forEach(function (s) {
var v = Math.floor(Number(s));
if (v >= 0 && v <= 2 && out.indexOf(v) === -1) out.push(v);
});
return out;
}
var SUSPECT_NAMES_EN = ['SOMCHAI', 'ARIN', 'JIRAPHA'];
function ensureTestimonyStyle() {
@@ -4739,31 +4752,49 @@
var card = e.target.closest('.es3-card'); if (!card || tmState.submitted) return;
var idx = parseInt(card.getAttribute('data-card-index'), 10);
if (!(idx >= 0)) return;
var maxPicks = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var pos = tmState.picks.indexOf(idx);
if (pos >= 0) { tmState.picks.splice(pos, 1); card.classList.remove('is-selected'); }
else { if (tmState.picks.length >= 2) return; tmState.picks.push(idx); card.classList.add('is-selected'); }
else { if (tmState.picks.length >= maxPicks) return; tmState.picks.push(idx); card.classList.add('is-selected'); }
es3UpdateCount();
});
var submitEl = ov.querySelector('#es3BtnSubmit');
if (submitEl) submitEl.addEventListener('click', function () {
if (tmState.picks.length !== 2 || tmState.submitted) return;
socket.emit('testimony-submit', { picks: tmState.picks.slice(0, 2) }, function (res) {
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
if (tmState.picks.length !== req || tmState.submitted) return;
socket.emit('testimony-submit', { picks: tmState.picks.slice(0, req) }, function (res) {
if (res && res.ok) {
tmState.submitted = true;
if (!tmState.submittedPicksById) tmState.submittedPicksById = {};
tmState.submittedPicksById[socket.id] = tmState.picks.slice(0, 2);
tmState.submittedPicksById[socket.id] = tmState.picks.slice(0, req);
es3SetStatus('ส่งหลักฐานแล้ว — รอผู้เล่นอื่น...');
var sub = document.getElementById('es3BtnSubmit'); if (sub) sub.disabled = true;
} else appendLobbySystemChat('— ส่งหลักฐานไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : ''));
});
});
var openEl = ov.querySelector('#es3BtnOpen');
if (openEl) {
openEl.addEventListener('click', function () {
function autoSubmitThenReveal() {
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var doReveal = function () {
socket.emit('testimony-reveal', {}, function (res) { if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); });
});
};
/* ถ้ายังไม่ submit แต่เลือกครบตาม required แล้ว — submit ให้ก่อน reveal */
if (!tmState.submitted && tmState.ownedSlots.length > 0 && tmState.picks.length === req) {
socket.emit('testimony-submit', { picks: tmState.picks.slice(0, req) }, function (res) {
if (res && res.ok) {
tmState.submitted = true;
tmState.submittedPicksById[socket.id] = tmState.picks.slice(0, req);
}
doReveal();
});
} else {
doReveal();
}
}
if (openEl) {
openEl.addEventListener('click', autoSubmitThenReveal);
openEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.click(); }
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); autoSubmitThenReveal(); }
});
}
var readyEl = ov.querySelector('#esBtnReady');
@@ -4788,8 +4819,9 @@
}
function es3UpdateCount() {
var el = document.getElementById('es3-count'); if (el) el.textContent = 'เลือกหลักฐาน ' + tmState.picks.length + '/2';
var sub = document.getElementById('es3BtnSubmit'); if (sub) sub.disabled = (tmState.picks.length !== 2 || tmState.submitted);
var req = Math.max(1, Math.min(2, tmState.requiredPicks || 2));
var el = document.getElementById('es3-count'); if (el) el.textContent = 'เลือกหลักฐาน ' + tmState.picks.length + '/' + req;
var sub = document.getElementById('es3BtnSubmit'); if (sub) sub.disabled = (tmState.picks.length !== req || tmState.submitted);
}
function esRevealCardEl(c, linkName, cardIdx) {
@@ -4924,6 +4956,8 @@
function openTestimony(data) {
if (typeof closeSuspectOverlay === 'function') closeSuspectOverlay();
/* ซิงค์หลักฐานของตัวเองจาก server ก่อน (กันรอบที่ play จบใหม่ๆ แล้ว state ใน room-lobby ยังไม่อัปเดต) */
if (data && Array.isArray(data.myPlayerEvidence)) applyMyPlayerEvidence(data.myPlayerEvidence);
var ov = ensureTestimonyOverlay();
tmState.round = (data && typeof data.round === 'number') ? data.round : 0;
tmState.members = (data && Array.isArray(data.members)) ? data.members : [];
@@ -4931,6 +4965,9 @@
tmState.submitted = false;
tmState.submittedPicksById = {};
tmState.mode = 'select';
tmState.ownedSlots = tmOwnedSlotsForRound(tmState.round);
/* ต้องเลือกหลักฐาน = min(2, จำนวนที่เก็บได้) — ถ้าได้ 1 ใบ ส่ง 1 ใบพอ */
tmState.requiredPicks = Math.max(1, Math.min(2, tmState.ownedSlots.length));
var SC = '/Main-Lobby/IMAGE/Showcard/';
var CS = '/Main-Lobby/IMAGE/Choose%20a%20suspect/';
var sd = tmSuspectData(tmState.round);
@@ -4953,7 +4990,26 @@
}
es3SetStatus('กำลังรอ..ผู้เล่นเลือกหลักฐาน');
var cardsRoot = document.getElementById('es3-cards'); cardsRoot.textContent = '';
(sd.cards || []).forEach(function (c, i) { cardsRoot.appendChild(es3EvidenceCardEl(c, sd.linkName, i, true)); });
/* แสดงเฉพาะการ์ดที่ผู้เล่นเก็บได้จริง (ตามสล็อตจาก myPlayerEvidence) */
if (tmState.ownedSlots.length === 0) {
var empty = document.createElement('div');
empty.className = 'es3-empty-hint';
empty.textContent = 'ยังไม่มีหลักฐานของ ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1))) + ' — รอเพื่อนเลือกหลักฐาน';
cardsRoot.appendChild(empty);
/* auto-submit ว่างเพื่อไม่บล็อกรอบ */
socket.emit('testimony-submit', { picks: [] }, function (res) {
if (res && res.ok) {
tmState.submitted = true;
tmState.submittedPicksById[socket.id] = [];
es3SetStatus('ไม่มีหลักฐาน — ข้ามอัตโนมัติ');
}
});
} else {
tmState.ownedSlots.forEach(function (slot) {
var c = (sd.cards || [])[slot];
if (c) cardsRoot.appendChild(es3EvidenceCardEl(c, sd.linkName, slot, true));
});
}
es3UpdateCount();
es3RenderMembers([], [], false);
ov.classList.remove('is-hidden');
+1 -1
View File
@@ -3214,7 +3214,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.0465"></script>
<script src="js/play.js?v=0.0468"></script>
<div class="version-tag">v —</div>
</body>
</html>
+2 -2
View File
@@ -11,7 +11,7 @@
<link rel="stylesheet" href="css/profile-popup.css?v=6">
<link rel="stylesheet" href="css/customize-popup.css?v=42">
<link rel="stylesheet" href="css/leaderboard-popup.css?v=2">
<link rel="stylesheet" href="css/testimony-overlay.css?v=8">
<link rel="stylesheet" href="css/testimony-overlay.css?v=11">
<link rel="stylesheet" href="css/evidence-view-overlay.css?v=12">
<style>
html, body.room-lobby-page { margin: 0; padding: 0; overflow: hidden; width: 100%; height: 100%; min-height: 100%; }
@@ -1576,7 +1576,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.0243"></script>
<script src="js/room-lobby.js?v=0.0246"></script>
<div class="version-tag">v —</div>
</body>
</html>
+118 -13
View File
@@ -2088,12 +2088,14 @@ function emitTrialVoteUpdate(sid, space) {
io.to(sid).emit('trial-vote-update', {
counts,
voted: Object.keys(votes).length,
totalPlayers: space.peers ? space.peers.size : 0,
totalPlayers: testimonyTotalParticipants(space),
});
}
function computeAndEmitTrialResult(sid, space) {
space.trialPhase = 'revealed';
/* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */
autoVoteBotsTrial(space);
const counts = [0, 0, 0];
const votes = space.trialVotes || {};
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0;
@@ -2109,7 +2111,12 @@ function computeAndEmitTrialResult(sid, space) {
});
const winnerNames = winners.map((id) => {
const p = space.peers.get(id);
return (p && p.nickname) ? p.nickname : String(id).slice(0, 6);
if (p && p.nickname) return p.nickname;
if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) {
const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10);
return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท';
}
return String(id).slice(0, 6);
});
io.to(sid).emit('trial-result', {
culpritIndex: culprit,
@@ -2121,23 +2128,75 @@ function computeAndEmitTrialResult(sid, space) {
}
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */
/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */
const TESTIMONY_BOT_PREFIX = '__lobby_bot_';
function testimonyBotIds(space) {
const n = effectiveBotSlotCount(space);
const out = [];
for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i);
return out;
}
function buildTestimonyMembers(space) {
const arr = [];
space.peers.forEach((p, id) => {
arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId });
arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false });
});
testimonyBotIds(space).forEach((bid, i) => {
arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true });
});
return arr;
}
/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */
function testimonyTotalParticipants(space) {
return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
}
/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */
function botTestimonyPickCount(space) {
const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0;
let maxOwned = 0;
space.peers.forEach((_p, pid) => {
const ev = clonePlayerEvidence(space, pid);
const len = Array.isArray(ev[round]) ? ev[round].length : 0;
if (len > maxOwned) maxOwned = len;
});
/* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */
if (maxOwned === 0) return 1;
return Math.max(1, Math.min(2, maxOwned));
}
/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — จำนวนใบเท่ากับผู้เล่นจริง */
function autoSubmitBotTestimonyPicks(space) {
if (!space.testimonySubmitted) space.testimonySubmitted = {};
const slots = [0, 1, 2];
const want = botTestimonyPickCount(space);
testimonyBotIds(space).forEach((bid) => {
if (space.testimonySubmitted[bid] != null) return;
const pool = slots.slice();
const picks = [];
while (picks.length < want && pool.length) {
const idx = Math.floor(Math.random() * pool.length);
picks.push(pool.splice(idx, 1)[0]);
}
picks.sort((a, b) => a - b);
space.testimonySubmitted[bid] = picks;
});
}
function emitTestimonyOpen(sid, space) {
space.testimonySubmitted = {};
space.testimonyReadyNext = {};
space.testimonyRevealed = false;
/* บอทส่งหลักฐานทันทีเมื่อเปิด round */
autoSubmitBotTestimonyPicks(space);
const base = {
round: space.testimonyRound,
suspectIndex: space.testimonyRound,
members: buildTestimonyMembers(space),
totalPlayers: space.peers.size,
totalPlayers: testimonyTotalParticipants(space),
hostId: space.hostId,
cardMinigames: space.suspectCardMinigames || [],
};
@@ -2150,22 +2209,47 @@ function emitTestimonyOpen(sid, space) {
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
});
});
/* ถ้ามีแต่บอท (ไม่มีผู้เล่นจริง) ก็เปิด reveal เลย — แต่ปกติจะมีคนกด reveal เอง */
}
function emitTestimonyStatus(sid, space) {
io.to(sid).emit('testimony-status', {
round: space.testimonyRound,
submitted: Object.keys(space.testimonySubmitted || {}),
totalPlayers: space.peers.size,
totalPlayers: testimonyTotalParticipants(space),
});
}
function doTestimonyReveal(sid, space) {
space.testimonyRevealed = true;
/* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */
autoSubmitBotTestimonyPicks(space);
/* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; });
io.to(sid).emit('testimony-reveal', {
round: space.testimonyRound,
picks: space.testimonySubmitted || {},
members: buildTestimonyMembers(space),
ready: Object.keys(space.testimonyReadyNext),
totalPlayers: testimonyTotalParticipants(space),
});
}
/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */
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 (space.trialVotes[bid] != null) return;
/* 40% โหวตถูก, 60% โหวตสุ่ม */
let pick;
if (Math.random() < 0.4) {
pick = culprit;
} else {
pick = Math.floor(Math.random() * 3);
}
space.trialVotes[bid] = pick;
});
}
@@ -2175,6 +2259,8 @@ function advanceTestimony(sid, space) {
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;
@@ -4902,7 +4988,7 @@ io.on('connection', (socket) => {
reply({ ok: true });
});
// ผู้เล่นส่งหลักฐาน 2 ใบในปากคำปัจจุบัน
// ผู้เล่นส่งหลักฐาน 12 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง
socket.on('testimony-submit', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
@@ -4911,13 +4997,30 @@ io.on('connection', (socket) => {
if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' });
if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' });
const raw = (data && Array.isArray(data.picks)) ? data.picks : [];
const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []);
const required = Math.max(1, Math.min(2, owned.length));
const picks = [];
raw.forEach((n) => { const v = Math.floor(Number(n)); if (v >= 0 && v <= 8 && picks.indexOf(v) === -1) picks.push(v); });
if (picks.length !== 2) return reply({ ok: false, error: 'ต้องเลือกหลักฐาน 2 ใบ' });
raw.forEach((n) => {
const v = Math.floor(Number(n));
if (v >= 0 && v <= 2 && picks.indexOf(v) === -1 && owned.indexOf(v) >= 0) picks.push(v);
});
if (owned.length === 0) {
/* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */
if (!space.testimonySubmitted) space.testimonySubmitted = {};
space.testimonySubmitted[socket.id] = [];
emitTestimonyStatus(sid, space);
const totalP0 = testimonyTotalParticipants(space);
if (Object.keys(space.testimonySubmitted).length >= totalP0 && totalP0 > 0) doTestimonyReveal(sid, space);
return reply({ ok: true, skipped: true });
}
if (picks.length !== required) {
return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' });
}
if (!space.testimonySubmitted) space.testimonySubmitted = {};
space.testimonySubmitted[socket.id] = picks.slice(0, 2);
space.testimonySubmitted[socket.id] = picks.slice(0, required);
emitTestimonyStatus(sid, space);
if (Object.keys(space.testimonySubmitted).length >= space.peers.size && space.peers.size > 0) doTestimonyReveal(sid, space);
const totalP = testimonyTotalParticipants(space);
if (Object.keys(space.testimonySubmitted).length >= totalP && totalP > 0) doTestimonyReveal(sid, space);
reply({ ok: true });
});
@@ -4942,8 +5045,9 @@ io.on('connection', (socket) => {
if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' });
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
space.testimonyReadyNext[socket.id] = true;
io.to(sid).emit('testimony-ready-update', { ready: Object.keys(space.testimonyReadyNext), totalPlayers: space.peers.size });
if (Object.keys(space.testimonyReadyNext).length >= space.peers.size && space.peers.size > 0) advanceTestimony(sid, space);
const totalRN = testimonyTotalParticipants(space);
io.to(sid).emit('testimony-ready-update', { ready: Object.keys(space.testimonyReadyNext), totalPlayers: totalRN });
if (Object.keys(space.testimonyReadyNext).length >= totalRN && totalRN > 0) advanceTestimony(sid, space);
reply({ ok: true });
});
@@ -4959,7 +5063,8 @@ io.on('connection', (socket) => {
space.trialVotes = space.trialVotes || {};
space.trialVotes[socket.id] = idx;
emitTrialVoteUpdate(sid, space);
if (Object.keys(space.trialVotes).length >= space.peers.size && space.peers.size > 0) {
const totalTV = testimonyTotalParticipants(space);
if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) {
computeAndEmitTrialResult(sid, space);
}
reply({ ok: true, index: idx });