update all
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user