update all

This commit is contained in:
2026-07-31 03:36:58 +00:00
parent 6dcac08598
commit de7d91b74b
72 changed files with 65947 additions and 180683 deletions
+86
View File
@@ -111,9 +111,95 @@ function is_super(array $admin): bool
return ($admin['role'] ?? '') === 'super';
}
/**
* รายชื่อหมวด (tab) ทั้งหมดในหน้า admin — ต้องตรงกับ data-tab ใน Admin/index.html
* ใช้เป็น whitelist เวลาบันทึกสิทธิ์ กันค่ามั่วหลุดเข้า store
*/
function admin_all_tabs(): array
{
return [
'accounts', 'admins', 'oauth', 'change-password',
'achievements', 'ai-admin', 'case-media', 'characters', 'evidence-cards',
'game-timing', 'highscore', 'map-editor', 'postcase', 'qb-map-editor',
'quiz', 'quiz-battle', 'quiz-carry', 'sound', 'special-quiz',
'jump-survive', 'mega-virus', 'space-shooter', 'stack-game',
'test-mode', 'troublesome', 'vote-timing',
];
}
/**
* หมวดที่แอดมินคนนี้เข้าได้จริง
* - super = ทุกหมวดเสมอ
* - ไม่มีคีย์ tabs หรือ tabs ว่าง = ทุกหมวด (ความเข้ากันได้กับบัญชีเดิมที่สร้างก่อนมีระบบสิทธิ์)
*/
function admin_tabs(array $a): array
{
if (is_super($a)) {
return admin_all_tabs();
}
$t = $a['tabs'] ?? null;
if (!is_array($t) || count($t) === 0) {
return admin_all_tabs();
}
return array_values(array_intersect(admin_all_tabs(), $t));
}
function admin_can(array $a, string $tab): bool
{
return in_array($tab, admin_tabs($a), true);
}
/** กันที่ API: แอดมินที่ไม่มีสิทธิ์หมวดนี้ ยิงตรงมาก็ต้องโดนปฏิเสธ (ซ่อน tab ฝั่งหน้าเว็บอย่างเดียวไม่ใช่ security) */
function require_tab(string $tab): array
{
$a = current_admin();
if (!$a) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
if (!admin_can($a, $tab)) {
json_response(['ok' => false, 'error' => 'บัญชีนี้ไม่มีสิทธิ์เข้าหมวด "' . $tab . '"'], 403);
}
return $a;
}
/**
* บาง endpoint เป็น "config ก้อนเดียวที่หลายหมวดใช้ร่วมกัน" (เช่น quiz-settings.json)
* ถ้า gate ไว้หมวดเดียวจะพังหมวดอื่น → ผ่านถ้ามีสิทธิ์ "อย่างน้อย 1 หมวด" ในลิสต์
*/
function require_any_tab(array $tabs): array
{
$a = current_admin();
if (!$a) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
foreach ($tabs as $t) {
if (admin_can($a, $t)) {
return $a;
}
}
json_response(['ok' => false, 'error' => 'บัญชีนี้ไม่มีสิทธิ์เข้าหมวดที่เกี่ยวข้อง'], 403);
}
/** normalize ค่า tabs ที่รับมาจาก client ให้เหลือเฉพาะที่อยู่ใน whitelist */
function sanitize_tabs($raw): array
{
if (!is_array($raw)) {
return [];
}
$all = admin_all_tabs();
$out = [];
foreach ($raw as $t) {
if (is_string($t) && in_array($t, $all, true) && !in_array($t, $out, true)) {
$out[] = $t;
}
}
return $out;
}
function strip_admin(array $a): array
{
unset($a['passwordHash']);
$a['tabsEffective'] = admin_tabs($a);
return $a;
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* Shared helpers — lookup player account by jdPlayerKey (providerUserId).
* รองรับทั้ง guest และ google (และ loginType อื่นในอนาคต)
*/
function valid_player_key(string $key): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
function find_account_index_by_player_key(array $accounts, string $key): int
{
foreach ($accounts as $i => $a) {
if (($a['providerUserId'] ?? '') === $key) {
return (int) $i;
}
}
return -1;
}
function find_account_by_player_key(array $accounts, string $key): ?array
{
$idx = find_account_index_by_player_key($accounts, $key);
return $idx >= 0 ? $accounts[$idx] : null;
}
function default_guest_account(string $key, string $notes = 'auto', array $extra = []): array
{
$now = gmdate('c');
return array_merge([
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => $notes,
'blocked' => false,
'coins' => 0,
'createdAt' => $now,
'updatedAt' => $now,
], $extra);
}
function google_player_key(string $googleSub): string
{
$sub = trim($googleSub);
if ($sub === '') {
return '';
}
if (str_starts_with($sub, 'g_')) {
return $sub;
}
return 'g_' . $sub;
}
function merge_guest_account_into(array &$store, int $targetIdx, string $guestKey): void
{
if ($guestKey === '' || !valid_player_key($guestKey)) {
return;
}
$guestIdx = find_account_index_by_player_key($store['accounts'] ?? [], $guestKey);
if ($guestIdx < 0 || $guestIdx === $targetIdx) {
return;
}
$guest = $store['accounts'][$guestIdx];
if (($guest['loginType'] ?? '') !== 'guest') {
return;
}
$target = &$store['accounts'][$targetIdx];
$target['coins'] = max(0, (int) ($target['coins'] ?? 0)) + max(0, (int) ($guest['coins'] ?? 0));
$target['score'] = max(0, (int) ($target['score'] ?? 0)) + max(0, (int) ($guest['score'] ?? 0));
if (isset($guest['scoreByCase']) && is_array($guest['scoreByCase'])) {
if (!isset($target['scoreByCase']) || !is_array($target['scoreByCase'])) {
$target['scoreByCase'] = [];
}
foreach ($guest['scoreByCase'] as $cid => $sc) {
$target['scoreByCase'][$cid] = max(0, (int) ($target['scoreByCase'][$cid] ?? 0)) + max(0, (int) $sc);
}
}
if (isset($guest['achievements']) && is_array($guest['achievements'])) {
if (!isset($target['achievements']) || !is_array($target['achievements'])) {
$target['achievements'] = [];
}
foreach ($guest['achievements'] as $aid => $cnt) {
$target['achievements'][$aid] = max((int) ($target['achievements'][$aid] ?? 0), (int) $cnt);
}
}
if (isset($guest['daily']) && is_array($guest['daily']) && empty($target['daily'])) {
$target['daily'] = $guest['daily'];
}
if (!isset($target['lobbyColorThemeIndex']) && isset($guest['lobbyColorThemeIndex'])) {
$target['lobbyColorThemeIndex'] = $guest['lobbyColorThemeIndex'];
}
if (!isset($target['lobbySkinToneIndex']) && isset($guest['lobbySkinToneIndex'])) {
$target['lobbySkinToneIndex'] = $guest['lobbySkinToneIndex'];
}
if (($target['displayName'] ?? '') === '' || ($target['displayName'] ?? '') === 'Guest') {
$gn = trim((string) ($guest['displayName'] ?? ''));
if ($gn !== '' && $gn !== 'Guest') {
$target['displayName'] = $gn;
}
}
array_splice($store['accounts'], $guestIdx, 1);
if ($guestIdx < $targetIdx) {
$targetIdx--;
}
}
+317
View File
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/_player_account.php';
define('PLAYER_LOG_FILE', ADMIN_PRIVATE_DIR . '/player-logs.json');
define('PLAYER_LOG_MAX_EVENTS', 2000);
define('PLAYER_LOG_SESSION_GAP_SEC', 1800); // 30 นาที = session ใหม่
function player_log_default(): array
{
return [
'version' => 1,
'stats' => [
'totalEvents' => 0,
'totalVisits' => 0,
'uniquePlayers' => 0,
'visitsToday' => 0,
'todayKey' => '',
],
'players' => [],
'events' => [],
];
}
function player_log_read(): array
{
if (!is_file(PLAYER_LOG_FILE)) {
return player_log_default();
}
$raw = @file_get_contents(PLAYER_LOG_FILE);
$j = json_decode($raw ?: '{}', true);
if (!is_array($j)) {
return player_log_default();
}
return array_replace_recursive(player_log_default(), $j);
}
function player_log_write(array $data): bool
{
if (!is_dir(ADMIN_PRIVATE_DIR)) {
if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) {
return false;
}
}
$tmp = PLAYER_LOG_FILE . '.tmp.' . bin2hex(random_bytes(4));
$json = json_encode($data, 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, PLAYER_LOG_FILE)) {
@unlink($tmp);
return false;
}
return true;
}
function player_log_today_key(): string
{
$tz = new DateTimeZone('Asia/Bangkok');
return (new DateTimeImmutable('now', $tz))->format('Y-m-d');
}
function player_log_reset_today_if_needed(array &$log): void
{
$today = player_log_today_key();
if (($log['stats']['todayKey'] ?? '') !== $today) {
$log['stats']['todayKey'] = $today;
$log['stats']['visitsToday'] = 0;
}
}
function player_log_allowed_events(): array
{
return [
'login_guest' => true,
'login_google' => true,
'login_facebook' => true,
'lobby_enter' => true,
'room_enter' => true,
'game_enter' => true,
'quiz_battle_enter' => true,
'logout' => true,
];
}
function player_log_sanitize_name(string $raw): string
{
$s = trim($raw);
$s = preg_replace('/[\x00-\x1f\x7f]/u', '', $s) ?? '';
if (function_exists('mb_substr')) {
return mb_substr($s, 0, 32);
}
return substr($s, 0, 32);
}
function player_log_sanitize_page(string $raw): string
{
$s = trim($raw);
if (function_exists('mb_substr')) {
return mb_substr($s, 0, 180);
}
return substr($s, 0, 180);
}
/** event ที่นับเป็น "การเข้าใช้งาน" */
function player_log_counts_as_visit(string $event): bool
{
return isset([
'login_guest' => true,
'login_google' => true,
'login_facebook' => true,
'lobby_enter' => true,
'room_enter' => true,
'game_enter' => true,
][$event]);
}
function player_log_track(
string $playerKey,
string $event,
string $displayName = '',
string $loginType = 'guest',
string $page = '',
array $meta = []
): array {
$allowed = player_log_allowed_events();
if (!isset($allowed[$event])) {
return ['ok' => false, 'error' => 'unknown event'];
}
if (!valid_player_key($playerKey)) {
return ['ok' => false, 'error' => 'invalid playerKey'];
}
$lt = in_array($loginType, ['guest', 'google', 'facebook', 'email'], true) ? $loginType : 'guest';
$name = player_log_sanitize_name($displayName);
$page = player_log_sanitize_page($page);
$now = gmdate('c');
$nowTs = time();
$log = player_log_read();
player_log_reset_today_if_needed($log);
if (!isset($log['players']) || !is_array($log['players'])) {
$log['players'] = [];
}
if (!isset($log['events']) || !is_array($log['events'])) {
$log['events'] = [];
}
$isNewPlayer = !isset($log['players'][$playerKey]);
if ($isNewPlayer) {
$log['players'][$playerKey] = [
'playerKey' => $playerKey,
'displayName' => $name !== '' ? $name : 'ผู้เล่น',
'loginType' => $lt,
'visitCount' => 0,
'eventCount' => 0,
'firstSeenAt' => $now,
'lastSeenAt' => $now,
'lastVisitAt' => null,
'lastEvent' => $event,
'lastPage' => $page,
];
$log['stats']['uniquePlayers'] = count($log['players']);
}
$p = &$log['players'][$playerKey];
if ($name !== '') {
$p['displayName'] = $name;
}
$p['loginType'] = $lt;
$p['lastSeenAt'] = $now;
$p['lastEvent'] = $event;
if ($page !== '') {
$p['lastPage'] = $page;
}
$p['eventCount'] = max(0, (int) ($p['eventCount'] ?? 0)) + 1;
$countVisit = false;
if (player_log_counts_as_visit($event)) {
$lastVisitAt = isset($p['lastVisitAt']) ? strtotime((string) $p['lastVisitAt']) : false;
if ($lastVisitAt === false || ($nowTs - $lastVisitAt) >= PLAYER_LOG_SESSION_GAP_SEC) {
$countVisit = true;
$p['visitCount'] = max(0, (int) ($p['visitCount'] ?? 0)) + 1;
$p['lastVisitAt'] = $now;
$log['stats']['totalVisits'] = max(0, (int) ($log['stats']['totalVisits'] ?? 0)) + 1;
$log['stats']['visitsToday'] = max(0, (int) ($log['stats']['visitsToday'] ?? 0)) + 1;
}
}
$log['stats']['totalEvents'] = max(0, (int) ($log['stats']['totalEvents'] ?? 0)) + 1;
$ev = [
'id' => bin2hex(random_bytes(8)),
'at' => $now,
'playerKey' => $playerKey,
'displayName' => (string) ($p['displayName'] ?? ''),
'loginType' => $lt,
'event' => $event,
'page' => $page,
'meta' => is_array($meta) ? $meta : [],
'countedVisit' => $countVisit,
];
array_unshift($log['events'], $ev);
if (count($log['events']) > PLAYER_LOG_MAX_EVENTS) {
$log['events'] = array_slice($log['events'], 0, PLAYER_LOG_MAX_EVENTS);
}
if (!player_log_write($log)) {
return ['ok' => false, 'error' => 'บันทึก log ไม่สำเร็จ'];
}
return [
'ok' => true,
'countedVisit' => $countVisit,
'visitCount' => (int) ($p['visitCount'] ?? 0),
];
}
function player_log_public_summary(array $log): array
{
player_log_reset_today_if_needed($log);
return [
'totalEvents' => max(0, (int) ($log['stats']['totalEvents'] ?? 0)),
'totalVisits' => max(0, (int) ($log['stats']['totalVisits'] ?? 0)),
'uniquePlayers' => max(0, (int) ($log['stats']['uniquePlayers'] ?? count($log['players'] ?? []))),
'visitsToday' => max(0, (int) ($log['stats']['visitsToday'] ?? 0)),
'todayKey' => (string) ($log['stats']['todayKey'] ?? player_log_today_key()),
'eventCap' => PLAYER_LOG_MAX_EVENTS,
'storedEvents' => count($log['events'] ?? []),
];
}
function player_log_players_list(array $log, string $search = '', int $limit = 200): array
{
$rows = [];
foreach ($log['players'] ?? [] as $p) {
if (!is_array($p)) {
continue;
}
$key = (string) ($p['playerKey'] ?? '');
if ($key === '') {
continue;
}
if ($search !== '') {
$hay = strtolower($key . ' ' . ($p['displayName'] ?? '') . ' ' . ($p['loginType'] ?? ''));
if (strpos($hay, strtolower($search)) === false) {
continue;
}
}
$rows[] = [
'playerKey' => $key,
'displayName' => (string) ($p['displayName'] ?? 'ผู้เล่น'),
'loginType' => (string) ($p['loginType'] ?? 'guest'),
'visitCount' => max(0, (int) ($p['visitCount'] ?? 0)),
'eventCount' => max(0, (int) ($p['eventCount'] ?? 0)),
'firstSeenAt' => (string) ($p['firstSeenAt'] ?? ''),
'lastSeenAt' => (string) ($p['lastSeenAt'] ?? ''),
'lastVisitAt' => (string) ($p['lastVisitAt'] ?? ''),
'lastEvent' => (string) ($p['lastEvent'] ?? ''),
'lastPage' => (string) ($p['lastPage'] ?? ''),
];
}
usort($rows, static function ($a, $b) {
return strcmp((string) ($b['lastSeenAt'] ?? ''), (string) ($a['lastSeenAt'] ?? ''));
});
if ($limit > 0 && count($rows) > $limit) {
$rows = array_slice($rows, 0, $limit);
}
return $rows;
}
function player_log_events_list(array $log, array $opts = []): array
{
$search = trim((string) ($opts['search'] ?? ''));
$event = trim((string) ($opts['event'] ?? ''));
$playerKey = trim((string) ($opts['playerKey'] ?? ''));
$limit = max(1, min(500, (int) ($opts['limit'] ?? 100)));
$offset = max(0, (int) ($opts['offset'] ?? 0));
$rows = [];
foreach ($log['events'] ?? [] as $ev) {
if (!is_array($ev)) {
continue;
}
if ($event !== '' && ($ev['event'] ?? '') !== $event) {
continue;
}
if ($playerKey !== '' && ($ev['playerKey'] ?? '') !== $playerKey) {
continue;
}
if ($search !== '') {
$hay = strtolower(
($ev['playerKey'] ?? '') . ' ' .
($ev['displayName'] ?? '') . ' ' .
($ev['event'] ?? '') . ' ' .
($ev['page'] ?? '')
);
if (strpos($hay, strtolower($search)) === false) {
continue;
}
}
$rows[] = $ev;
}
$total = count($rows);
if ($offset > 0) {
$rows = array_slice($rows, $offset);
}
if (count($rows) > $limit) {
$rows = array_slice($rows, 0, $limit);
}
return ['rows' => $rows, 'total' => $total];
}
+120 -119
View File
@@ -1,119 +1,120 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
function normalize_account_row(array $a): array
{
$a['coins'] = max(0, (int)($a['coins'] ?? 0));
$a['score'] = max(0, (int)($a['score'] ?? 0));
return $a;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('normalize_account_row', read_store()['accounts'] ?? []);
json_response(['ok' => true, 'accounts' => $list]);
}
if ($method === 'POST') {
$body = require_json_body();
$store = read_store();
$coins = isset($body['coins']) ? (int)$body['coins'] : 0;
$coins = max(0, $coins);
$acc = [
'id' => new_id(),
'email' => trim((string)($body['email'] ?? '')),
'displayName' => trim((string)($body['displayName'] ?? '')),
'loginType' => in_array($body['loginType'] ?? '', ['guest', 'facebook', 'google', 'email'], true)
? $body['loginType'] : 'guest',
'providerUserId' => trim((string)($body['providerUserId'] ?? '')),
'notes' => trim((string)($body['notes'] ?? '')),
'blocked' => !empty($body['blocked']),
'coins' => $coins,
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
$store['accounts'][] = $acc;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'account' => $acc]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$id = trim((string)($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach ($store['accounts'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $id) {
continue;
}
$found = true;
if (array_key_exists('email', $body)) {
$store['accounts'][$i]['email'] = trim((string)$body['email']);
}
if (array_key_exists('displayName', $body)) {
$store['accounts'][$i]['displayName'] = trim((string)$body['displayName']);
}
if (array_key_exists('loginType', $body)) {
$lt = $body['loginType'];
if (in_array($lt, ['guest', 'facebook', 'google', 'email'], true)) {
$store['accounts'][$i]['loginType'] = $lt;
}
}
if (array_key_exists('providerUserId', $body)) {
$store['accounts'][$i]['providerUserId'] = trim((string)$body['providerUserId']);
}
if (array_key_exists('notes', $body)) {
$store['accounts'][$i]['notes'] = trim((string)$body['notes']);
}
if (array_key_exists('blocked', $body)) {
$store['accounts'][$i]['blocked'] = !empty($body['blocked']);
}
if (array_key_exists('coins', $body)) {
$store['accounts'][$i]['coins'] = max(0, (int)$body['coins']);
}
if (array_key_exists('score', $body)) {
$store['accounts'][$i]['score'] = max(0, (int)$body['score']);
}
if (array_key_exists('coinsDelta', $body)) {
$cur = max(0, (int)($store['accounts'][$i]['coins'] ?? 0));
$store['accounts'][$i]['coins'] = max(0, $cur + (int)$body['coinsDelta']);
}
$store['accounts'][$i]['updatedAt'] = gmdate('c');
break;
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$store['accounts'] = array_values(array_filter(
$store['accounts'] ?? [],
static fn($a) => ($a['id'] ?? '') !== $id
));
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_tab('accounts');
function normalize_account_row(array $a): array
{
$a['coins'] = max(0, (int)($a['coins'] ?? 0));
$a['score'] = max(0, (int)($a['score'] ?? 0));
return $a;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('normalize_account_row', read_store()['accounts'] ?? []);
json_response(['ok' => true, 'accounts' => $list]);
}
if ($method === 'POST') {
$body = require_json_body();
$store = read_store();
$coins = isset($body['coins']) ? (int)$body['coins'] : 0;
$coins = max(0, $coins);
$acc = [
'id' => new_id(),
'email' => trim((string)($body['email'] ?? '')),
'displayName' => trim((string)($body['displayName'] ?? '')),
'loginType' => in_array($body['loginType'] ?? '', ['guest', 'facebook', 'google', 'email'], true)
? $body['loginType'] : 'guest',
'providerUserId' => trim((string)($body['providerUserId'] ?? '')),
'notes' => trim((string)($body['notes'] ?? '')),
'blocked' => !empty($body['blocked']),
'coins' => $coins,
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
$store['accounts'][] = $acc;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'account' => $acc]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$id = trim((string)($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach ($store['accounts'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $id) {
continue;
}
$found = true;
if (array_key_exists('email', $body)) {
$store['accounts'][$i]['email'] = trim((string)$body['email']);
}
if (array_key_exists('displayName', $body)) {
$store['accounts'][$i]['displayName'] = trim((string)$body['displayName']);
}
if (array_key_exists('loginType', $body)) {
$lt = $body['loginType'];
if (in_array($lt, ['guest', 'facebook', 'google', 'email'], true)) {
$store['accounts'][$i]['loginType'] = $lt;
}
}
if (array_key_exists('providerUserId', $body)) {
$store['accounts'][$i]['providerUserId'] = trim((string)$body['providerUserId']);
}
if (array_key_exists('notes', $body)) {
$store['accounts'][$i]['notes'] = trim((string)$body['notes']);
}
if (array_key_exists('blocked', $body)) {
$store['accounts'][$i]['blocked'] = !empty($body['blocked']);
}
if (array_key_exists('coins', $body)) {
$store['accounts'][$i]['coins'] = max(0, (int)$body['coins']);
}
if (array_key_exists('score', $body)) {
$store['accounts'][$i]['score'] = max(0, (int)$body['score']);
}
if (array_key_exists('coinsDelta', $body)) {
$cur = max(0, (int)($store['accounts'][$i]['coins'] ?? 0));
$store['accounts'][$i]['coins'] = max(0, $cur + (int)$body['coinsDelta']);
}
$store['accounts'][$i]['updatedAt'] = gmdate('c');
break;
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$store['accounts'] = array_values(array_filter(
$store['accounts'] ?? [],
static fn($a) => ($a['id'] ?? '') !== $id
));
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+397 -382
View File
@@ -1,382 +1,397 @@
<?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]);
}
/* streak — สำหรับ achievement แบบ "ติดต่อกัน" (a6/d4/e1): event 'hit' นับต่อ, 'miss' รีเซ็ต 0.
* เก็บ current streak แยกใน accounts[].achvStreak {id:count} · achievement = best streak ที่เคยถึง (ไม่ลดลงเวลารีเซ็ต) */
if ($action === 'streak' && $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'] ?? ''));
$event = (string) ($body['event'] ?? '');
if (!achv_valid_key($key) || $id === '' || ($event !== 'hit' && $event !== 'miss')) {
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);
$acct = $store['accounts'][$i];
$streaks = (isset($acct['achvStreak']) && is_array($acct['achvStreak'])) ? $acct['achvStreak'] : [];
$sc = max(0, (int) ($streaks[$id] ?? 0));
$sc = ($event === 'hit') ? ($sc + 1) : 0;
$streaks[$id] = $sc;
$cur = achv_progress_of($acct);
$best = max(0, min($target, max((int) ($cur[$id] ?? 0), $sc))); /* achievement = best ไม่ลดลง */
$cur[$id] = $best;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['achvStreak'] = (object) $streaks;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'streak' => $sc, 'value' => $best, 'unlocked' => $best >= $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);
<?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]);
}
/* ---------- Server-only (secret) : streak [2026-07-27] ----------
* ก่อนหน้านี้ "ไม่มี branch นี้เลย" → POST action=streak จาก Game/server.js:2264 ตกไปถึง
* require_login() ด้านล่าง แล้วได้ 401 Unauthorized ทุกครั้ง = achievement แบบ streak
* (a6_unbreakable_logic / d4_flawless_diver / e1_the_observer) ไม่เคยถูกบันทึกเลย
*
* สัญญาที่ server.js คาดไว้: {playerKey, id, event:'hit'|'miss'}
* hit → streak ปัจจุบัน +1
* miss → streak ปัจจุบัน = 0
* ค่า achievement ที่เก็บ = **สถิติสูงสุด (best) ไม่ลดลง** ตามคอมเมนต์ที่ server.js เขียนไว้
* streak ที่กำลังนับอยู่เก็บแยกใน achievementStreaks เพื่อไม่ให้ทับ progress ปกติ
*/
if ($action === 'streak' && $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'] ?? ''));
$event = (string) ($body['event'] ?? '');
if (!achv_valid_key($key) || $id === '' || ($event !== 'hit' && $event !== 'miss')) {
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);
$runs = [];
if (isset($store['accounts'][$i]['achievementStreaks'])) {
$runs = (array) $store['accounts'][$i]['achievementStreaks'];
}
$run = max(0, (int) ($runs[$id] ?? 0));
$run = ($event === 'miss') ? 0 : $run + 1;
$runs[$id] = $run;
$cur = achv_progress_of($store['accounts'][$i]);
/* best ไม่ลดลง — miss ไม่ทำให้ achievement ที่เคยได้หายไป */
$best = max((int) ($cur[$id] ?? 0), min($target, $run));
$cur[$id] = $best;
$store['accounts'][$i]['achievementStreaks'] = (object) $runs;
$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, 'event' => $event, 'streak' => $run, 'value' => $best, 'unlocked' => $best >= $target]);
}
/* ---------- Admin ---------- */
require_login();
require_tab('achievements');
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);
+141 -127
View File
@@ -1,127 +1,141 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
$me = current_admin();
if (!$me) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('strip_admin', read_store()['admins'] ?? []);
json_response(['ok' => true, 'admins' => $list]);
}
if (!is_super($me)) {
json_response(['ok' => false, 'error' => 'เฉพาะ super admin จัดการบัญชีแอดมินได้'], 403);
}
if ($method === 'POST') {
$body = require_json_body();
$username = trim((string)($body['username'] ?? ''));
$password = (string)($body['password'] ?? '');
$role = ($body['role'] ?? 'admin') === 'super' ? 'super' : 'admin';
if ($role === 'super' && !is_super($me)) {
json_response(['ok' => false, 'error' => 'สร้าง super admin ได้เฉพาะ super เท่านั้น'], 403);
}
if ($username === '' || strlen($username) < 2) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้สั้นเกินไป'], 400);
}
if (strlen($password) < 8) {
json_response(['ok' => false, 'error' => 'รหัสผ่านอย่างน้อย 8 ตัวอักษร'], 400);
}
$store = read_store();
foreach ($store['admins'] ?? [] as $x) {
if (strcasecmp($x['username'] ?? '', $username) === 0) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้ซ้ำ'], 400);
}
}
$store['admins'][] = [
'id' => new_id(),
'username' => $username,
'passwordHash' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'createdAt' => gmdate('c'),
];
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$targetId = trim((string)($body['id'] ?? ''));
$newPassword = (string)($body['newPassword'] ?? '');
if ($targetId === '') {
json_response(['ok' => false, 'error' => 'ระบุ id แอดมิน'], 400);
}
if (strlen($newPassword) < 8) {
json_response(['ok' => false, 'error' => 'รหัสใหม่อย่างน้อย 8 ตัวอักษร'], 400);
}
if ($targetId === $me['id']) {
json_response(['ok' => false, 'error' => 'เปลี่ยนรหัสตัวเองให้ใช้เมนู "เปลี่ยนรหัสผ่านของคุณ"'], 400);
}
$store = read_store();
$updated = false;
foreach ($store['admins'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $targetId) {
continue;
}
$store['admins'][$i]['passwordHash'] = password_hash($newPassword, PASSWORD_DEFAULT);
$updated = true;
break;
}
if (!$updated) {
json_response(['ok' => false, 'error' => 'ไม่พบแอดมิน'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'message' => 'ตั้งรหัสใหม่แล้ว']);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
if ($id === $me['id']) {
json_response(['ok' => false, 'error' => 'ลบบัญชีตัวเองไม่ได้'], 400);
}
$store = read_store();
$admins = $store['admins'] ?? [];
$next = [];
$removed = false;
foreach ($admins as $a) {
if (($a['id'] ?? '') === $id) {
$removed = true;
continue;
}
$next[] = $a;
}
if (!$removed) {
json_response(['ok' => false, 'error' => 'ไม่พบผู้ใช้'], 404);
}
$supers = 0;
foreach ($next as $a) {
if (($a['role'] ?? '') === 'super') {
$supers++;
}
}
if ($supers < 1) {
json_response(['ok' => false, 'error' => 'ต้องมี super admin อย่างน้อย 1 คน'], 400);
}
$store['admins'] = $next;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_tab('admins');
$me = current_admin();
if (!$me) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('strip_admin', read_store()['admins'] ?? []);
json_response(['ok' => true, 'admins' => $list]);
}
if (!is_super($me)) {
json_response(['ok' => false, 'error' => 'เฉพาะ super admin จัดการบัญชีแอดมินได้'], 403);
}
if ($method === 'POST') {
$body = require_json_body();
$username = trim((string)($body['username'] ?? ''));
$password = (string)($body['password'] ?? '');
$role = ($body['role'] ?? 'admin') === 'super' ? 'super' : 'admin';
if ($role === 'super' && !is_super($me)) {
json_response(['ok' => false, 'error' => 'สร้าง super admin ได้เฉพาะ super เท่านั้น'], 403);
}
if ($username === '' || strlen($username) < 2) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้สั้นเกินไป'], 400);
}
if (strlen($password) < 8) {
json_response(['ok' => false, 'error' => 'รหัสผ่านอย่างน้อย 8 ตัวอักษร'], 400);
}
$store = read_store();
foreach ($store['admins'] ?? [] as $x) {
if (strcasecmp($x['username'] ?? '', $username) === 0) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้ซ้ำ'], 400);
}
}
/* สิทธิ์ระดับหมวด — super ไม่ต้องเก็บ (ได้ทุกหมวดเสมอ) · admin ที่ส่ง tabs ว่างมา = ทุกหมวด (พฤติกรรมเดิม) */
$store['admins'][] = [
'id' => new_id(),
'username' => $username,
'passwordHash' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'tabs' => $role === 'super' ? [] : sanitize_tabs($body['tabs'] ?? []),
'createdAt' => gmdate('c'),
];
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$targetId = trim((string)($body['id'] ?? ''));
$newPassword = (string)($body['newPassword'] ?? '');
/* PATCH ทำได้ 2 อย่าง: ตั้งรหัสใหม่ และ/หรือ แก้สิทธิ์หมวด — ต้องส่งมาอย่างน้อย 1 อย่าง */
$wantTabs = array_key_exists('tabs', $body);
if ($targetId === '') {
json_response(['ok' => false, 'error' => 'ระบุ id แอดมิน'], 400);
}
if ($newPassword === '' && !$wantTabs) {
json_response(['ok' => false, 'error' => 'ไม่มีอะไรให้แก้ (ส่ง newPassword หรือ tabs)'], 400);
}
if ($newPassword !== '' && strlen($newPassword) < 8) {
json_response(['ok' => false, 'error' => 'รหัสใหม่อย่างน้อย 8 ตัวอักษร'], 400);
}
if ($newPassword !== '' && $targetId === $me['id']) {
json_response(['ok' => false, 'error' => 'เปลี่ยนรหัสตัวเองให้ใช้เมนู "เปลี่ยนรหัสผ่านของคุณ"'], 400);
}
$store = read_store();
$updated = false;
foreach ($store['admins'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $targetId) {
continue;
}
if ($newPassword !== '') {
$store['admins'][$i]['passwordHash'] = password_hash($newPassword, PASSWORD_DEFAULT);
}
if ($wantTabs) {
/* super ได้ทุกหมวดอยู่แล้ว — เก็บ tabs ว่างไว้ กันเข้าใจผิดว่าถูกจำกัด */
$store['admins'][$i]['tabs'] = (($a['role'] ?? '') === 'super') ? [] : sanitize_tabs($body['tabs']);
}
$updated = true;
break;
}
if (!$updated) {
json_response(['ok' => false, 'error' => 'ไม่พบแอดมิน'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'message' => $newPassword !== '' ? 'ตั้งรหัสใหม่แล้ว' : 'บันทึกสิทธิ์หมวดแล้ว']);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
if ($id === $me['id']) {
json_response(['ok' => false, 'error' => 'ลบบัญชีตัวเองไม่ได้'], 400);
}
$store = read_store();
$admins = $store['admins'] ?? [];
$next = [];
$removed = false;
foreach ($admins as $a) {
if (($a['id'] ?? '') === $id) {
$removed = true;
continue;
}
$next[] = $a;
}
if (!$removed) {
json_response(['ok' => false, 'error' => 'ไม่พบผู้ใช้'], 404);
}
$supers = 0;
foreach ($next as $a) {
if (($a['role'] ?? '') === 'super') {
$supers++;
}
}
if ($supers < 1) {
json_response(['ok' => false, 'error' => 'ต้องมี super admin อย่างน้อย 1 คน'], 400);
}
$store['admins'] = $next;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* ตั้งค่า AI แชท — อ่าน/เขียน Game/data/ai-settings.json
* ใช้ session Admin หลัก (ไม่ต้องรหัส game-ai-admin แยกเมื่อเปิดจากแผง Admin)
*
* GET -> { model, models[], hasKey, intent, rag_enabled, rag_context } (ไม่ส่ง api key ดิบ)
* PUT -> { openai_api_key?, model?, intent?, rag_enabled?, rag_context? }
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('ai-admin');
define('AI_SETTINGS_FILE', dirname(__DIR__, 2) . '/Game/data/ai-settings.json');
function ai_models(): array
{
return [
'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano',
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research',
'o1', 'o1-mini', 'o1-pro',
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4',
'gpt-3.5-turbo',
];
}
function ai_defaults(): array
{
return [
'openai_api_key' => '',
'model' => 'gpt-4o-mini',
'intent' => '',
'rag_enabled' => false,
'rag_context' => '',
];
}
function ai_read(): array
{
$base = ai_defaults();
if (!is_file(AI_SETTINGS_FILE)) {
return $base;
}
$raw = @file_get_contents(AI_SETTINGS_FILE);
$j = json_decode($raw ?: '{}', true);
if (!is_array($j)) {
return $base;
}
return array_replace($base, array_intersect_key($j, $base));
}
function ai_write(array $s): bool
{
$dir = dirname(AI_SETTINGS_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return false;
}
$json = json_encode($s, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) {
return false;
}
$tmp = AI_SETTINGS_FILE . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
if (!rename($tmp, AI_SETTINGS_FILE)) {
@unlink($tmp);
return false;
}
return true;
}
function ai_public_payload(array $s): array
{
$key = trim((string) ($s['openai_api_key'] ?? ''));
return [
'ok' => true,
'model' => (string) ($s['model'] ?? 'gpt-4o-mini') ?: 'gpt-4o-mini',
'models' => ai_models(),
'hasKey' => $key !== '',
'intent' => (string) ($s['intent'] ?? ''),
'rag_enabled' => !empty($s['rag_enabled']),
'rag_context' => (string) ($s['rag_context'] ?? ''),
];
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
json_response(ai_public_payload(ai_read()));
}
if ($method === 'PUT' || $method === 'POST') {
$body = require_json_body();
$s = ai_read();
if (array_key_exists('openai_api_key', $body)) {
$s['openai_api_key'] = trim((string) $body['openai_api_key']);
}
if (array_key_exists('model', $body)) {
$m = trim((string) $body['model']);
$s['model'] = $m !== '' ? $m : 'gpt-4o-mini';
}
if (array_key_exists('intent', $body)) {
$s['intent'] = (string) $body['intent'];
}
if (array_key_exists('rag_enabled', $body)) {
$s['rag_enabled'] = !empty($body['rag_enabled']);
}
if (array_key_exists('rag_context', $body)) {
$s['rag_context'] = (string) $body['rag_context'];
}
if (!ai_write($s)) {
json_response(['ok' => false, 'error' => 'บันทึก ai-settings.json ไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
@@ -1,69 +1,70 @@
<?php
declare(strict_types=1);
/**
* Proxy อัปโหลดรูปป้าย quiz_carry → Node POST /Game/api/quiz-carry-plaque-upload
* ใช้เมื่อแอดมินอยู่คนละ path / nginx ไม่ส่ง POST ไป Node — คล้าย game-quiz-settings.php
*/
require __DIR__ . '/_common.php';
require_login();
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl · Enable php-curl'], 500);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'POST') {
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
}
$raw = file_get_contents('php://input');
if ($raw === false) {
$raw = '';
}
$contentLen = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
if ($raw === '' && $contentLen > 0) {
json_response([
'ok' => false,
'error' => 'รับ body ไม่ได้ (มักเกิดจาก nginx client_max_body_size หรือ PHP post_max_size เล็กเกินไป) — ต้องตั้งอย่างน้อย ~20M สำหรับ /Admin/api/*.php · Request body was discarded (raise nginx body limit + PHP post_max_size)',
], 413);
}
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-carry-plaque-upload';
$ch = curl_init($target);
if ($ch === false) {
json_response(['ok' => false, 'error' => 'curl init failed'], 500);
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
json_response([
'ok' => false,
'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจ systemd/pm2 และพอร์ต ' . $port . ' · ' . $err,
], 502);
}
http_response_code($code > 0 ? $code : 500);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
echo $out;
<?php
declare(strict_types=1);
/**
* Proxy อัปโหลดรูปป้าย quiz_carry → Node POST /Game/api/quiz-carry-plaque-upload
* ใช้เมื่อแอดมินอยู่คนละ path / nginx ไม่ส่ง POST ไป Node — คล้าย game-quiz-settings.php
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('quiz-carry');
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl · Enable php-curl'], 500);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'POST') {
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
}
$raw = file_get_contents('php://input');
if ($raw === false) {
$raw = '';
}
$contentLen = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
if ($raw === '' && $contentLen > 0) {
json_response([
'ok' => false,
'error' => 'รับ body ไม่ได้ (มักเกิดจาก nginx client_max_body_size หรือ PHP post_max_size เล็กเกินไป) — ต้องตั้งอย่างน้อย ~20M สำหรับ /Admin/api/*.php · Request body was discarded (raise nginx body limit + PHP post_max_size)',
], 413);
}
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-carry-plaque-upload';
$ch = curl_init($target);
if ($ch === false) {
json_response(['ok' => false, 'error' => 'curl init failed'], 500);
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
json_response([
'ok' => false,
'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจ systemd/pm2 และพอร์ต ' . $port . ' · ' . $err,
], 502);
}
http_response_code($code > 0 ? $code : 500);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
echo $out;
@@ -12,6 +12,7 @@ declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_any_tab(['quiz','quiz-carry','quiz-battle','special-quiz','game-timing','jump-survive','mega-virus','space-shooter','stack-game','evidence-cards','postcase','troublesome','vote-timing']);
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl (ติดตั้งแพ็กเกจ php-curl แล้วรีสตาร์ท PHP-FPM)'], 500);
+95 -94
View File
@@ -1,94 +1,95 @@
<?php
declare(strict_types=1);
/**
* แอดมินเท่านั้น — จัดการกระดานผู้นำ (High Score)
* GET → รายชื่อบัญชีเรียงตามคะแนนสะสม (มาก→น้อย)
* POST { action: 'resetAll' } → ล้างคะแนนทุกคนเป็น 0
* POST { action: 'reset', id } → ล้างคะแนนคนเดียวเป็น 0
* (การตั้งคะแนนรายคน ใช้ accounts.php PATCH { id, score })
*/
require __DIR__ . '/_common.php';
require_login();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$rows = [];
foreach (($store['accounts'] ?? []) as $a) {
$score = max(0, (int) ($a['score'] ?? 0));
$name = trim((string) ($a['lbName'] ?? ''));
if ($name === '') $name = trim((string) ($a['displayName'] ?? ''));
if ($name === '') $name = 'ผู้เล่น';
$rows[] = [
'id' => (string) ($a['id'] ?? ''),
'name' => $name,
'score' => $score,
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'loginType' => (string) ($a['loginType'] ?? ''),
'key' => (string) ($a['providerUserId'] ?? ''),
];
}
usort($rows, function ($x, $y) {
if ($y['score'] !== $x['score']) {
return $y['score'] - $x['score'];
}
return strcmp((string) $x['name'], (string) $y['name']);
});
foreach ($rows as $i => &$r) {
$r['rank'] = $i + 1;
}
unset($r);
json_response(['ok' => true, 'rows' => $rows, 'total' => count($rows)]);
}
if ($method === 'POST') {
$body = require_json_body();
$action = (string) ($body['action'] ?? '');
if ($action === 'resetAll') {
$store = read_store();
$n = 0;
foreach (($store['accounts'] ?? []) as $i => $a) {
if ((int) ($a['score'] ?? 0) !== 0) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$n++;
}
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'reset' => $n]);
}
if ($action === 'reset') {
$id = trim((string) ($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach (($store['accounts'] ?? []) as $i => $a) {
if (($a['id'] ?? '') === $id) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$found = true;
break;
}
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'action ไม่ถูกต้อง'], 400);
}
json_response(['ok' => false, 'error' => 'Use GET or POST'], 405);
<?php
declare(strict_types=1);
/**
* แอดมินเท่านั้น — จัดการกระดานผู้นำ (High Score)
* GET → รายชื่อบัญชีเรียงตามคะแนนสะสม (มาก→น้อย)
* POST { action: 'resetAll' } → ล้างคะแนนทุกคนเป็น 0
* POST { action: 'reset', id } → ล้างคะแนนคนเดียวเป็น 0
* (การตั้งคะแนนรายคน ใช้ accounts.php PATCH { id, score })
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('highscore');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$rows = [];
foreach (($store['accounts'] ?? []) as $a) {
$score = max(0, (int) ($a['score'] ?? 0));
$name = trim((string) ($a['lbName'] ?? ''));
if ($name === '') $name = trim((string) ($a['displayName'] ?? ''));
if ($name === '') $name = 'ผู้เล่น';
$rows[] = [
'id' => (string) ($a['id'] ?? ''),
'name' => $name,
'score' => $score,
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'loginType' => (string) ($a['loginType'] ?? ''),
'key' => (string) ($a['providerUserId'] ?? ''),
];
}
usort($rows, function ($x, $y) {
if ($y['score'] !== $x['score']) {
return $y['score'] - $x['score'];
}
return strcmp((string) $x['name'], (string) $y['name']);
});
foreach ($rows as $i => &$r) {
$r['rank'] = $i + 1;
}
unset($r);
json_response(['ok' => true, 'rows' => $rows, 'total' => count($rows)]);
}
if ($method === 'POST') {
$body = require_json_body();
$action = (string) ($body['action'] ?? '');
if ($action === 'resetAll') {
$store = read_store();
$n = 0;
foreach (($store['accounts'] ?? []) as $i => $a) {
if ((int) ($a['score'] ?? 0) !== 0) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$n++;
}
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'reset' => $n]);
}
if ($action === 'reset') {
$id = trim((string) ($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach (($store['accounts'] ?? []) as $i => $a) {
if (($a['id'] ?? '') === $id) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$found = true;
break;
}
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'action ไม่ถูกต้อง'], 400);
}
json_response(['ok' => false, 'error' => 'Use GET or POST'], 405);
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* Google OAuth — แลก authorization code เป็นข้อมูลผู้ใช้ แล้วสร้าง/อัปเดตบัญชี loginType=google
*
* POST JSON: { code, redirectUri, state?, mergeGuestKey? }
*/
require __DIR__ . '/_common.php';
require __DIR__ . '/_player_account.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
json_response(['ok' => false, 'error' => 'Use POST'], 405);
}
$body = require_json_body();
$code = trim((string) ($body['code'] ?? ''));
$redirectUri = trim((string) ($body['redirectUri'] ?? ''));
$mergeGuestKey = trim((string) ($body['mergeGuestKey'] ?? ''));
if ($code === '') {
json_response(['ok' => false, 'error' => 'missing code'], 400);
}
$oauth = read_store()['oauth'] ?? [];
$clientId = trim((string) ($oauth['googleClientId'] ?? ''));
$clientSecret = trim((string) ($oauth['googleClientSecret'] ?? ''));
$configuredRedirect = trim((string) ($oauth['googleRedirectUri'] ?? ''));
if ($clientId === '' || $clientSecret === '') {
json_response(['ok' => false, 'error' => 'ยังไม่ได้ตั้งค่า Google OAuth ใน Admin'], 503);
}
if ($redirectUri === '') {
$redirectUri = $configuredRedirect;
}
if ($redirectUri === '') {
json_response(['ok' => false, 'error' => 'missing redirectUri'], 400);
}
if ($configuredRedirect !== '' && $redirectUri !== $configuredRedirect) {
json_response(['ok' => false, 'error' => 'redirectUri ไม่ตรงกับที่ตั้งใน Admin'], 400);
}
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'PHP cURL ไม่พร้อมใช้งาน'], 500);
}
$tokenPayload = http_build_query([
'code' => $code,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => $redirectUri,
'grant_type' => 'authorization_code',
]);
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $tokenPayload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_TIMEOUT => 20,
]);
$tokenRaw = curl_exec($ch);
$tokenHttp = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$tokenJson = json_decode((string) $tokenRaw, true);
if ($tokenHttp < 200 || $tokenHttp >= 300 || !is_array($tokenJson)) {
json_response(['ok' => false, 'error' => 'แลก token จาก Google ไม่สำเร็จ', 'detail' => (string) $tokenRaw], 502);
}
$accessToken = trim((string) ($tokenJson['access_token'] ?? ''));
if ($accessToken === '') {
json_response(['ok' => false, 'error' => 'Google ไม่คืน access_token', 'detail' => $tokenJson], 502);
}
$ch2 = curl_init('https://www.googleapis.com/oauth2/v3/userinfo');
curl_setopt_array($ch2, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
CURLOPT_TIMEOUT => 20,
]);
$userRaw = curl_exec($ch2);
$userHttp = (int) curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
$user = json_decode((string) $userRaw, true);
if ($userHttp < 200 || $userHttp >= 300 || !is_array($user)) {
json_response(['ok' => false, 'error' => 'อ่านข้อมูลผู้ใช้จาก Google ไม่สำเร็จ'], 502);
}
$googleSub = trim((string) ($user['sub'] ?? ''));
if ($googleSub === '') {
json_response(['ok' => false, 'error' => 'Google ไม่คืน sub (user id)'], 502);
}
$playerKey = google_player_key($googleSub);
if (!valid_player_key($playerKey)) {
json_response(['ok' => false, 'error' => 'playerKey จาก Google ไม่ถูกต้อง'], 500);
}
$email = trim((string) ($user['email'] ?? ''));
$name = trim((string) ($user['name'] ?? ''));
if ($name === '') {
$name = trim((string) ($user['given_name'] ?? ''));
}
if ($name === '') {
$name = $email !== '' ? preg_replace('/@.*$/', '', $email) : 'Google Player';
}
if (function_exists('mb_substr')) {
$name = mb_substr($name, 0, 32);
} else {
$name = substr($name, 0, 32);
}
$picture = trim((string) ($user['picture'] ?? ''));
$now = gmdate('c');
$store = read_store();
if (!isset($store['accounts']) || !is_array($store['accounts'])) {
$store['accounts'] = [];
}
$idx = find_account_index_by_player_key($store['accounts'], $playerKey);
$isNew = $idx < 0;
if ($idx < 0) {
$store['accounts'][] = [
'id' => new_id(),
'email' => $email,
'displayName' => $name,
'loginType' => 'google',
'providerUserId' => $playerKey,
'googleSub' => $googleSub,
'avatarUrl' => $picture,
'notes' => 'oauth: google',
'blocked' => false,
'coins' => 0,
'createdAt' => $now,
'updatedAt' => $now,
];
$idx = count($store['accounts']) - 1;
} else {
$store['accounts'][$idx]['loginType'] = 'google';
$store['accounts'][$idx]['email'] = $email !== '' ? $email : ($store['accounts'][$idx]['email'] ?? '');
if ($name !== '') {
$store['accounts'][$idx]['displayName'] = $name;
}
$store['accounts'][$idx]['googleSub'] = $googleSub;
if ($picture !== '') {
$store['accounts'][$idx]['avatarUrl'] = $picture;
}
$store['accounts'][$idx]['updatedAt'] = $now;
}
if (!empty($store['accounts'][$idx]['blocked'])) {
json_response(['ok' => false, 'error' => 'บัญชีนี้ถูกระงับ'], 403);
}
if ($isNew && $mergeGuestKey !== '') {
merge_guest_account_into($store, $idx, $mergeGuestKey);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกบัญชีไม่สำเร็จ'], 500);
}
$acc = $store['accounts'][$idx] ?? [];
json_response([
'ok' => true,
'playerKey' => $playerKey,
'loginType' => 'google',
'displayName' => (string) ($acc['displayName'] ?? $name),
'email' => (string) ($acc['email'] ?? $email),
'avatarUrl' => (string) ($acc['avatarUrl'] ?? $picture),
'coins' => max(0, (int) ($acc['coins'] ?? 0)),
'accountId' => (string) ($acc['id'] ?? ''),
'isNew' => $isNew,
]);
+34 -33
View File
@@ -1,33 +1,34 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$o = read_store()['oauth'] ?? [];
json_response(['ok' => true, 'oauth' => $o]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'PUT' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['ok' => false, 'error' => 'Use GET or PUT'], 405);
}
$body = require_json_body();
$store = read_store();
$cur = $store['oauth'] ?? [];
$keys = [
'facebookAppId', 'facebookAppSecret', 'facebookRedirectUri',
'googleClientId', 'googleClientSecret', 'googleRedirectUri',
];
foreach ($keys as $k) {
if (array_key_exists($k, $body)) {
$cur[$k] = trim((string)$body[$k]);
}
}
$store['oauth'] = $cur;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'oauth' => $cur]);
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_tab('oauth');
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$o = read_store()['oauth'] ?? [];
json_response(['ok' => true, 'oauth' => $o]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'PUT' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['ok' => false, 'error' => 'Use GET or PUT'], 405);
}
$body = require_json_body();
$store = read_store();
$cur = $store['oauth'] ?? [];
$keys = [
'facebookAppId', 'facebookAppSecret', 'facebookRedirectUri',
'googleClientId', 'googleClientSecret', 'googleRedirectUri',
];
foreach ($keys as $k) {
if (array_key_exists($k, $body)) {
$cur[$k] = trim((string)$body[$k]);
}
}
$store['oauth'] = $cur;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'oauth' => $cur]);
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* Player activity log
*
* Public POST: { action:'track', playerKey, event, displayName?, loginType?, page?, meta? }
* Admin GET : ?action=summary|events|players
* Admin DELETE: ล้าง log ทั้งหมด
*/
require __DIR__ . '/_common.php';
require __DIR__ . '/_player_log.php';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'POST') {
$body = require_json_body();
$action = (string) ($body['action'] ?? 'track');
if ($action !== 'track') {
json_response(['ok' => false, 'error' => 'unknown action'], 400);
}
$playerKey = trim((string) ($body['playerKey'] ?? ''));
$event = trim((string) ($body['event'] ?? ''));
$displayName = trim((string) ($body['displayName'] ?? ''));
$loginType = trim((string) ($body['loginType'] ?? 'guest'));
$page = trim((string) ($body['page'] ?? ''));
$meta = isset($body['meta']) && is_array($body['meta']) ? $body['meta'] : [];
$result = player_log_track($playerKey, $event, $displayName, $loginType, $page, $meta);
if (empty($result['ok'])) {
json_response($result, 400);
}
json_response($result);
}
if ($method === 'GET') {
require_login();
$action = (string) ($_GET['action'] ?? 'summary');
$log = player_log_read();
if ($action === 'summary') {
json_response([
'ok' => true,
'summary' => player_log_public_summary($log),
'eventTypes' => array_keys(player_log_allowed_events()),
]);
}
if ($action === 'players') {
$search = trim((string) ($_GET['search'] ?? ''));
$limit = (int) ($_GET['limit'] ?? 200);
json_response([
'ok' => true,
'players' => player_log_players_list($log, $search, $limit),
]);
}
if ($action === 'events') {
$result = player_log_events_list($log, [
'search' => (string) ($_GET['search'] ?? ''),
'event' => (string) ($_GET['event'] ?? ''),
'playerKey' => (string) ($_GET['playerKey'] ?? ''),
'limit' => (int) ($_GET['limit'] ?? 100),
'offset' => (int) ($_GET['offset'] ?? 0),
]);
json_response([
'ok' => true,
'events' => $result['rows'],
'total' => $result['total'],
]);
}
json_response(['ok' => false, 'error' => 'unknown action'], 400);
}
if ($method === 'DELETE') {
require_login();
if (!player_log_write(player_log_default())) {
json_response(['ok' => false, 'error' => 'ล้าง log ไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Use GET, POST or DELETE'], 405);
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
/**
* เปิด/ปิด 10 โหมด (ห้อง) ของ Quiz Battle + กำหนดวัน-เวลาที่เล่นได้ถึง [2026-07-22]
*
* อ่าน/เขียน Game/data/quiz-battle-modes.json
* เวลาอ้างอิง = **เวลาไทย (Asia/Bangkok, UTC+7)** เก็บเป็นสตริง 'YYYY-MM-DD HH:MM' ตรงๆ
* (ไม่เก็บ UTC เพื่อให้ค่าที่แอดมินกรอกกับค่าที่เก็บตรงกันเป๊ะ อ่านไฟล์แล้วเข้าใจทันที)
*
* openUntil = '' → ไม่มีวันหมดเขต (เปิดตลอด)
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('quiz-battle');
define('QB_MODES_FILE', dirname(__DIR__, 2) . '/Game/data/quiz-battle-modes.json');
define('QB_ROOM_COUNT', 10);
define('QB_TZ', 'Asia/Bangkok');
define('QB_MAX_PLAYERS_CAP', 50); /* เพดานแข็ง — จำนวนคนต่อห้องปรับได้ไม่เกินนี้ */
/** จำกัดจำนวนคนต่อห้อง 1..50 (ค่าว่าง/เพี้ยน → 50) */
function qb_clamp_max($raw): int
{
$n = (int)$raw;
if ($n < 1) {
return QB_MAX_PLAYERS_CAP;
}
return min(QB_MAX_PLAYERS_CAP, $n);
}
/** ชื่อหัวข้อของแต่ละห้อง (ให้หน้า admin โชว์ — ตรงกับรูป Room-NN.png) */
function qb_default_titles(): array
{
return [
1 => 'กฎหมายใกล้ตัว',
2 => 'กฎหมายสิทธิพื้นฐาน',
3 => 'กฎหมายจราจร',
4 => 'คดีเกี่ยวกับทรัพย์',
5 => 'คดีหมิ่นประมาท',
6 => 'คดีทางเพศ',
7 => 'คดีอาชญากรรม',
8 => 'อาชญากรรมออนไลน์',
9 => 'กระบวนการยุติธรรม',
10 => 'งานบริการกระทรวงยุติธรรม',
];
}
function qb_default_modes(): array
{
$titles = qb_default_titles();
$modes = [];
for ($i = 1; $i <= QB_ROOM_COUNT; $i++) {
$modes[(string)$i] = [
'title' => $titles[$i] ?? ('ห้อง ' . $i),
'enabled' => true,
'openUntil' => '',
'maxPlayers' => QB_MAX_PLAYERS_CAP,
];
}
return $modes;
}
/** ชื่อหัวข้อห้อง — แก้ได้ [2026-07-23] · ตัดช่องว่าง/อักขระควบคุม จำกัด 60 ตัว · ว่าง → คืน default */
function qb_clean_title($raw, string $default): string
{
if (!is_string($raw)) {
return $default;
}
$s = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $raw); // ตัดอักขระควบคุม/ขึ้นบรรทัด
$s = trim(preg_replace('/\s+/u', ' ', $s ?? ''));
if ($s === '') {
return $default;
}
if (function_exists('mb_substr')) {
$s = mb_substr($s, 0, 60, 'UTF-8');
} else {
$s = substr($s, 0, 180);
}
return $s;
}
/** 'YYYY-MM-DD HH:MM' เท่านั้น (เวลาไทย) — ค่าอื่นถือว่าไม่ได้ตั้ง */
function qb_clean_until($raw): string
{
if (!is_string($raw)) {
return '';
}
$s = trim($raw);
if ($s === '') {
return '';
}
$s = str_replace('T', ' ', $s);
if (strlen($s) === 16 && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s) === 1) {
[$d, $t] = explode(' ', $s, 2);
[$y, $m, $dd] = array_map('intval', explode('-', $d));
if (checkdate($m, $dd, $y)) {
[$hh, $mm] = array_map('intval', explode(':', $t));
if ($hh >= 0 && $hh <= 23 && $mm >= 0 && $mm <= 59) {
return $s;
}
}
}
return '';
}
function qb_read_modes(): array
{
$out = ['version' => 1, 'updatedAt' => '', 'modes' => qb_default_modes()];
$raw = @file_get_contents(QB_MODES_FILE);
if ($raw === false || $raw === '') {
return $out;
}
$j = json_decode($raw, true);
if (!is_array($j) || !isset($j['modes']) || !is_array($j['modes'])) {
return $out;
}
$titles = qb_default_titles();
foreach ($out['modes'] as $k => $def) {
$src = $j['modes'][$k] ?? null;
if (!is_array($src)) {
continue;
}
$out['modes'][$k] = [
/* [2026-07-23] หัวข้อแก้ได้แล้ว — ใช้ค่าที่เก็บไว้ ถ้าว่าง fallback เป็น default ของห้องนั้น */
'title' => qb_clean_title($src['title'] ?? '', $titles[(int)$k] ?? $def['title']),
'enabled' => !isset($src['enabled']) || (bool)$src['enabled'],
'openUntil' => qb_clean_until($src['openUntil'] ?? ''),
'maxPlayers' => qb_clamp_max($src['maxPlayers'] ?? QB_MAX_PLAYERS_CAP),
];
}
$out['updatedAt'] = is_string($j['updatedAt'] ?? null) ? $j['updatedAt'] : '';
return $out;
}
function qb_write_modes(array $data): bool
{
$dir = dirname(QB_MODES_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
$tmp = QB_MODES_FILE . '.tmp';
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false || @file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
/* เขียน tmp แล้ว rename — กันไฟล์พังครึ่งทางถ้า Node อ่านพอดีจังหวะ */
if (!@rename($tmp, QB_MODES_FILE)) {
@unlink($tmp);
return false;
}
@chmod(QB_MODES_FILE, 0664);
return true;
}
/** สถานะที่คำนวณแล้ว ณ เวลาไทยตอนนี้ — ให้หน้า admin โชว์ว่าห้องไหน "ปิดไปแล้ว" */
function qb_state(array $mode): array
{
$tz = new DateTimeZone(QB_TZ);
$now = new DateTimeImmutable('now', $tz);
$until = $mode['openUntil'] ?? '';
$expired = false;
if ($until !== '') {
$end = DateTimeImmutable::createFromFormat('Y-m-d H:i', $until, $tz);
$expired = ($end !== false) && ($now > $end);
}
return [
'open' => (bool)($mode['enabled'] ?? true) && !$expired,
'expired' => $expired,
];
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$data = qb_read_modes();
$tz = new DateTimeZone(QB_TZ);
$now = new DateTimeImmutable('now', $tz);
foreach ($data['modes'] as $k => $m) {
$data['modes'][$k] = array_merge($m, qb_state($m));
}
$data['nowThai'] = $now->format('Y-m-d H:i');
$data['tz'] = QB_TZ;
json_response(['ok' => true] + $data);
}
if ($method === 'PUT' || $method === 'POST') {
$body = require_json_body();
$src = $body['modes'] ?? null;
if (!is_array($src)) {
json_response(['ok' => false, 'error' => 'ต้องส่ง modes มาด้วย'], 400);
}
$cur = qb_read_modes();
$next = $cur['modes'];
foreach ($next as $k => $def) {
$m = $src[$k] ?? null;
if (!is_array($m)) {
continue;
}
$next[$k] = [
/* [2026-07-23] หัวข้อแก้ได้ — sanitize + ถ้าไม่ส่ง/ว่าง คงค่าเดิม (ไม่ reset เป็น default) */
'title' => qb_clean_title($m['title'] ?? ($def['title'] ?? ''), $def['title'] ?? ('ห้อง ' . $k)),
'enabled' => !isset($m['enabled']) || (bool)$m['enabled'],
'openUntil' => qb_clean_until($m['openUntil'] ?? ''),
/* [2026-07-23] จำนวนคนต่อห้อง — ถ้าไม่ส่งมา คงค่าเดิมไว้ (ไม่ reset เป็น 50) */
'maxPlayers' => qb_clamp_max($m['maxPlayers'] ?? ($def['maxPlayers'] ?? QB_MAX_PLAYERS_CAP)),
];
}
$me = current_admin();
$payload = [
'version' => 1,
'updatedAt' => (new DateTimeImmutable('now', new DateTimeZone(QB_TZ)))->format('Y-m-d H:i'),
'updatedBy' => $me['username'] ?? '',
'tz' => QB_TZ,
'modes' => $next,
];
if (!qb_write_modes($payload)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ (สิทธิ์เขียนไฟล์?)'], 500);
}
foreach ($payload['modes'] as $k => $m) {
$payload['modes'][$k] = array_merge($m, qb_state($m));
}
json_response(['ok' => true, 'message' => 'บันทึกแล้ว'] + $payload);
}
json_response(['ok' => false, 'error' => 'Use GET / PUT'], 405);
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
/**
* คัดกรองการสร้างห้องเกม — Admin proxy ไป Node + เก็บประวัติอนุมัติ/ปฏิเสธ
*/
require __DIR__ . '/_common.php';
require_login();
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl'], 500);
}
function room_moderation_disk_path(): string
{
return dirname(__DIR__, 2) . '/Game/data/room-moderation.json';
}
function room_approval_history_path(): string
{
return ADMIN_PRIVATE_DIR . '/room-approval-history.json';
}
function sync_room_moderation_disk(bool $enabled): void
{
$path = room_moderation_disk_path();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$json = json_encode(['enabled' => $enabled], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json !== false) {
@file_put_contents($path, $json, LOCK_EX);
}
}
function read_room_approval_history(): array
{
$path = room_approval_history_path();
if (!is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
$j = json_decode($raw ?: '[]', true);
return is_array($j) ? $j : [];
}
function append_room_approval_history(array $row): void
{
$rows = read_room_approval_history();
array_unshift($rows, $row);
if (count($rows) > 500) {
$rows = array_slice($rows, 0, 500);
}
if (!is_dir(ADMIN_PRIVATE_DIR)) {
@mkdir(ADMIN_PRIVATE_DIR, 0750, true);
}
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json !== false) {
@file_put_contents(room_approval_history_path(), $json, LOCK_EX);
}
}
function node_room_approvals_url(string $suffix = ''): string
{
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '13010')) ?: '13010';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
return 'http://' . $host . ':' . $port . '/Game/api/admin/room-approvals' . $suffix;
}
function proxy_node_room_approvals(string $method, ?string $body = null, string $suffix = ''): array
{
$ch = curl_init(node_room_approvals_url($suffix));
if ($ch === false) {
return ['ok' => false, 'error' => 'curl init failed', 'httpCode' => 0];
}
$headers = ['Accept: application/json'];
if ($body !== null && $method !== 'GET') {
$headers[] = 'Content-Type: application/json';
}
$opts = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 12,
];
if ($body !== null && $method !== 'GET') {
$opts[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($ch, $opts);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
return ['ok' => false, 'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node): ' . $err, 'httpCode' => $code];
}
$j = json_decode($out, true);
if (!is_array($j)) {
return ['ok' => false, 'error' => 'ตอบกลับจาก Node ไม่ถูกต้อง', 'httpCode' => $code];
}
$j['httpCode'] = $code;
return $j;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$admin = current_admin();
if ($method === 'GET') {
$action = isset($_GET['action']) ? (string)$_GET['action'] : 'pending';
$store = read_store();
$enabled = !empty($store['roomModeration']['enabled']);
sync_room_moderation_disk($enabled);
if ($action === 'settings') {
json_response([
'ok' => true,
'enabled' => $enabled,
]);
}
if ($action === 'history') {
json_response([
'ok' => true,
'history' => read_room_approval_history(),
'enabled' => $enabled,
]);
}
$node = proxy_node_room_approvals('GET');
if (empty($node['ok'])) {
json_response([
'ok' => true,
'enabled' => $enabled,
'pending' => [],
'nodeError' => $node['error'] ?? 'Node unavailable',
]);
}
json_response([
'ok' => true,
'enabled' => $enabled,
'nodeModerationEnabled' => !empty($node['moderationEnabled']),
'pending' => $node['pending'] ?? [],
]);
}
if ($method === 'PATCH') {
$raw = file_get_contents('php://input') ?: '{}';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_response(['ok' => false, 'error' => 'ข้อมูลไม่ถูกต้อง'], 400);
}
$store = read_store();
if (!isset($store['roomModeration']) || !is_array($store['roomModeration'])) {
$store['roomModeration'] = ['enabled' => true];
}
if (array_key_exists('enabled', $body)) {
$store['roomModeration']['enabled'] = !!$body['enabled'];
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกการตั้งค่าไม่สำเร็จ'], 500);
}
sync_room_moderation_disk(!empty($store['roomModeration']['enabled']));
json_response(['ok' => true, 'enabled' => !empty($store['roomModeration']['enabled'])]);
}
if ($method === 'POST') {
$raw = file_get_contents('php://input') ?: '{}';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_response(['ok' => false, 'error' => 'ข้อมูลไม่ถูกต้อง'], 400);
}
$spaceId = isset($body['spaceId']) ? trim((string)$body['spaceId']) : '';
$act = isset($body['action']) ? trim((string)$body['action']) : '';
$note = isset($body['note']) ? trim((string)$body['note']) : '';
if ($spaceId === '' || !in_array($act, ['approve', 'reject', 'dismiss'], true)) {
json_response(['ok' => false, 'error' => 'ต้องระบุ spaceId และ action (reject|dismiss)'], 400);
}
$meta = isset($body['meta']) && is_array($body['meta']) ? $body['meta'] : [];
$payload = json_encode([
'spaceId' => $spaceId,
'action' => $act,
'note' => $note,
], JSON_UNESCAPED_UNICODE);
$result = proxy_node_room_approvals('POST', $payload, '');
if (empty($result['ok'])) {
json_response(['ok' => false, 'error' => $result['error'] ?? 'ดำเนินการไม่สำเร็จ'], !empty($result['httpCode']) && $result['httpCode'] >= 400 ? (int)$result['httpCode'] : 400);
}
append_room_approval_history([
'at' => gmdate('c'),
'action' => $act,
'spaceId' => $spaceId,
'spaceName' => $meta['spaceName'] ?? $spaceId,
'creatorDisplayName' => $meta['creatorDisplayName'] ?? '',
'creatorPlayerKey' => $meta['creatorPlayerKey'] ?? '',
'adminUsername' => is_array($admin) ? ($admin['username'] ?? '') : '',
'note' => $note,
'kicked' => isset($result['kicked']) ? (int)$result['kicked'] : null,
]);
json_response([
'ok' => true,
'action' => $act,
'spaceId' => $spaceId,
'kicked' => $result['kicked'] ?? null,
]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* ระดับเสียง Master / Music / SFX — อ่าน/เขียน Game/data/sound-settings.json
* ใช้ session Admin หลัก (ไม่ต้องล็อกอิน game_ai_admin แยก)
*
* GET -> { masterVol, musicVol, sfxVol } ค่า 0..1
* PUT -> body JSON เดียวกัน → บันทึก
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('sound');
define('SOUND_SETTINGS_FILE', dirname(__DIR__, 2) . '/Game/data/sound-settings.json');
function sound_defaults(): array
{
return ['masterVol' => 1.0, 'musicVol' => 0.35, 'sfxVol' => 0.75];
}
function sound_clamp($v, float $default): float
{
$n = is_numeric($v) ? (float) $v : $default;
if (!is_finite($n)) {
return $default;
}
return max(0.0, min(1.0, $n));
}
function sound_read(): array
{
$base = sound_defaults();
if (!is_file(SOUND_SETTINGS_FILE)) {
return $base;
}
$raw = @file_get_contents(SOUND_SETTINGS_FILE);
$j = json_decode($raw ?: '{}', true);
if (!is_array($j)) {
return $base;
}
return [
'masterVol' => sound_clamp($j['masterVol'] ?? null, $base['masterVol']),
'musicVol' => sound_clamp($j['musicVol'] ?? null, $base['musicVol']),
'sfxVol' => sound_clamp($j['sfxVol'] ?? null, $base['sfxVol']),
];
}
function sound_write(array $s): bool
{
$dir = dirname(SOUND_SETTINGS_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return false;
}
$json = json_encode($s, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) {
return false;
}
$tmp = SOUND_SETTINGS_FILE . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
if (!rename($tmp, SOUND_SETTINGS_FILE)) {
@unlink($tmp);
return false;
}
return true;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
json_response(sound_read());
}
if ($method === 'PUT' || $method === 'POST') {
$body = require_json_body();
$cur = sound_read();
$next = [
'masterVol' => array_key_exists('masterVol', $body) ? sound_clamp($body['masterVol'], $cur['masterVol']) : $cur['masterVol'],
'musicVol' => array_key_exists('musicVol', $body) ? sound_clamp($body['musicVol'], $cur['musicVol']) : $cur['musicVol'],
'sfxVol' => array_key_exists('sfxVol', $body) ? sound_clamp($body['sfxVol'], $cur['sfxVol']) : $cur['sfxVol'],
];
if (!sound_write($next)) {
json_response(['ok' => false, 'error' => 'บันทึก sound-settings.json ไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'settings' => $next]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
const ROOM_RETENTION_MIN_DAYS = 3;
const ROOM_RETENTION_MAX_DAYS = 7;
const GAME_SYSTEM_SETTINGS = __DIR__ . '/../../Game/data/system-settings.json';
function clamp_room_retention_days($value): int
{
$n = (int)$value;
if ($n < ROOM_RETENTION_MIN_DAYS) {
return ROOM_RETENTION_MIN_DAYS;
}
if ($n > ROOM_RETENTION_MAX_DAYS) {
return ROOM_RETENTION_MAX_DAYS;
}
return $n;
}
function system_settings_payload(array $store): array
{
$settings = $store['systemSettings'] ?? [];
return [
'roomRetentionDays' => clamp_room_retention_days($settings['roomRetentionDays'] ?? ROOM_RETENTION_MIN_DAYS),
'minRoomRetentionDays' => ROOM_RETENTION_MIN_DAYS,
'maxRoomRetentionDays' => ROOM_RETENTION_MAX_DAYS,
];
}
function sync_game_system_settings(array $payload): bool
{
$dir = dirname(GAME_SYSTEM_SETTINGS);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return false;
}
$data = [
'roomRetentionDays' => $payload['roomRetentionDays'],
'updatedAt' => gmdate('c'),
];
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
return $json !== false && file_put_contents(GAME_SYSTEM_SETTINGS, $json, LOCK_EX) !== false;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$payload = system_settings_payload($store);
sync_game_system_settings($payload);
json_response(['ok' => true] + $payload);
}
if ($method === 'PATCH' || $method === 'PUT' || $method === 'POST') {
$body = require_json_body();
if (!array_key_exists('roomRetentionDays', $body)) {
json_response(['ok' => false, 'error' => 'กรุณาระบุจำนวนวันจัดเก็บห้อง'], 400);
}
$store = read_store();
$store['systemSettings'] = $store['systemSettings'] ?? [];
$store['systemSettings']['roomRetentionDays'] = clamp_room_retention_days($body['roomRetentionDays']);
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกการตั้งค่าไม่สำเร็จ'], 500);
}
$payload = system_settings_payload($store);
if (!sync_game_system_settings($payload)) {
json_response(['ok' => false, 'error' => 'บันทึกค่าไปยัง Game/data ไม่สำเร็จ'], 500);
}
json_response(['ok' => true] + $payload);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);