accounts[].achievements { id: count } * * Public (ไม่ต้องล็อกอิน): * GET ?action=state&playerKey=KEY -> { ok, catalog:[...], progress:{id:count} } * GET ?action=catalog -> { ok, catalog:[...] } * * Server-only (มี secret game-award-secret.txt) — สำหรับ auto-track ภายหลัง: * POST {action:'progress', secret, playerKey, id, inc?, set?} * * Admin (ต้องล็อกอิน session): * POST {action:'saveCatalog', catalog:[...]} * GET ?action=players -> รายชื่อผู้เล่น + จำนวนที่ปลดล็อก * GET ?action=player&playerKey=KEY -> progress ของผู้เล่นคนเดียว * POST {action:'setProgress', playerKey, id, value} * POST {action:'unlock', playerKey, id} -> set = target * POST {action:'resetPlayer', playerKey} -> ล้าง progress ทั้งหมด */ require __DIR__ . '/_common.php'; date_default_timezone_set('Asia/Bangkok'); define('ACHV_CATALOG_FILE', ADMIN_PRIVATE_DIR . '/achievements.json'); function achv_default_catalog(): array { return [ ['id' => 'a1_first_deduction', 'g' => 1, 'title' => 'First Deduction', 'desc' => 'โหวตถูกตัวคนร้ายเป็นครั้งแรก', 'target' => 1, 'enabled' => true], ['id' => 'a2_sharp_eye', 'g' => 1, 'title' => 'Sharp Eye', 'desc' => 'สะสมหลักฐานระดับมีน้ำหนัก (Silver) ครบ 10 ใบ', 'target' => 10, 'enabled' => true], ['id' => 'a3_mind_architect', 'g' => 1, 'title' => 'Mind Architect', 'desc' => 'สะสมหลักฐานครบทุกระดับ (ทั่วไป, มีน้ำหนัก, ชี้ชัด) ใน 1 คดี', 'target' => 1, 'enabled' => true], ['id' => 'a4_logic_over_luck', 'g' => 1, 'title' => 'Logic Over Luck', 'desc' => 'โหวตถูกโดยไม่พึ่งหลักฐานชี้ชัด (Legendary) เลย 3 ครั้ง', 'target' => 3, 'enabled' => true], ['id' => 'a5_truth_hunter', 'g' => 1, 'title' => 'Truth Hunter', 'desc' => 'จับคนร้ายถูกตัวสะสมครบ 20 คดี', 'target' => 20, 'enabled' => true], ['id' => 'a6_unbreakable_logic', 'g' => 1, 'title' => 'Unbreakable Logic', 'desc' => 'โหวตถูกตัวติดกัน 5 คดีรวด', 'target' => 5, 'enabled' => true], ['id' => 'b1_evidence_collector', 'g' => 2, 'title' => 'Evidence Collector', 'desc' => 'สะสมหลักฐานระดับทั่วไป (Common) ครบ 20 ใบ', 'target' => 20, 'enabled' => true], ['id' => 'b2_relentless_investigator', 'g' => 2, 'title' => 'Relentless Investigator', 'desc' => 'เล่น Mini Game ครบทุกรอบ ใน 1 คดี', 'target' => 1, 'enabled' => true], ['id' => 'b3_hidden_fragment', 'g' => 2, 'title' => 'Hidden Fragment', 'desc' => 'ค้นพบหลักฐานระดับชี้ชัด (Legendary/Gold) เป็นครั้งแรก', 'target' => 1, 'enabled' => true], ['id' => 'b4_data_miner', 'g' => 2, 'title' => 'Data Miner', 'desc' => 'สะสมการ์ดหลักฐานรวมครบ 100 ใบ', 'target' => 100, 'enabled' => true], ['id' => 'b5_deep_scanner', 'g' => 2, 'title' => 'Deep Scanner', 'desc' => 'เก็บไอเทมช่วยเหลือ (ตำรวจ/ทนาย) สะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true], ['id' => 'c1_early_accusation', 'g' => 3, 'title' => 'Early Accusation', 'desc' => 'ชี้ตัวคนร้ายก่อนที่จะเปิดหลักฐานครบ', 'target' => 1, 'enabled' => true], ['id' => 'c2_high_stakes', 'g' => 3, 'title' => 'High Stakes', 'desc' => 'ชี้ตัวคนร้ายถูกโดยมีหลักฐานในมือไม่เกิน 3 ใบ', 'target' => 1, 'enabled' => true], ['id' => 'c3_quick_draw', 'g' => 3, 'title' => 'Quick Draw', 'desc' => 'ชี้ตัวคนร้ายเร็วที่สุดในทีมสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true], ['id' => 'c4_clutch_mind', 'g' => 3, 'title' => 'Clutch Mind', 'desc' => 'โหวตถูกในช่วง 10 วินาทีสุดท้ายก่อนหมดเวลา', 'target' => 1, 'enabled' => true], ['id' => 'c5_lone_wolf', 'g' => 3, 'title' => 'Lone Wolf', 'desc' => 'โหวตสวนทางกับเสียงส่วนใหญ่ของทีม (คุณถูกคนเดียว)', 'target' => 1, 'enabled' => true], ['id' => 'd1_minigame_solver', 'g' => 4, 'title' => 'Minigame Solver', 'desc' => 'เอาชีวิตรอด / เล่นมินิเกมสำเร็จ 20 ครั้ง', 'target' => 20, 'enabled' => true], ['id' => 'd2_silent_guardian', 'g' => 4, 'title' => 'Silent Guardian', 'desc' => 'ไม่โดนโหวตใช้การ์ด Event พิเศษ เลยตลอด 5 คดี', 'target' => 5, 'enabled' => true], ['id' => 'd3_the_backbone', 'g' => 4, 'title' => 'The Backbone', 'desc' => 'ส่งมอบหลักฐานให้เพื่อนวิเคราะห์ครบ 30 ใบ', 'target' => 30, 'enabled' => true], ['id' => 'd4_flawless_diver', 'g' => 4, 'title' => 'Flawless Diver', 'desc' => 'ไม่โหวตจับผิดตัวเลยตลอด 15 คดี', 'target' => 15, 'enabled' => true], ['id' => 'e1_the_observer', 'g' => 5, 'title' => 'The Observer', 'desc' => 'เล่นจบ 15 คดีโดยไม่เคยสัมผัสหลักฐานชี้ชัด (Legendary) เลยสักครั้ง', 'target' => 15, 'enabled' => true], ['id' => 'e2_the_impostor', 'g' => 5, 'title' => 'The Impostor', 'desc' => 'รับบทเป็น "ตัวป่วน" ครั้งแรก', 'target' => 1, 'enabled' => true], ['id' => 'e3_master_of_doubt', 'g' => 5, 'title' => 'Master of Doubt', 'desc' => 'เอาชนะคดีในฐานะตัวป่วนได้สำเร็จ', 'target' => 1, 'enabled' => true], ['id' => 'e4_agent_of_chaos', 'g' => 5, 'title' => 'Agent of Chaos', 'desc' => 'รับบทเป็นตัวป่วนสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true], ['id' => 'e5_slippery_eel', 'g' => 5, 'title' => 'Slippery Eel', 'desc' => 'เป็นตัวป่วนแต่รอดพ้นจากการถูกจับได้ (ไม่ถูกโหวตออก) จนจบเกม 5 ครั้ง', 'target' => 5, 'enabled' => true], ]; } function achv_sanitize_catalog(array $arr): array { $out = []; $seen = []; foreach ($arr as $row) { if (!is_array($row)) continue; $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($row['id'] ?? '')); if ($id === '' || isset($seen[$id])) continue; $seen[$id] = true; $g = (int) ($row['g'] ?? 1); if ($g < 1 || $g > 5) $g = 1; $title = trim((string) ($row['title'] ?? '')); $desc = trim((string) ($row['desc'] ?? '')); if (function_exists('mb_substr')) { $title = mb_substr($title, 0, 60); $desc = mb_substr($desc, 0, 200); } $target = (int) ($row['target'] ?? 1); if ($target < 1) $target = 1; if ($target > 100000) $target = 100000; $out[] = [ 'id' => $id, 'g' => $g, 'title' => $title !== '' ? $title : $id, 'desc' => $desc, 'target' => $target, 'enabled' => !isset($row['enabled']) || !empty($row['enabled']), ]; } return $out; } function achv_load_catalog(): array { if (is_file(ACHV_CATALOG_FILE)) { $raw = @file_get_contents(ACHV_CATALOG_FILE); $j = json_decode($raw ?: '[]', true); if (is_array($j) && $j) { $c = achv_sanitize_catalog($j); if ($c) return $c; } } return achv_default_catalog(); } function achv_save_catalog(array $catalog): bool { if (!is_dir(ADMIN_PRIVATE_DIR)) { if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) return false; } $tmp = ACHV_CATALOG_FILE . '.tmp.' . bin2hex(random_bytes(4)); $json = json_encode($catalog, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if ($json === false) return false; if (file_put_contents($tmp, $json, LOCK_EX) === false) return false; if (!rename($tmp, ACHV_CATALOG_FILE)) { @unlink($tmp); return false; } return true; } function achv_valid_key(string $key): bool { return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key); } /* หา index บัญชี guest ตาม playerKey (สร้างใหม่ถ้าไม่มี) */ function achv_find_or_create(array &$store, string $key): int { foreach ($store['accounts'] as $i => $a) { if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) { return $i; } } $store['accounts'][] = [ 'id' => new_id(), 'email' => '', 'displayName' => 'Guest', 'loginType' => 'guest', 'providerUserId' => $key, 'notes' => 'auto: achievements', 'blocked' => false, 'coins' => 0, 'achievements' => new \stdClass(), 'createdAt' => gmdate('c'), 'updatedAt' => gmdate('c'), ]; return count($store['accounts']) - 1; } function achv_progress_of(array $account): array { $p = $account['achievements'] ?? []; if (!is_array($p)) return []; $out = []; foreach ($p as $k => $v) { $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $k); if ($id === '') continue; $out[$id] = max(0, (int) $v); } return $out; } $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $action = (string) ($_GET['action'] ?? ''); if ($method === 'POST') { $body = require_json_body(); if (!$action) $action = (string) ($body['action'] ?? ''); } else { $body = []; } /* ---------- Public ---------- */ if ($action === 'catalog' && $method === 'GET') { json_response(['ok' => true, 'catalog' => achv_load_catalog()]); } if ($action === 'state' && $method === 'GET') { $key = trim((string) ($_GET['playerKey'] ?? '')); if (!achv_valid_key($key)) { json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400); } $catalog = achv_load_catalog(); $store = read_store(); $progress = []; foreach ($store['accounts'] as $a) { if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) { $progress = achv_progress_of($a); break; } } json_response(['ok' => true, 'catalog' => $catalog, 'progress' => (object) $progress]); } /* ---------- Server-only (secret) : auto-track ภายหลัง ---------- */ if ($action === 'progress' && $method === 'POST') { $secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt'; $expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : ''; $secret = (string) ($body['secret'] ?? ''); if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) { json_response(['ok' => false, 'error' => 'unauthorized'], 403); } $key = trim((string) ($body['playerKey'] ?? '')); $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? '')); if (!achv_valid_key($key) || $id === '') { json_response(['ok' => false, 'error' => 'bad params'], 400); } $catalog = achv_load_catalog(); $target = 0; foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } } if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404); $store = read_store(); $i = achv_find_or_create($store, $key); $cur = achv_progress_of($store['accounts'][$i]); if (isset($body['set'])) { $val = max(0, min($target, (int) $body['set'])); } else { $inc = (int) ($body['inc'] ?? 1); $val = max(0, min($target, ($cur[$id] ?? 0) + $inc)); } $cur[$id] = $val; $store['accounts'][$i]['achievements'] = (object) $cur; $store['accounts'][$i]['updatedAt'] = gmdate('c'); if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500); json_response(['ok' => true, 'id' => $id, 'value' => $val, 'unlocked' => $val >= $target]); } /* ---------- Admin ---------- */ require_login(); if ($action === 'saveCatalog' && $method === 'POST') { $catIn = (isset($body['catalog']) && is_array($body['catalog'])) ? $body['catalog'] : null; if ($catIn === null) json_response(['ok' => false, 'error' => 'ไม่มี catalog'], 400); $clean = achv_sanitize_catalog($catIn); if (!$clean) json_response(['ok' => false, 'error' => 'catalog ว่าง'], 400); if (!achv_save_catalog($clean)) { json_response(['ok' => false, 'error' => 'บันทึก achievements.json ไม่สำเร็จ (chown ให้ user เว็บ)'], 500); } json_response(['ok' => true, 'catalog' => $clean]); } if ($action === 'resetCatalog' && $method === 'POST') { $def = achv_default_catalog(); if (!achv_save_catalog($def)) { json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500); } json_response(['ok' => true, 'catalog' => $def]); } if ($action === 'players' && $method === 'GET') { $catalog = achv_load_catalog(); $byId = []; foreach ($catalog as $c) $byId[$c['id']] = (int) $c['target']; $store = read_store(); $rows = []; foreach ($store['accounts'] as $a) { if (($a['loginType'] ?? '') !== 'guest') continue; $key = (string) ($a['providerUserId'] ?? ''); if ($key === '') continue; $prog = achv_progress_of($a); $unlocked = 0; foreach ($prog as $id => $v) { if (isset($byId[$id]) && $v >= $byId[$id]) $unlocked++; } $rows[] = [ 'playerKey' => $key, 'displayName' => (string) ($a['displayName'] ?? ($a['lbName'] ?? 'Guest')), 'coins' => max(0, (int) ($a['coins'] ?? 0)), 'blocked' => !empty($a['blocked']), 'unlocked' => $unlocked, 'total' => count($byId), 'updatedAt' => (string) ($a['updatedAt'] ?? ''), ]; } json_response(['ok' => true, 'players' => $rows, 'total' => count($byId)]); } if ($action === 'player' && $method === 'GET') { $key = trim((string) ($_GET['playerKey'] ?? '')); if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400); $store = read_store(); foreach ($store['accounts'] as $a) { if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) { json_response(['ok' => true, 'playerKey' => $key, 'displayName' => (string) ($a['displayName'] ?? 'Guest'), 'progress' => (object) achv_progress_of($a)]); } } json_response(['ok' => true, 'playerKey' => $key, 'displayName' => 'Guest', 'progress' => (object) []]); } if ($action === 'setProgress' && $method === 'POST') { $key = trim((string) ($body['playerKey'] ?? '')); $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? '')); if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400); $catalog = achv_load_catalog(); $target = 0; foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } } if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404); $value = max(0, min($target, (int) ($body['value'] ?? 0))); $store = read_store(); $i = achv_find_or_create($store, $key); $cur = achv_progress_of($store['accounts'][$i]); $cur[$id] = $value; $store['accounts'][$i]['achievements'] = (object) $cur; $store['accounts'][$i]['updatedAt'] = gmdate('c'); if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500); json_response(['ok' => true, 'id' => $id, 'value' => $value, 'unlocked' => $value >= $target]); } if ($action === 'unlock' && $method === 'POST') { $key = trim((string) ($body['playerKey'] ?? '')); $id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? '')); if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400); $catalog = achv_load_catalog(); $target = 0; foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } } if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404); $store = read_store(); $i = achv_find_or_create($store, $key); $cur = achv_progress_of($store['accounts'][$i]); $cur[$id] = $target; $store['accounts'][$i]['achievements'] = (object) $cur; $store['accounts'][$i]['updatedAt'] = gmdate('c'); if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500); json_response(['ok' => true, 'id' => $id, 'value' => $target, 'unlocked' => true]); } if ($action === 'resetPlayer' && $method === 'POST') { $key = trim((string) ($body['playerKey'] ?? '')); if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400); $store = read_store(); foreach ($store['accounts'] as $idx => $a) { if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) { $store['accounts'][$idx]['achievements'] = new \stdClass(); $store['accounts'][$idx]['updatedAt'] = gmdate('c'); if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500); json_response(['ok' => true]); } } json_response(['ok' => true]); } json_response(['ok' => false, 'error' => 'unknown action'], 400);