diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js index e979fd8..9a2b49b 100644 --- a/www/html/Admin/admin.js +++ b/www/html/Admin/admin.js @@ -4688,6 +4688,7 @@ if (name === 'game-timing') loadGameTimingPanel(); if (name === 'stack-game') loadStackGamePanel(); if (name === 'highscore') loadHighscore(); + if (name === 'achievements') loadAchievementsPanel(); if (name === 'test-mode') { loadForcedMinigamePanel(); loadSpecialCardByMapPanel(); loadTroublesomeForcePanel(); } } @@ -5187,6 +5188,219 @@ }); })(); + /* ===== Achievements admin ===== */ + var achvCatalog = []; + var achvCatalogById = {}; + var achvCurrentPlayer = null; + + function achvGroupLabel(g) { + var names = { 1: '1 · Analyst', 2: '2 · Explorer', 3: '3 · Risk', 4: '4 · Support', 5: '5 · Elite' }; + return names[g] || String(g); + } + + function renderAchvCatalog() { + var tb = el('table-achv-catalog'); + if (!tb) return; + tb = tb.querySelector('tbody'); + tb.innerHTML = ''; + achvCatalog.slice().sort(function (a, b) { return (a.g - b.g) || 0; }).forEach(function (a) { + var tr = document.createElement('tr'); + tr.setAttribute('data-achv-id', a.id); + + var tdG = document.createElement('td'); + var sel = document.createElement('select'); + sel.className = 'achv-f-g'; + for (var g = 1; g <= 5; g++) { + var op = document.createElement('option'); + op.value = String(g); op.textContent = achvGroupLabel(g); + if (g === (parseInt(a.g, 10) || 1)) op.selected = true; + sel.appendChild(op); + } + tdG.appendChild(sel); + + var tdT = document.createElement('td'); + var inT = document.createElement('input'); + inT.type = 'text'; inT.className = 'achv-f-title'; inT.value = a.title || ''; inT.style.width = '100%'; + tdT.appendChild(inT); + + var tdD = document.createElement('td'); + var inD = document.createElement('input'); + inD.type = 'text'; inD.className = 'achv-f-desc'; inD.value = a.desc || ''; inD.style.width = '100%'; + tdD.appendChild(inD); + + var tdTar = document.createElement('td'); + var inTar = document.createElement('input'); + inTar.type = 'number'; inTar.min = '1'; inTar.step = '1'; inTar.className = 'achv-f-target'; + inTar.value = String(Math.max(1, parseInt(a.target, 10) || 1)); inTar.style.width = '72px'; + tdTar.appendChild(inTar); + + var tdE = document.createElement('td'); + var inE = document.createElement('input'); + inE.type = 'checkbox'; inE.className = 'achv-f-enabled'; inE.checked = a.enabled !== false; + tdE.appendChild(inE); + + tr.appendChild(tdG); tr.appendChild(tdT); tr.appendChild(tdD); tr.appendChild(tdTar); tr.appendChild(tdE); + tb.appendChild(tr); + }); + } + + function collectAchvCatalogFromTable() { + var rows = document.querySelectorAll('#table-achv-catalog tbody tr'); + var out = []; + rows.forEach(function (tr) { + out.push({ + id: tr.getAttribute('data-achv-id'), + g: parseInt(tr.querySelector('.achv-f-g').value, 10) || 1, + title: tr.querySelector('.achv-f-title').value, + desc: tr.querySelector('.achv-f-desc').value, + target: Math.max(1, parseInt(tr.querySelector('.achv-f-target').value, 10) || 1), + enabled: tr.querySelector('.achv-f-enabled').checked, + }); + }); + return out; + } + + function loadAchvCatalog() { + return api('achievements.php?action=catalog').then(function (r) { + achvCatalog = (r && r.catalog) || []; + achvCatalogById = {}; + achvCatalog.forEach(function (a) { achvCatalogById[a.id] = a; }); + renderAchvCatalog(); + setMsg('achv-catalog-msg', 'โหลดแคตตาล็อก ' + achvCatalog.length + ' รายการ', ''); + }).catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); }); + } + + function renderAchvPlayers(rows) { + var tb = el('table-achv-players'); + if (!tb) return; + tb = tb.querySelector('tbody'); + tb.innerHTML = ''; + (rows || []).forEach(function (p) { + var tr = document.createElement('tr'); + var c1 = document.createElement('td'); c1.textContent = p.displayName || 'Guest'; + var c2 = document.createElement('td'); c2.textContent = p.playerKey; c2.style.fontSize = '12px'; c2.style.opacity = '0.8'; + var c3 = document.createElement('td'); c3.textContent = (p.unlocked || 0) + ' / ' + (p.total || 0); + var c4 = document.createElement('td'); c4.textContent = String(p.coins || 0); + var c5 = document.createElement('td'); + var btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'btn btn-ghost'; btn.textContent = 'จัดการ'; + btn.addEventListener('click', function () { openAchvPlayerDetail(p.playerKey, p.displayName); }); + c5.appendChild(btn); + tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3); tr.appendChild(c4); tr.appendChild(c5); + tb.appendChild(tr); + }); + } + + function loadAchvPlayers() { + var q = (el('achv-player-search') && el('achv-player-search').value || '').trim().toLowerCase(); + return api('achievements.php?action=players').then(function (r) { + var rows = (r && r.players) || []; + if (q) { + rows = rows.filter(function (p) { + return (p.displayName || '').toLowerCase().indexOf(q) >= 0 || (p.playerKey || '').toLowerCase().indexOf(q) >= 0; + }); + } + renderAchvPlayers(rows); + setMsg('achv-players-msg', 'ผู้เล่น ' + rows.length + ' คน', ''); + }).catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + } + + function openAchvPlayerDetail(key, name) { + achvCurrentPlayer = key; + var box = el('achv-player-detail'); + if (box) box.hidden = false; + if (el('achv-player-detail-name')) el('achv-player-detail-name').textContent = (name || 'Guest') + ' (' + key + ')'; + api('achievements.php?action=player&playerKey=' + encodeURIComponent(key)).then(function (r) { + var prog = (r && r.progress) || {}; + var tb = el('table-achv-player-detail').querySelector('tbody'); + tb.innerHTML = ''; + achvCatalog.slice().sort(function (a, b) { return (a.g - b.g) || 0; }).forEach(function (a) { + var target = Math.max(1, parseInt(a.target, 10) || 1); + var cur = Math.max(0, parseInt(prog[a.id], 10) || 0); + var tr = document.createElement('tr'); + + var c1 = document.createElement('td'); + c1.innerHTML = '' + achvGroupLabel(a.g).charAt(0) + ' · ' + (a.title || a.id); + var c2 = document.createElement('td'); + var inp = document.createElement('input'); + inp.type = 'number'; inp.min = '0'; inp.max = String(target); inp.step = '1'; inp.value = String(Math.min(cur, target)); + inp.style.width = '70px'; + c2.appendChild(inp); + c2.appendChild(document.createTextNode(' / ' + target + (cur >= target ? ' ✔' : ''))); + + var c3 = document.createElement('td'); + var setBtn = document.createElement('button'); + setBtn.type = 'button'; setBtn.className = 'btn btn-ghost'; setBtn.textContent = 'ตั้งค่า'; + setBtn.style.marginRight = '6px'; + setBtn.addEventListener('click', function () { + api('achievements.php', { method: 'POST', body: { action: 'setProgress', playerKey: key, id: a.id, value: parseInt(inp.value, 10) || 0 } }) + .then(function () { setMsg('achv-players-msg', 'บันทึก ' + a.id, 'ok'); openAchvPlayerDetail(key, name); }) + .catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + }); + var unBtn = document.createElement('button'); + unBtn.type = 'button'; unBtn.className = 'btn btn-primary'; unBtn.textContent = 'ปลดล็อก'; + unBtn.addEventListener('click', function () { + api('achievements.php', { method: 'POST', body: { action: 'unlock', playerKey: key, id: a.id } }) + .then(function () { setMsg('achv-players-msg', 'ปลดล็อก ' + a.id, 'ok'); openAchvPlayerDetail(key, name); }) + .catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + }); + c3.appendChild(setBtn); c3.appendChild(unBtn); + + tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3); + tb.appendChild(tr); + }); + }).catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + } + + function showAchvSubtab(which) { + var isCat = which !== 'players'; + if (el('achv-panel-catalog')) el('achv-panel-catalog').hidden = !isCat; + if (el('achv-panel-players')) el('achv-panel-players').hidden = isCat; + if (el('achv-subtab-catalog')) el('achv-subtab-catalog').classList.toggle('is-active', isCat); + if (el('achv-subtab-players')) el('achv-subtab-players').classList.toggle('is-active', !isCat); + if (!isCat) loadAchvPlayers(); + } + + var achvPanelInited = false; + function loadAchievementsPanel() { + if (!achvPanelInited) { + achvPanelInited = true; + var sc = el('achv-subtab-catalog'); if (sc) sc.addEventListener('click', function () { showAchvSubtab('catalog'); }); + var sp = el('achv-subtab-players'); if (sp) sp.addEventListener('click', function () { showAchvSubtab('players'); }); + var save = el('btn-achv-save'); + if (save) save.addEventListener('click', function () { + api('achievements.php', { method: 'POST', body: { action: 'saveCatalog', catalog: collectAchvCatalogFromTable() } }) + .then(function (r) { + achvCatalog = (r && r.catalog) || achvCatalog; + achvCatalogById = {}; achvCatalog.forEach(function (a) { achvCatalogById[a.id] = a; }); + renderAchvCatalog(); + setMsg('achv-catalog-msg', 'บันทึกแคตตาล็อกแล้ว', 'ok'); + }) + .catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); }); + }); + var reload = el('btn-achv-reload'); + if (reload) reload.addEventListener('click', function () { loadAchvCatalog(); }); + var resetDef = el('btn-achv-reset-default'); + if (resetDef) resetDef.addEventListener('click', function () { + if (!confirm('คืนค่าแคตตาล็อกกลับเป็นค่าเริ่มต้น 25 รายการ? (ค่าที่แก้ไว้จะหาย)')) return; + api('achievements.php', { method: 'POST', body: { action: 'resetCatalog' } }) + .then(function () { setMsg('achv-catalog-msg', 'คืนค่าเริ่มต้นแล้ว', 'ok'); return loadAchvCatalog(); }) + .catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); }); + }); + var pr = el('btn-achv-players-refresh'); if (pr) pr.addEventListener('click', function () { loadAchvPlayers(); }); + var ps = el('achv-player-search'); if (ps) ps.addEventListener('input', function () { loadAchvPlayers(); }); + var rpb = el('btn-achv-player-reset'); + if (rpb) rpb.addEventListener('click', function () { + if (!achvCurrentPlayer) return; + if (!confirm('รีเซ็ต achievement ทั้งหมดของผู้เล่นนี้?')) return; + api('achievements.php', { method: 'POST', body: { action: 'resetPlayer', playerKey: achvCurrentPlayer } }) + .then(function () { setMsg('achv-players-msg', 'รีเซ็ตแล้ว', 'ok'); openAchvPlayerDetail(achvCurrentPlayer, el('achv-player-detail-name') ? el('achv-player-detail-name').textContent : ''); loadAchvPlayers(); }) + .catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + }); + } + return loadAchvCatalog(); + } + el('form-account-add').addEventListener('submit', function (e) { e.preventDefault(); var fd = new FormData(e.target); diff --git a/www/html/Admin/api/achievements.php b/www/html/Admin/api/achievements.php new file mode 100644 index 0000000..0b7efe8 --- /dev/null +++ b/www/html/Admin/api/achievements.php @@ -0,0 +1,345 @@ + 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); diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 3c34aff..b575259 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -119,6 +119,7 @@ + @@ -1057,6 +1058,79 @@
+จัดการ แคตตาล็อก รางวัล (ชื่อ/คำอธิบาย/เป้าหมาย/เปิด-ปิด) และ ความคืบหน้ารายผู้เล่น · แสดงผลในหน้าโปรไฟล์ผู้เล่น 5 หมวด
+ +| หมวด | +ชื่อ (EN) | +คำอธิบาย (TH) | +เป้าหมาย | +เปิด | +
|---|
| ชื่อผู้เล่น | +playerKey | +ปลดล็อก | +COINS | ++ |
|---|
| รางวัล | +ความคืบหน้า | ++ |
|---|
เฉพาะ super admin เท่านั้นที่เพิ่ม/ลบแอดมินได้
@@ -1124,6 +1198,9 @@