diff --git a/www/html/Admin/api/daily-reward.php b/www/html/Admin/api/daily-reward.php new file mode 100644 index 0000000..bd3a0bb --- /dev/null +++ b/www/html/Admin/api/daily-reward.php @@ -0,0 +1,259 @@ + daily_midnight_ms($nowMs), + 'claimedDays' => array_fill(0, DAILY_TOTAL_DAYS, false), + 'lockUntilMs' => 0, + ]; +} + +/** ทำให้ state ถูกต้อง (รีเซ็ตรอบใหม่ถ้าถึงเวลา) แล้วคำนวณ currentDay/msUntilReset */ +function daily_compute(array &$daily, int $nowMs): array +{ + if (!is_array($daily['claimedDays'] ?? null) || count($daily['claimedDays']) !== DAILY_TOTAL_DAYS) { + $daily = daily_default_state($nowMs); + } + // normalize types + $claimed = []; + for ($i = 0; $i < DAILY_TOTAL_DAYS; $i++) { + $claimed[$i] = !empty($daily['claimedDays'][$i]); + } + $daily['claimedDays'] = $claimed; + $daily['anchorMs'] = (int) ($daily['anchorMs'] ?? daily_midnight_ms($nowMs)); + $daily['lockUntilMs'] = (int) ($daily['lockUntilMs'] ?? 0); + + $changed = false; + + // วันแรกที่ยังไม่เคลม + $firstUnclaimed = 0; + for ($i = 0; $i < DAILY_TOTAL_DAYS; $i++) { + if (!$claimed[$i]) { $firstUnclaimed = $i + 1; break; } + } + + $nowMidnight = daily_midnight_ms($nowMs); + $remainMs = max(0, daily_next_midnight_ms($nowMs) - $nowMs); + + // self-heal: เคลมครบแต่ไม่มี lock → ตั้ง lock ถึงเที่ยงคืนถัดไป + if (!$firstUnclaimed && $daily['lockUntilMs'] <= 0) { + $daily['lockUntilMs'] = daily_next_midnight_ms($nowMs); + $changed = true; + } + + // เคลมครบ 7 วัน + ถึงเวลารีเซ็ตแล้ว → เริ่มรอบใหม่ + if (!$firstUnclaimed && $daily['lockUntilMs'] > 0 && $nowMs >= $daily['lockUntilMs']) { + $daily['anchorMs'] = $nowMidnight; + $daily['claimedDays'] = array_fill(0, DAILY_TOTAL_DAYS, false); + $daily['lockUntilMs'] = 0; + $claimed = $daily['claimedDays']; + $firstUnclaimed = 1; + $changed = true; + } + + // วันที่ปลดล็อกตามเวลา (เพิ่มทีละวันทุกเที่ยงคืน) + $anchorMidnight = daily_midnight_ms((int) $daily['anchorMs']); + $dayIndex = (int) max(0, floor(($nowMidnight - $anchorMidnight) / 86400000)); + $unlockedByTime = min(DAILY_TOTAL_DAYS, $dayIndex + 1); + + $lockActive = $daily['lockUntilMs'] > $nowMs; + if (!$lockActive && $daily['lockUntilMs'] > 0) { + $daily['lockUntilMs'] = 0; + $changed = true; + } + + $currentDay = 0; + if ($firstUnclaimed > 0 && $firstUnclaimed <= $unlockedByTime && !$lockActive) { + $currentDay = $firstUnclaimed; + } + + return [ + 'changed' => $changed, + 'currentDay' => $currentDay, + 'msUntilReset' => $remainMs, + 'firstUnclaimed' => $firstUnclaimed, + ]; +} + +function daily_public_state(array $daily, array $computed, int $coins, int $nowMs): array +{ + return [ + 'ok' => true, + 'claimedDays' => array_values($daily['claimedDays']), + 'anchorMs' => (int) $daily['anchorMs'], + 'lockUntilMs' => (int) $daily['lockUntilMs'], + 'currentDay' => (int) $computed['currentDay'], + 'msUntilReset' => (int) $computed['msUntilReset'], + 'serverNowMs' => $nowMs, + 'coins' => $coins, + 'rewards' => DAILY_REWARDS, + ]; +} + +function daily_validate_key(string $key): void +{ + if (!preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key)) { + json_response(['ok' => false, 'error' => 'playerKey ต้องยาว 8–128 (a-z 0-9 _ -)'], 400); + } +} + +/** หา index บัญชี guest ตาม playerKey (คืน -1 ถ้าไม่มี) */ +function daily_find_account(array $store, string $key): int +{ + foreach ($store['accounts'] ?? [] as $i => $a) { + if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) { + return $i; + } + } + return -1; +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$nowMs = daily_now_ms(); + +if ($method === 'GET') { + $key = trim((string) ($_GET['playerKey'] ?? '')); + daily_validate_key($key); + + $store = read_store(); + $idx = daily_find_account($store, $key); + + if ($idx < 0) { + // ยังไม่มีบัญชี → คืน state เริ่มต้น (ไม่เขียน store จนกว่าจะเคลมจริง) + $daily = daily_default_state($nowMs); + $computed = daily_compute($daily, $nowMs); + json_response(daily_public_state($daily, $computed, 0, $nowMs)); + } + + $acc = $store['accounts'][$idx]; + $daily = is_array($acc['daily'] ?? null) ? $acc['daily'] : daily_default_state($nowMs); + $computed = daily_compute($daily, $nowMs); + $coins = max(0, (int) ($acc['coins'] ?? 0)); + + if ($computed['changed']) { + $store['accounts'][$idx]['daily'] = $daily; + $store['accounts'][$idx]['updatedAt'] = gmdate('c'); + write_store($store); + } + + json_response(daily_public_state($daily, $computed, $coins, $nowMs)); +} + +if ($method !== 'POST') { + json_response(['ok' => false, 'error' => 'Use GET or POST'], 405); +} + +// ---- POST: เคลมรางวัล ---- +$body = require_json_body(); +$key = trim((string) ($body['playerKey'] ?? '')); +daily_validate_key($key); + +$day = (int) ($body['day'] ?? 0); +if ($day < 1 || $day > DAILY_TOTAL_DAYS) { + json_response(['ok' => false, 'error' => 'day ต้องอยู่ระหว่าง 1–' . DAILY_TOTAL_DAYS], 400); +} + +$store = read_store(); +$idx = daily_find_account($store, $key); + +if ($idx < 0) { + // สร้างบัญชี guest ใหม่พร้อม daily state + $idx = count($store['accounts'] ?? []); + $store['accounts'][] = [ + 'id' => new_id(), + 'email' => '', + 'displayName' => 'Guest', + 'loginType' => 'guest', + 'providerUserId' => $key, + 'notes' => 'auto: daily-reward', + 'blocked' => false, + 'coins' => 0, + 'daily' => daily_default_state($nowMs), + 'createdAt' => gmdate('c'), + 'updatedAt' => gmdate('c'), + ]; +} + +$acc = $store['accounts'][$idx]; +if (!empty($acc['blocked'])) { + json_response(['ok' => false, 'error' => 'บัญชีถูกระงับ'], 403); +} + +$daily = is_array($acc['daily'] ?? null) ? $acc['daily'] : daily_default_state($nowMs); +$computed = daily_compute($daily, $nowMs); + +// ตรวจสอบ server-side: วันที่เคลมต้องเป็น "วันที่กดได้" จริงในตอนนี้ +if ($computed['currentDay'] !== $day) { + $coinsNow = max(0, (int) ($acc['coins'] ?? 0)); + $resp = daily_public_state($daily, $computed, $coinsNow, $nowMs); + $resp['ok'] = false; + $resp['error'] = $daily['claimedDays'][$day - 1] + ? 'รับรางวัลวันนี้ไปแล้ว รอรีเซ็ตรอบถัดไป' + : 'ยังเปิดรับรางวัลวันนี้ไม่ได้'; + // เขียน state ที่ normalize แล้ว (เผื่อมีการรีเซ็ต) ก่อนตอบ + $store['accounts'][$idx]['daily'] = $daily; + $store['accounts'][$idx]['updatedAt'] = gmdate('c'); + write_store($store); + json_response($resp, 409); +} + +// ผ่านการตรวจสอบ → เคลม + บวกเหรียญจริง +$reward = DAILY_REWARDS[$day - 1] ?? 0; +$daily['claimedDays'][$day - 1] = true; +$daily['lockUntilMs'] = daily_next_midnight_ms($nowMs); + +$coins = max(0, (int) ($acc['coins'] ?? 0)) + $reward; +$store['accounts'][$idx]['coins'] = $coins; +$store['accounts'][$idx]['daily'] = $daily; +$store['accounts'][$idx]['updatedAt'] = gmdate('c'); + +if (!write_store($store)) { + json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500); +} + +// คำนวณสถานะใหม่หลังเคลม (currentDay จะเลื่อนเป็น 0 เพราะ lock แล้ว) +$computedAfter = daily_compute($daily, $nowMs); +$resp = daily_public_state($daily, $computedAfter, $coins, $nowMs); +$resp['claimedDay'] = $day; +$resp['reward'] = $reward; +$resp['added'] = $reward; +json_response($resp); diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json index f49ac5e..9ef507a 100644 --- a/www/html/Admin/private/store.json +++ b/www/html/Admin/private/store.json @@ -38,9 +38,22 @@ "providerUserId": "p_1775109142385_wq7wfy1p32j", "notes": "auto: player-coins", "blocked": false, - "coins": 0, + "coins": 10, "createdAt": "2026-04-02T05:52:21+00:00", - "updatedAt": "2026-04-02T05:52:21+00:00" + "updatedAt": "2026-06-12T06:21:53+00:00", + "daily": { + "anchorMs": 1781197200000, + "claimedDays": [ + true, + false, + false, + false, + false, + false, + false + ], + "lockUntilMs": 1781283600000 + } }, { "id": "1d64c56fadb64a93eae68a1d", diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 2c8e661..50b1222 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -378,7 +378,7 @@ ov.id = 'evidence-card-lightbox'; ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)'; ov.innerHTML = - '' + + '' + '
' + '' + '
' + diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 755ed68..8c89b3e 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -4903,7 +4903,7 @@ appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names); // โชว์คะแนนโหวตสักครู่ แล้วไปหน้าผลเต็มเสมอ (ถูก = เรือนจำไซเบอร์ / ผิด/ไม่โหวต = ภารกิจล้มเหลว) if (trialFinalTimer) clearTimeout(trialFinalTimer); - trialFinalTimer = setTimeout(function () { showFinalResult(correct, culprit); }, 2600); + trialFinalTimer = setTimeout(function () { showFinalResult(correct, culprit, anyVotes); }, 2600); } var trialFinalTimer = null; @@ -4919,10 +4919,12 @@ '#trial-final-culprit{position:absolute;left:50%;top:55%;transform:translate(-50%,-50%);height:52%;width:auto;z-index:2;display:none;filter:drop-shadow(0 0 20px rgba(255,200,80,.55))}' + // หัวข้อ "ภารกิจล้มเหลว" (ตอนตอบผิด) วางทับ lose-bg '#trial-final-txt{position:absolute;left:50%;top:7%;transform:translateX(-50%);width:780px;max-width:90vw;height:auto;z-index:2;display:none}' + + /* ชนะ: ข้อความ "ยินดีด้วย ชนะ" วางช่วงล่างเหนือปุ่มกลับ (เลี่ยงป้าย "เรือนจำไซเบอร์" บนสุด) */ + '#trial-final-txt.final-txt-congrats{top:auto;bottom:21%;width:420px;filter:drop-shadow(0 2px 10px rgba(0,0,0,.7))}' + '#trial-final-home{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);width:300px;height:104px;max-width:70vw;background:url(' + R + 'btn-gohome.png) center/contain no-repeat;border:none;cursor:pointer;font-size:0;color:transparent;z-index:3;filter:drop-shadow(0 4px 14px rgba(0,0,0,.6))}'; document.head.appendChild(st); } - function showFinalResult(correct, culprit) { + function showFinalResult(correct, culprit, anyVotes) { injectFinalResultStyle(); var R = '/Main-Lobby/IMAGE/Result/'; var ov = document.getElementById('trial-final'); @@ -4938,16 +4940,23 @@ var culpEl = ov.querySelector('#trial-final-culprit'); var txtEl = ov.querySelector('#trial-final-txt'); if (correct) { + /* ชนะ — จับคนร้ายจริง: เรือนจำไซเบอร์ + คนร้ายในคุก + ข้อความ "ยินดีด้วย ทีมนักสืบ ชนะ!" */ ov.style.backgroundImage = 'url(' + R + 'cyber-prison.png)'; var culpIdx = (typeof culprit === 'number') ? culprit : 0; if (culpEl) { culpEl.src = getCulpritPrisonImageUrl(culpIdx); culpEl.style.display = 'block'; } - if (txtEl) txtEl.style.display = 'none'; + if (txtEl) { txtEl.src = R + 'txt-congrats.png'; txtEl.className = 'final-txt-congrats'; txtEl.style.display = 'block'; } + } else if (anyVotes) { + /* จับแพะ — โหวตผิดคน: "อุปส์..ตัวป่วนชนะ ความจริงถูกบิดเบือน!" (troll-win) */ + ov.style.backgroundImage = 'url(' + R + 'troll-win-bg.png)'; + if (txtEl) { txtEl.src = R + 'txt-troll-win.png'; txtEl.className = ''; txtEl.style.display = 'block'; } + if (culpEl) culpEl.style.display = 'none'; } else { + /* ไม่มีใครโหวต / ภารกิจล้มเหลว */ ov.style.backgroundImage = 'url(' + R + 'lose-bg.png)'; - if (txtEl) { txtEl.src = R + 'txt-your-lose.png'; txtEl.style.display = 'block'; } + if (txtEl) { txtEl.src = R + 'txt-your-lose.png'; txtEl.className = ''; txtEl.style.display = 'block'; } if (culpEl) culpEl.style.display = 'none'; } ov.classList.remove('is-hidden'); @@ -5302,7 +5311,7 @@ ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.86);backdrop-filter:blur(4px)'; ov.innerHTML = '' + + 'style="position:absolute;top:22px;right:26px;width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0;border:none;border-radius:50%;cursor:pointer;font:900 26px/1 system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 8px 22px rgba(0,0,0,.45)">×' + '
' + '' + '
' + diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 6a31c60..795d673 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -5152,7 +5152,7 @@ - +
v —
diff --git a/www/html/Game/public/room-lobby.html b/www/html/Game/public/room-lobby.html index bc0a4eb..80b0006 100644 --- a/www/html/Game/public/room-lobby.html +++ b/www/html/Game/public/room-lobby.html @@ -291,7 +291,7 @@ .suspect-card-ui { position: absolute; left: 61%; - bottom: 11%; + bottom: 9%; transform: translateX(-50%); width: 368px; display: flex; @@ -1613,7 +1613,7 @@ - +
v —
diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 49cfc2a..2680dac 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -836,7 +836,7 @@ const SPECIAL_CARD_DEFS = { 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 ใบ' }, + 7: { key: 'free_evidence', when: 'after_game', evidence: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, }; function specialCardDef(cardId) { @@ -2964,7 +2964,7 @@ function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { const pend = space.pendingSpecialCard; if (pend && Number(pend.cardId) === 7 && !pend.consumed) { const def = specialCardDef(7); - freeEvidenceCount = (def && def.evidence) || 2; + freeEvidenceCount = (def && def.evidence) || 1; for (let n = 0; n < freeEvidenceCount; n++) { ids.forEach((pid) => { if (!space.peers.has(pid)) return; diff --git a/www/html/Main-Lobby/daily-popup.js b/www/html/Main-Lobby/daily-popup.js index 2cdedd7..7be2d65 100644 --- a/www/html/Main-Lobby/daily-popup.js +++ b/www/html/Main-Lobby/daily-popup.js @@ -57,6 +57,10 @@ var normalResetMs = 24 * 60 * 60 * 1000; var activeResetMs = isFastTestMode ? testResetMs : normalResetMs; var DAILY_STORAGE_KEY = 'jdDailyProgressV3:shared'; + var DAILY_ENDPOINT = appPath('/Admin/api/daily-reward.php'); + var PLAYER_KEY_LS = 'jdPlayerKey'; + /* โหมดทดสอบเร็ว = ใช้ local อย่างเดียว, โหมดปกติ = server เป็นเจ้าของสถานะจริง (กันฟาร์ม + ให้เหรียญจริง) */ + var useServer = !isFastTestMode; var state = { isOpen: false, @@ -175,6 +179,86 @@ } catch (e) { /* ignore */ } } + /* ===== ผูกกับ server (รางวัล + กันฟาร์ม) ===== */ + + function getPlayerKey() { + var k; + try { k = localStorage.getItem(PLAYER_KEY_LS); } catch (e) { k = ''; } + if (!k || String(k).length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + try { localStorage.setItem(PLAYER_KEY_LS, k); } catch (e2) { /* ignore */ } + } + return k; + } + + /* อัปเดตยอดเหรียญที่โปรไฟล์ทันที (server บันทึกจริงไปแล้ว) */ + function applyCoins(coins) { + var c = String(Math.max(0, parseInt(coins, 10) || 0)); + try { localStorage.setItem('jdCoins', c); } catch (e) { /* ignore */ } + var el = document.getElementById('lobby-profile-coins'); + if (el) el.textContent = c; + } + + /* แปลง response ของ server → progress object รูปแบบเดียวกับ local */ + function progressFromServer(data) { + if (!data || !Array.isArray(data.claimedDays) || data.claimedDays.length !== TOTAL_DAYS) return null; + var anchor = Number(data.anchorMs); + if (!isFinite(anchor) || anchor <= 0) anchor = createDefaultProgress(Date.now()).anchorMs; + var lock = Number(data.lockUntilMs); + if (!isFinite(lock) || lock < 0) lock = 0; + return { + anchorMs: anchor, + claimedDays: data.claimedDays.map(function (v) { return !!v; }), + lockUntilMs: lock, + timingKey: 'normal' + }; + } + + /* ดึงสถานะจริงจาก server แล้วยึดเป็นหลัก (ออฟไลน์ → คงสถานะ local เดิม) */ + function fetchServerDaily(done) { + if (!useServer) { if (done) done(false); return; } + var key = getPlayerKey(); + fetch(DAILY_ENDPOINT + '?playerKey=' + encodeURIComponent(key), { credentials: 'omit' }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d || !d.ok) { if (done) done(false); return; } + var p = progressFromServer(d); + if (p) { + state.progress = p; + saveProgress(); + syncProgress(); + if (refs.cardsEl) renderCards(); + } + if (typeof d.coins === 'number') applyCoins(d.coins); + if (done) done(true); + }) + .catch(function () { if (done) done(false); }); + } + + /* เคลมรางวัลผ่าน server — server ตรวจสอบ + บวกเหรียญจริง + กันยิงซ้ำ */ + function claimDayOnServer(day, onOk, onFail) { + var key = getPlayerKey(); + fetch(DAILY_ENDPOINT, { + method: 'POST', + credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playerKey: key, day: day }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + d = d || {}; + var p = progressFromServer(d); + if (p) { state.progress = p; saveProgress(); } + if (d.ok) { + if (typeof d.coins === 'number') applyCoins(d.coins); + if (onOk) onOk(d); + } else { + if (onFail) onFail(d); + } + }) + .catch(function () { if (onFail) onFail(null); }); + } + function syncProgress() { if (!state.progress) return false; var progress = state.progress; @@ -415,14 +499,32 @@ if (!state.progress) return; if (day !== state.currentDay) return; if (state.progress.claimedDays[day - 1]) return; - state.progress.claimedDays[day - 1] = true; - state.progress.lockUntilMs = Date.now() + Math.max(1, state.msUntilReset || activeResetMs); - saveProgress(); - syncProgress(); - state.claimedAnimDay = day; - renderCards(); - state.claimedAnimDay = null; - syncDailyDotIndicator(); + if (claimBtn.disabled) return; + claimBtn.disabled = true; + + function finishLocal(animate) { + if (animate) state.claimedAnimDay = day; + syncProgress(); + renderCards(); + state.claimedAnimDay = null; + syncDailyDotIndicator(); + } + + if (useServer) { + claimDayOnServer(day, function () { + // สำเร็จ: server บันทึก + บวกเหรียญแล้ว, progress อัปเดตจาก response + finishLocal(true); + }, function () { + // server ปฏิเสธ/ออฟไลน์: ใช้ progress ล่าสุดที่ได้กลับมา (ถ้ามี) แล้ว render ใหม่ + finishLocal(false); + }); + } else { + // โหมดทดสอบเร็ว: local อย่างเดียว + state.progress.claimedDays[day - 1] = true; + state.progress.lockUntilMs = Date.now() + Math.max(1, state.msUntilReset || activeResetMs); + saveProgress(); + finishLocal(true); + } }); inner.appendChild(claimBtn); } else { @@ -540,6 +642,7 @@ state.isOpen = true; syncScale(); startResetCountdown(); + fetchServerDaily(); /* ดึงสถานะจริงจาก server ทุกครั้งที่เปิด */ } function closePopup() { @@ -571,6 +674,7 @@ state.progress = loadProgress(); syncProgress(); + fetchServerDaily(); /* ซิงก์สถานะจริง + ยอดเหรียญตั้งแต่โหลด (อัปเดต dot ให้ถูก) */ startDailyDotWatcher(); bindTriggers(); diff --git a/www/html/Main-Lobby/index.html b/www/html/Main-Lobby/index.html index 9de6c96..e550125 100644 --- a/www/html/Main-Lobby/index.html +++ b/www/html/Main-Lobby/index.html @@ -227,7 +227,7 @@ - +