diff --git a/www/html/Admin/api/leaderboard.php b/www/html/Admin/api/leaderboard.php
index 503b879..a8b2233 100644
--- a/www/html/Admin/api/leaderboard.php
+++ b/www/html/Admin/api/leaderboard.php
@@ -19,15 +19,44 @@ if ($limit > 200) $limit = 200;
$caseId = preg_replace('/[^0-9]/', '', (string) ($_GET['caseId'] ?? ''));
+/* ชื่อคดี (caseId -> name) จาก Game/data/case-media.json */
+function lb_case_names(): array
+{
+ $path = __DIR__ . '/../../Game/data/case-media.json';
+ if (!is_file($path)) return [];
+ $j = json_decode((string) @file_get_contents($path), true);
+ $cases = (is_array($j) && isset($j['cases']) && is_array($j['cases'])) ? $j['cases'] : [];
+ $out = [];
+ foreach ($cases as $cid => $c) {
+ $nm = is_array($c) ? trim((string) ($c['name'] ?? '')) : '';
+ if ($nm !== '') $out[(string) $cid] = $nm;
+ }
+ return $out;
+}
+$caseNames = lb_case_names();
+
$store = read_store();
$accounts = (isset($store['accounts']) && is_array($store['accounts'])) ? $store['accounts'] : [];
$rows = [];
foreach ($accounts as $a) {
+ $bestCaseId = '';
if ($caseId !== '') {
+ /* เจาะจงคดี */
$score = max(0, (int) (($a['scoreByCase'][$caseId] ?? 0)));
+ $bestCaseId = $caseId;
} else {
- $score = max(0, (int) ($a['score'] ?? 0));
+ /* กระดานรวม — คะแนนสูงสุดของ user จากคดีไหนก็ได้ */
+ $score = 0;
+ $sbc = (isset($a['scoreByCase']) && is_array($a['scoreByCase'])) ? $a['scoreByCase'] : [];
+ foreach ($sbc as $cid => $sc) {
+ $sc = max(0, (int) $sc);
+ if ($sc > $score) { $score = $sc; $bestCaseId = (string) $cid; }
+ }
+ if ($score <= 0) { /* legacy: ไม่มีคะแนนรายคดี ใช้คะแนนรวม (ไม่มีชื่อคดี) */
+ $score = max(0, (int) ($a['score'] ?? 0));
+ $bestCaseId = '';
+ }
}
if ($score <= 0) {
continue;
@@ -39,6 +68,8 @@ foreach ($accounts as $a) {
'name' => $name,
'score' => $score,
'coins' => max(0, (int) ($a['coins'] ?? 0)),
+ 'caseId' => $bestCaseId,
+ 'caseName' => ($bestCaseId !== '' && isset($caseNames[$bestCaseId])) ? $caseNames[$bestCaseId] : '',
'key' => (string) ($a['providerUserId'] ?? ''),
];
}
@@ -57,7 +88,7 @@ $myKey = trim((string) ($_GET['playerKey'] ?? ''));
if ($myKey !== '') {
for ($i = 0; $i < $total; $i++) {
if ($rows[$i]['key'] === $myKey) {
- $me = ['rank' => $i + 1, 'name' => $rows[$i]['name'], 'score' => $rows[$i]['score'], 'coins' => $rows[$i]['coins']];
+ $me = ['rank' => $i + 1, 'name' => $rows[$i]['name'], 'score' => $rows[$i]['score'], 'coins' => $rows[$i]['coins'], 'caseId' => $rows[$i]['caseId'], 'caseName' => $rows[$i]['caseName']];
break;
}
}
@@ -71,6 +102,8 @@ for ($i = 0; $i < $n; $i++) {
'name' => $rows[$i]['name'],
'score' => $rows[$i]['score'],
'coins' => $rows[$i]['coins'],
+ 'caseId' => $rows[$i]['caseId'],
+ 'caseName' => $rows[$i]['caseName'],
];
}
diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json
index 477e73f..2ef2120 100644
--- a/www/html/Admin/private/store.json
+++ b/www/html/Admin/private/store.json
@@ -38,9 +38,9 @@
"providerUserId": "p_1775109142385_wq7wfy1p32j",
"notes": "auto: player-coins",
"blocked": false,
- "coins": 545,
+ "coins": 565,
"createdAt": "2026-04-02T05:52:21+00:00",
- "updatedAt": "2026-06-17T07:52:36+00:00",
+ "updatedAt": "2026-06-17T08:31:26+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -54,7 +54,7 @@
],
"lockUntilMs": 1781715600000
},
- "score": 470,
+ "score": 490,
"scoreByCase": {
"1": 10,
"10": 120,
@@ -63,11 +63,14 @@
"13": 20,
"14": 20,
"9": 40,
- "12": 40,
+ "12": 60,
"15": 20,
"4": 10
},
- "lbName": "Q"
+ "lbName": "Q",
+ "achievements": {
+ "d1_minigame_solver": 2
+ }
},
{
"id": "1d64c56fadb64a93eae68a1d",
@@ -173,6 +176,21 @@
"coins": 0,
"createdAt": "2026-06-16T05:42:47+00:00",
"updatedAt": "2026-06-16T05:42:47+00:00"
+ },
+ {
+ "id": "0b016b9f790d0b90d99d7ba6",
+ "email": "",
+ "displayName": "Guest",
+ "loginType": "guest",
+ "providerUserId": "achvtest_12345678",
+ "notes": "auto: achievements",
+ "blocked": false,
+ "coins": 0,
+ "achievements": {
+ "d1_minigame_solver": 3
+ },
+ "createdAt": "2026-06-17T08:22:51+00:00",
+ "updatedAt": "2026-06-17T08:22:51+00:00"
}
]
}
\ No newline at end of file
diff --git a/www/html/Game/server.js b/www/html/Game/server.js
index 61ff41e..66039b2 100644
--- a/www/html/Game/server.js
+++ b/www/html/Game/server.js
@@ -1760,6 +1760,7 @@ function computeRunGradeForEvidence(space) {
/* ===== แจกเหรียญตามอันดับ + คะแนนสะสม (high score) แบบ server-authoritative ===== */
const GAME_AWARD_URL = process.env.GAME_AWARD_URL || 'https://srv1361159.hstgr.cloud/Admin/api/game-award.php';
+const ACHIEVEMENTS_URL = process.env.ACHIEVEMENTS_URL || GAME_AWARD_URL.replace('game-award.php', 'achievements.php');
const GAME_AWARD_SECRET_PATH = path.join(__dirname, '..', 'Admin', 'private', 'game-award-secret.txt');
let __gameAwardSecret = null;
function gameAwardSecret() {
@@ -1807,6 +1808,25 @@ function submitGameAwards(awards) {
} catch (e) { console.error('[game-award] throw', e && e.message); }
}
+/* บวก progress achievement (server-authoritative ผ่าน achievements.php — secret เดียวกับ game-award)
+ items: [{ playerKey, id, inc }] — หนึ่ง POST ต่อรายการ (achievements.php รับทีละ id) */
+function submitAchievementProgress(items) {
+ const secret = gameAwardSecret();
+ if (!secret || !Array.isArray(items) || !items.length) return;
+ items.forEach((it) => {
+ if (!it || !it.playerKey || !it.id) return;
+ try {
+ fetch(ACHIEVEMENTS_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'progress', secret, playerKey: it.playerKey, id: it.id, inc: it.inc || 1 }),
+ }).then((r) => r.json()).then((d) => {
+ if (!d || !d.ok) console.error('[achv] failed', it.id, d);
+ }).catch((e) => console.error('[achv] error', e && e.message));
+ } catch (e) { console.error('[achv] throw', e && e.message); }
+ });
+}
+
/* แจกเหรียญตามอันดับคะแนน (เฉพาะผู้รอดชีวิต ผู้ตาย = 0) + คะแนนสะสมเข้า leaderboard — ครั้งเดียวต่อรอบ */
function awardMinigameRankCoins(sid, space) {
if (!space || space.coinAwardDoneForRun) return;
@@ -1829,6 +1849,9 @@ function awardMinigameRankCoins(sid, space) {
const awards = rows.filter((r) => r.coins > 0 && r.playerKey).map((r) => ({ playerKey: r.playerKey, nickname: r.nickname, coins: r.coins, caseId: caseId }));
submitGameAwards(awards);
+ /* Achievement: d1 Minigame Solver — รอดมินิเกมสำเร็จ (เฉพาะผู้รอดชีวิตที่เป็นผู้เล่นจริง) */
+ submitAchievementProgress(survivors.filter((r) => r.playerKey).map((r) => ({ playerKey: r.playerKey, id: 'd1_minigame_solver', inc: 1 })));
+
io.to(sid).emit('minigame-coins', {
gameType,
coinsTable: MINIGAME_RANK_COINS,
@@ -3636,6 +3659,34 @@ function computeAndEmitTrialResult(sid, space) {
disruptorId,
hasDisruptor: !!disruptorId,
});
+
+ /* ===== Achievement auto-track ===== */
+ try {
+ const achv = [];
+ /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */
+ winners.forEach((id) => {
+ const p = space.peers.get(id);
+ if (p && p.playerKey) {
+ achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 });
+ achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 });
+ }
+ });
+ /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */
+ if (disruptorId && space.disruptorPlayerKey) {
+ const dKey = space.disruptorPlayerKey;
+ achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */
+ achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */
+ /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */
+ let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; }
+ const anyV = (counts[0] + counts[1] + counts[2]) > 0;
+ if (anyV && mv !== culprit) {
+ achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */
+ achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */
+ }
+ }
+ submitAchievementProgress(achv);
+ } catch (e) { console.error('[achv] trial-result', e && e.message); }
+
space.silencedPlayerId = null;
}
diff --git a/www/html/Main-Lobby/index.html b/www/html/Main-Lobby/index.html
index c1723a3..1f90e16 100644
--- a/www/html/Main-Lobby/index.html
+++ b/www/html/Main-Lobby/index.html
@@ -231,6 +231,6 @@
-
+