Files
2026-06-17 08:15:22 +00:00

346 lines
18 KiB
PHP

<?php
declare(strict_types=1);
/**
* Achievements — แคตตาล็อก + progress รายผู้เล่น
*
* แคตตาล็อก override : Admin/private/achievements.json (ถ้าไม่มี ใช้ default ในไฟล์นี้)
* progress รายผู้เล่น : เก็บใน store.json -> 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);