update all

This commit is contained in:
2026-07-31 03:36:58 +00:00
parent 6dcac08598
commit de7d91b74b
72 changed files with 65947 additions and 180683 deletions
+6075 -5827
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -111,9 +111,95 @@ function is_super(array $admin): bool
return ($admin['role'] ?? '') === 'super';
}
/**
* รายชื่อหมวด (tab) ทั้งหมดในหน้า admin — ต้องตรงกับ data-tab ใน Admin/index.html
* ใช้เป็น whitelist เวลาบันทึกสิทธิ์ กันค่ามั่วหลุดเข้า store
*/
function admin_all_tabs(): array
{
return [
'accounts', 'admins', 'oauth', 'change-password',
'achievements', 'ai-admin', 'case-media', 'characters', 'evidence-cards',
'game-timing', 'highscore', 'map-editor', 'postcase', 'qb-map-editor',
'quiz', 'quiz-battle', 'quiz-carry', 'sound', 'special-quiz',
'jump-survive', 'mega-virus', 'space-shooter', 'stack-game',
'test-mode', 'troublesome', 'vote-timing',
];
}
/**
* หมวดที่แอดมินคนนี้เข้าได้จริง
* - super = ทุกหมวดเสมอ
* - ไม่มีคีย์ tabs หรือ tabs ว่าง = ทุกหมวด (ความเข้ากันได้กับบัญชีเดิมที่สร้างก่อนมีระบบสิทธิ์)
*/
function admin_tabs(array $a): array
{
if (is_super($a)) {
return admin_all_tabs();
}
$t = $a['tabs'] ?? null;
if (!is_array($t) || count($t) === 0) {
return admin_all_tabs();
}
return array_values(array_intersect(admin_all_tabs(), $t));
}
function admin_can(array $a, string $tab): bool
{
return in_array($tab, admin_tabs($a), true);
}
/** กันที่ API: แอดมินที่ไม่มีสิทธิ์หมวดนี้ ยิงตรงมาก็ต้องโดนปฏิเสธ (ซ่อน tab ฝั่งหน้าเว็บอย่างเดียวไม่ใช่ security) */
function require_tab(string $tab): array
{
$a = current_admin();
if (!$a) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
if (!admin_can($a, $tab)) {
json_response(['ok' => false, 'error' => 'บัญชีนี้ไม่มีสิทธิ์เข้าหมวด "' . $tab . '"'], 403);
}
return $a;
}
/**
* บาง endpoint เป็น "config ก้อนเดียวที่หลายหมวดใช้ร่วมกัน" (เช่น quiz-settings.json)
* ถ้า gate ไว้หมวดเดียวจะพังหมวดอื่น → ผ่านถ้ามีสิทธิ์ "อย่างน้อย 1 หมวด" ในลิสต์
*/
function require_any_tab(array $tabs): array
{
$a = current_admin();
if (!$a) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
foreach ($tabs as $t) {
if (admin_can($a, $t)) {
return $a;
}
}
json_response(['ok' => false, 'error' => 'บัญชีนี้ไม่มีสิทธิ์เข้าหมวดที่เกี่ยวข้อง'], 403);
}
/** normalize ค่า tabs ที่รับมาจาก client ให้เหลือเฉพาะที่อยู่ใน whitelist */
function sanitize_tabs($raw): array
{
if (!is_array($raw)) {
return [];
}
$all = admin_all_tabs();
$out = [];
foreach ($raw as $t) {
if (is_string($t) && in_array($t, $all, true) && !in_array($t, $out, true)) {
$out[] = $t;
}
}
return $out;
}
function strip_admin(array $a): array
{
unset($a['passwordHash']);
$a['tabsEffective'] = admin_tabs($a);
return $a;
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* Shared helpers — lookup player account by jdPlayerKey (providerUserId).
* รองรับทั้ง guest และ google (และ loginType อื่นในอนาคต)
*/
function valid_player_key(string $key): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
function find_account_index_by_player_key(array $accounts, string $key): int
{
foreach ($accounts as $i => $a) {
if (($a['providerUserId'] ?? '') === $key) {
return (int) $i;
}
}
return -1;
}
function find_account_by_player_key(array $accounts, string $key): ?array
{
$idx = find_account_index_by_player_key($accounts, $key);
return $idx >= 0 ? $accounts[$idx] : null;
}
function default_guest_account(string $key, string $notes = 'auto', array $extra = []): array
{
$now = gmdate('c');
return array_merge([
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => $notes,
'blocked' => false,
'coins' => 0,
'createdAt' => $now,
'updatedAt' => $now,
], $extra);
}
function google_player_key(string $googleSub): string
{
$sub = trim($googleSub);
if ($sub === '') {
return '';
}
if (str_starts_with($sub, 'g_')) {
return $sub;
}
return 'g_' . $sub;
}
function merge_guest_account_into(array &$store, int $targetIdx, string $guestKey): void
{
if ($guestKey === '' || !valid_player_key($guestKey)) {
return;
}
$guestIdx = find_account_index_by_player_key($store['accounts'] ?? [], $guestKey);
if ($guestIdx < 0 || $guestIdx === $targetIdx) {
return;
}
$guest = $store['accounts'][$guestIdx];
if (($guest['loginType'] ?? '') !== 'guest') {
return;
}
$target = &$store['accounts'][$targetIdx];
$target['coins'] = max(0, (int) ($target['coins'] ?? 0)) + max(0, (int) ($guest['coins'] ?? 0));
$target['score'] = max(0, (int) ($target['score'] ?? 0)) + max(0, (int) ($guest['score'] ?? 0));
if (isset($guest['scoreByCase']) && is_array($guest['scoreByCase'])) {
if (!isset($target['scoreByCase']) || !is_array($target['scoreByCase'])) {
$target['scoreByCase'] = [];
}
foreach ($guest['scoreByCase'] as $cid => $sc) {
$target['scoreByCase'][$cid] = max(0, (int) ($target['scoreByCase'][$cid] ?? 0)) + max(0, (int) $sc);
}
}
if (isset($guest['achievements']) && is_array($guest['achievements'])) {
if (!isset($target['achievements']) || !is_array($target['achievements'])) {
$target['achievements'] = [];
}
foreach ($guest['achievements'] as $aid => $cnt) {
$target['achievements'][$aid] = max((int) ($target['achievements'][$aid] ?? 0), (int) $cnt);
}
}
if (isset($guest['daily']) && is_array($guest['daily']) && empty($target['daily'])) {
$target['daily'] = $guest['daily'];
}
if (!isset($target['lobbyColorThemeIndex']) && isset($guest['lobbyColorThemeIndex'])) {
$target['lobbyColorThemeIndex'] = $guest['lobbyColorThemeIndex'];
}
if (!isset($target['lobbySkinToneIndex']) && isset($guest['lobbySkinToneIndex'])) {
$target['lobbySkinToneIndex'] = $guest['lobbySkinToneIndex'];
}
if (($target['displayName'] ?? '') === '' || ($target['displayName'] ?? '') === 'Guest') {
$gn = trim((string) ($guest['displayName'] ?? ''));
if ($gn !== '' && $gn !== 'Guest') {
$target['displayName'] = $gn;
}
}
array_splice($store['accounts'], $guestIdx, 1);
if ($guestIdx < $targetIdx) {
$targetIdx--;
}
}
+317
View File
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/_player_account.php';
define('PLAYER_LOG_FILE', ADMIN_PRIVATE_DIR . '/player-logs.json');
define('PLAYER_LOG_MAX_EVENTS', 2000);
define('PLAYER_LOG_SESSION_GAP_SEC', 1800); // 30 นาที = session ใหม่
function player_log_default(): array
{
return [
'version' => 1,
'stats' => [
'totalEvents' => 0,
'totalVisits' => 0,
'uniquePlayers' => 0,
'visitsToday' => 0,
'todayKey' => '',
],
'players' => [],
'events' => [],
];
}
function player_log_read(): array
{
if (!is_file(PLAYER_LOG_FILE)) {
return player_log_default();
}
$raw = @file_get_contents(PLAYER_LOG_FILE);
$j = json_decode($raw ?: '{}', true);
if (!is_array($j)) {
return player_log_default();
}
return array_replace_recursive(player_log_default(), $j);
}
function player_log_write(array $data): bool
{
if (!is_dir(ADMIN_PRIVATE_DIR)) {
if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) {
return false;
}
}
$tmp = PLAYER_LOG_FILE . '.tmp.' . bin2hex(random_bytes(4));
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) {
return false;
}
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
if (!rename($tmp, PLAYER_LOG_FILE)) {
@unlink($tmp);
return false;
}
return true;
}
function player_log_today_key(): string
{
$tz = new DateTimeZone('Asia/Bangkok');
return (new DateTimeImmutable('now', $tz))->format('Y-m-d');
}
function player_log_reset_today_if_needed(array &$log): void
{
$today = player_log_today_key();
if (($log['stats']['todayKey'] ?? '') !== $today) {
$log['stats']['todayKey'] = $today;
$log['stats']['visitsToday'] = 0;
}
}
function player_log_allowed_events(): array
{
return [
'login_guest' => true,
'login_google' => true,
'login_facebook' => true,
'lobby_enter' => true,
'room_enter' => true,
'game_enter' => true,
'quiz_battle_enter' => true,
'logout' => true,
];
}
function player_log_sanitize_name(string $raw): string
{
$s = trim($raw);
$s = preg_replace('/[\x00-\x1f\x7f]/u', '', $s) ?? '';
if (function_exists('mb_substr')) {
return mb_substr($s, 0, 32);
}
return substr($s, 0, 32);
}
function player_log_sanitize_page(string $raw): string
{
$s = trim($raw);
if (function_exists('mb_substr')) {
return mb_substr($s, 0, 180);
}
return substr($s, 0, 180);
}
/** event ที่นับเป็น "การเข้าใช้งาน" */
function player_log_counts_as_visit(string $event): bool
{
return isset([
'login_guest' => true,
'login_google' => true,
'login_facebook' => true,
'lobby_enter' => true,
'room_enter' => true,
'game_enter' => true,
][$event]);
}
function player_log_track(
string $playerKey,
string $event,
string $displayName = '',
string $loginType = 'guest',
string $page = '',
array $meta = []
): array {
$allowed = player_log_allowed_events();
if (!isset($allowed[$event])) {
return ['ok' => false, 'error' => 'unknown event'];
}
if (!valid_player_key($playerKey)) {
return ['ok' => false, 'error' => 'invalid playerKey'];
}
$lt = in_array($loginType, ['guest', 'google', 'facebook', 'email'], true) ? $loginType : 'guest';
$name = player_log_sanitize_name($displayName);
$page = player_log_sanitize_page($page);
$now = gmdate('c');
$nowTs = time();
$log = player_log_read();
player_log_reset_today_if_needed($log);
if (!isset($log['players']) || !is_array($log['players'])) {
$log['players'] = [];
}
if (!isset($log['events']) || !is_array($log['events'])) {
$log['events'] = [];
}
$isNewPlayer = !isset($log['players'][$playerKey]);
if ($isNewPlayer) {
$log['players'][$playerKey] = [
'playerKey' => $playerKey,
'displayName' => $name !== '' ? $name : 'ผู้เล่น',
'loginType' => $lt,
'visitCount' => 0,
'eventCount' => 0,
'firstSeenAt' => $now,
'lastSeenAt' => $now,
'lastVisitAt' => null,
'lastEvent' => $event,
'lastPage' => $page,
];
$log['stats']['uniquePlayers'] = count($log['players']);
}
$p = &$log['players'][$playerKey];
if ($name !== '') {
$p['displayName'] = $name;
}
$p['loginType'] = $lt;
$p['lastSeenAt'] = $now;
$p['lastEvent'] = $event;
if ($page !== '') {
$p['lastPage'] = $page;
}
$p['eventCount'] = max(0, (int) ($p['eventCount'] ?? 0)) + 1;
$countVisit = false;
if (player_log_counts_as_visit($event)) {
$lastVisitAt = isset($p['lastVisitAt']) ? strtotime((string) $p['lastVisitAt']) : false;
if ($lastVisitAt === false || ($nowTs - $lastVisitAt) >= PLAYER_LOG_SESSION_GAP_SEC) {
$countVisit = true;
$p['visitCount'] = max(0, (int) ($p['visitCount'] ?? 0)) + 1;
$p['lastVisitAt'] = $now;
$log['stats']['totalVisits'] = max(0, (int) ($log['stats']['totalVisits'] ?? 0)) + 1;
$log['stats']['visitsToday'] = max(0, (int) ($log['stats']['visitsToday'] ?? 0)) + 1;
}
}
$log['stats']['totalEvents'] = max(0, (int) ($log['stats']['totalEvents'] ?? 0)) + 1;
$ev = [
'id' => bin2hex(random_bytes(8)),
'at' => $now,
'playerKey' => $playerKey,
'displayName' => (string) ($p['displayName'] ?? ''),
'loginType' => $lt,
'event' => $event,
'page' => $page,
'meta' => is_array($meta) ? $meta : [],
'countedVisit' => $countVisit,
];
array_unshift($log['events'], $ev);
if (count($log['events']) > PLAYER_LOG_MAX_EVENTS) {
$log['events'] = array_slice($log['events'], 0, PLAYER_LOG_MAX_EVENTS);
}
if (!player_log_write($log)) {
return ['ok' => false, 'error' => 'บันทึก log ไม่สำเร็จ'];
}
return [
'ok' => true,
'countedVisit' => $countVisit,
'visitCount' => (int) ($p['visitCount'] ?? 0),
];
}
function player_log_public_summary(array $log): array
{
player_log_reset_today_if_needed($log);
return [
'totalEvents' => max(0, (int) ($log['stats']['totalEvents'] ?? 0)),
'totalVisits' => max(0, (int) ($log['stats']['totalVisits'] ?? 0)),
'uniquePlayers' => max(0, (int) ($log['stats']['uniquePlayers'] ?? count($log['players'] ?? []))),
'visitsToday' => max(0, (int) ($log['stats']['visitsToday'] ?? 0)),
'todayKey' => (string) ($log['stats']['todayKey'] ?? player_log_today_key()),
'eventCap' => PLAYER_LOG_MAX_EVENTS,
'storedEvents' => count($log['events'] ?? []),
];
}
function player_log_players_list(array $log, string $search = '', int $limit = 200): array
{
$rows = [];
foreach ($log['players'] ?? [] as $p) {
if (!is_array($p)) {
continue;
}
$key = (string) ($p['playerKey'] ?? '');
if ($key === '') {
continue;
}
if ($search !== '') {
$hay = strtolower($key . ' ' . ($p['displayName'] ?? '') . ' ' . ($p['loginType'] ?? ''));
if (strpos($hay, strtolower($search)) === false) {
continue;
}
}
$rows[] = [
'playerKey' => $key,
'displayName' => (string) ($p['displayName'] ?? 'ผู้เล่น'),
'loginType' => (string) ($p['loginType'] ?? 'guest'),
'visitCount' => max(0, (int) ($p['visitCount'] ?? 0)),
'eventCount' => max(0, (int) ($p['eventCount'] ?? 0)),
'firstSeenAt' => (string) ($p['firstSeenAt'] ?? ''),
'lastSeenAt' => (string) ($p['lastSeenAt'] ?? ''),
'lastVisitAt' => (string) ($p['lastVisitAt'] ?? ''),
'lastEvent' => (string) ($p['lastEvent'] ?? ''),
'lastPage' => (string) ($p['lastPage'] ?? ''),
];
}
usort($rows, static function ($a, $b) {
return strcmp((string) ($b['lastSeenAt'] ?? ''), (string) ($a['lastSeenAt'] ?? ''));
});
if ($limit > 0 && count($rows) > $limit) {
$rows = array_slice($rows, 0, $limit);
}
return $rows;
}
function player_log_events_list(array $log, array $opts = []): array
{
$search = trim((string) ($opts['search'] ?? ''));
$event = trim((string) ($opts['event'] ?? ''));
$playerKey = trim((string) ($opts['playerKey'] ?? ''));
$limit = max(1, min(500, (int) ($opts['limit'] ?? 100)));
$offset = max(0, (int) ($opts['offset'] ?? 0));
$rows = [];
foreach ($log['events'] ?? [] as $ev) {
if (!is_array($ev)) {
continue;
}
if ($event !== '' && ($ev['event'] ?? '') !== $event) {
continue;
}
if ($playerKey !== '' && ($ev['playerKey'] ?? '') !== $playerKey) {
continue;
}
if ($search !== '') {
$hay = strtolower(
($ev['playerKey'] ?? '') . ' ' .
($ev['displayName'] ?? '') . ' ' .
($ev['event'] ?? '') . ' ' .
($ev['page'] ?? '')
);
if (strpos($hay, strtolower($search)) === false) {
continue;
}
}
$rows[] = $ev;
}
$total = count($rows);
if ($offset > 0) {
$rows = array_slice($rows, $offset);
}
if (count($rows) > $limit) {
$rows = array_slice($rows, 0, $limit);
}
return ['rows' => $rows, 'total' => $total];
}
+120 -119
View File
@@ -1,119 +1,120 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
function normalize_account_row(array $a): array
{
$a['coins'] = max(0, (int)($a['coins'] ?? 0));
$a['score'] = max(0, (int)($a['score'] ?? 0));
return $a;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('normalize_account_row', read_store()['accounts'] ?? []);
json_response(['ok' => true, 'accounts' => $list]);
}
if ($method === 'POST') {
$body = require_json_body();
$store = read_store();
$coins = isset($body['coins']) ? (int)$body['coins'] : 0;
$coins = max(0, $coins);
$acc = [
'id' => new_id(),
'email' => trim((string)($body['email'] ?? '')),
'displayName' => trim((string)($body['displayName'] ?? '')),
'loginType' => in_array($body['loginType'] ?? '', ['guest', 'facebook', 'google', 'email'], true)
? $body['loginType'] : 'guest',
'providerUserId' => trim((string)($body['providerUserId'] ?? '')),
'notes' => trim((string)($body['notes'] ?? '')),
'blocked' => !empty($body['blocked']),
'coins' => $coins,
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
$store['accounts'][] = $acc;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'account' => $acc]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$id = trim((string)($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach ($store['accounts'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $id) {
continue;
}
$found = true;
if (array_key_exists('email', $body)) {
$store['accounts'][$i]['email'] = trim((string)$body['email']);
}
if (array_key_exists('displayName', $body)) {
$store['accounts'][$i]['displayName'] = trim((string)$body['displayName']);
}
if (array_key_exists('loginType', $body)) {
$lt = $body['loginType'];
if (in_array($lt, ['guest', 'facebook', 'google', 'email'], true)) {
$store['accounts'][$i]['loginType'] = $lt;
}
}
if (array_key_exists('providerUserId', $body)) {
$store['accounts'][$i]['providerUserId'] = trim((string)$body['providerUserId']);
}
if (array_key_exists('notes', $body)) {
$store['accounts'][$i]['notes'] = trim((string)$body['notes']);
}
if (array_key_exists('blocked', $body)) {
$store['accounts'][$i]['blocked'] = !empty($body['blocked']);
}
if (array_key_exists('coins', $body)) {
$store['accounts'][$i]['coins'] = max(0, (int)$body['coins']);
}
if (array_key_exists('score', $body)) {
$store['accounts'][$i]['score'] = max(0, (int)$body['score']);
}
if (array_key_exists('coinsDelta', $body)) {
$cur = max(0, (int)($store['accounts'][$i]['coins'] ?? 0));
$store['accounts'][$i]['coins'] = max(0, $cur + (int)$body['coinsDelta']);
}
$store['accounts'][$i]['updatedAt'] = gmdate('c');
break;
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$store['accounts'] = array_values(array_filter(
$store['accounts'] ?? [],
static fn($a) => ($a['id'] ?? '') !== $id
));
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_tab('accounts');
function normalize_account_row(array $a): array
{
$a['coins'] = max(0, (int)($a['coins'] ?? 0));
$a['score'] = max(0, (int)($a['score'] ?? 0));
return $a;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('normalize_account_row', read_store()['accounts'] ?? []);
json_response(['ok' => true, 'accounts' => $list]);
}
if ($method === 'POST') {
$body = require_json_body();
$store = read_store();
$coins = isset($body['coins']) ? (int)$body['coins'] : 0;
$coins = max(0, $coins);
$acc = [
'id' => new_id(),
'email' => trim((string)($body['email'] ?? '')),
'displayName' => trim((string)($body['displayName'] ?? '')),
'loginType' => in_array($body['loginType'] ?? '', ['guest', 'facebook', 'google', 'email'], true)
? $body['loginType'] : 'guest',
'providerUserId' => trim((string)($body['providerUserId'] ?? '')),
'notes' => trim((string)($body['notes'] ?? '')),
'blocked' => !empty($body['blocked']),
'coins' => $coins,
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
$store['accounts'][] = $acc;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'account' => $acc]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$id = trim((string)($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach ($store['accounts'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $id) {
continue;
}
$found = true;
if (array_key_exists('email', $body)) {
$store['accounts'][$i]['email'] = trim((string)$body['email']);
}
if (array_key_exists('displayName', $body)) {
$store['accounts'][$i]['displayName'] = trim((string)$body['displayName']);
}
if (array_key_exists('loginType', $body)) {
$lt = $body['loginType'];
if (in_array($lt, ['guest', 'facebook', 'google', 'email'], true)) {
$store['accounts'][$i]['loginType'] = $lt;
}
}
if (array_key_exists('providerUserId', $body)) {
$store['accounts'][$i]['providerUserId'] = trim((string)$body['providerUserId']);
}
if (array_key_exists('notes', $body)) {
$store['accounts'][$i]['notes'] = trim((string)$body['notes']);
}
if (array_key_exists('blocked', $body)) {
$store['accounts'][$i]['blocked'] = !empty($body['blocked']);
}
if (array_key_exists('coins', $body)) {
$store['accounts'][$i]['coins'] = max(0, (int)$body['coins']);
}
if (array_key_exists('score', $body)) {
$store['accounts'][$i]['score'] = max(0, (int)$body['score']);
}
if (array_key_exists('coinsDelta', $body)) {
$cur = max(0, (int)($store['accounts'][$i]['coins'] ?? 0));
$store['accounts'][$i]['coins'] = max(0, $cur + (int)$body['coinsDelta']);
}
$store['accounts'][$i]['updatedAt'] = gmdate('c');
break;
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$store['accounts'] = array_values(array_filter(
$store['accounts'] ?? [],
static fn($a) => ($a['id'] ?? '') !== $id
));
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+397 -382
View File
@@ -1,382 +1,397 @@
<?php
declare(strict_types=1);
/**
* Achievements — แคตตาล็อก + progress รายผู้เล่น
*
* แคตตาล็อก override : Admin/private/achievements.json (ถ้าไม่มี ใช้ default ในไฟล์นี้)
* progress รายผู้เล่น : เก็บใน store.json -> accounts[].achievements { id: count }
*
* Public (ไม่ต้องล็อกอิน):
* GET ?action=state&playerKey=KEY -> { ok, catalog:[...], progress:{id:count} }
* GET ?action=catalog -> { ok, catalog:[...] }
*
* Server-only (มี secret game-award-secret.txt) — สำหรับ auto-track ภายหลัง:
* POST {action:'progress', secret, playerKey, id, inc?, set?}
*
* Admin (ต้องล็อกอิน session):
* POST {action:'saveCatalog', catalog:[...]}
* GET ?action=players -> รายชื่อผู้เล่น + จำนวนที่ปลดล็อก
* GET ?action=player&playerKey=KEY -> progress ของผู้เล่นคนเดียว
* POST {action:'setProgress', playerKey, id, value}
* POST {action:'unlock', playerKey, id} -> set = target
* POST {action:'resetPlayer', playerKey} -> ล้าง progress ทั้งหมด
*/
require __DIR__ . '/_common.php';
date_default_timezone_set('Asia/Bangkok');
define('ACHV_CATALOG_FILE', ADMIN_PRIVATE_DIR . '/achievements.json');
function achv_default_catalog(): array
{
return [
['id' => 'a1_first_deduction', 'g' => 1, 'title' => 'First Deduction', 'desc' => 'โหวตถูกตัวคนร้ายเป็นครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'a2_sharp_eye', 'g' => 1, 'title' => 'Sharp Eye', 'desc' => 'สะสมหลักฐานระดับมีน้ำหนัก (Silver) ครบ 10 ใบ', 'target' => 10, 'enabled' => true],
['id' => 'a3_mind_architect', 'g' => 1, 'title' => 'Mind Architect', 'desc' => 'สะสมหลักฐานครบทุกระดับ (ทั่วไป, มีน้ำหนัก, ชี้ชัด) ใน 1 คดี', 'target' => 1, 'enabled' => true],
['id' => 'a4_logic_over_luck', 'g' => 1, 'title' => 'Logic Over Luck', 'desc' => 'โหวตถูกโดยไม่พึ่งหลักฐานชี้ชัด (Legendary) เลย 3 ครั้ง', 'target' => 3, 'enabled' => true],
['id' => 'a5_truth_hunter', 'g' => 1, 'title' => 'Truth Hunter', 'desc' => 'จับคนร้ายถูกตัวสะสมครบ 20 คดี', 'target' => 20, 'enabled' => true],
['id' => 'a6_unbreakable_logic', 'g' => 1, 'title' => 'Unbreakable Logic', 'desc' => 'โหวตถูกตัวติดกัน 5 คดีรวด', 'target' => 5, 'enabled' => true],
['id' => 'b1_evidence_collector', 'g' => 2, 'title' => 'Evidence Collector', 'desc' => 'สะสมหลักฐานระดับทั่วไป (Common) ครบ 20 ใบ', 'target' => 20, 'enabled' => true],
['id' => 'b2_relentless_investigator', 'g' => 2, 'title' => 'Relentless Investigator', 'desc' => 'เล่น Mini Game ครบทุกรอบ ใน 1 คดี', 'target' => 1, 'enabled' => true],
['id' => 'b3_hidden_fragment', 'g' => 2, 'title' => 'Hidden Fragment', 'desc' => 'ค้นพบหลักฐานระดับชี้ชัด (Legendary/Gold) เป็นครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'b4_data_miner', 'g' => 2, 'title' => 'Data Miner', 'desc' => 'สะสมการ์ดหลักฐานรวมครบ 100 ใบ', 'target' => 100, 'enabled' => true],
['id' => 'b5_deep_scanner', 'g' => 2, 'title' => 'Deep Scanner', 'desc' => 'เก็บไอเทมช่วยเหลือ (ตำรวจ/ทนาย) สะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'c1_early_accusation', 'g' => 3, 'title' => 'Early Accusation', 'desc' => 'ชี้ตัวคนร้ายก่อนที่จะเปิดหลักฐานครบ', 'target' => 1, 'enabled' => true],
['id' => 'c2_high_stakes', 'g' => 3, 'title' => 'High Stakes', 'desc' => 'ชี้ตัวคนร้ายถูกโดยมีหลักฐานในมือไม่เกิน 3 ใบ', 'target' => 1, 'enabled' => true],
['id' => 'c3_quick_draw', 'g' => 3, 'title' => 'Quick Draw', 'desc' => 'ชี้ตัวคนร้ายเร็วที่สุดในทีมสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'c4_clutch_mind', 'g' => 3, 'title' => 'Clutch Mind', 'desc' => 'โหวตถูกในช่วง 10 วินาทีสุดท้ายก่อนหมดเวลา', 'target' => 1, 'enabled' => true],
['id' => 'c5_lone_wolf', 'g' => 3, 'title' => 'Lone Wolf', 'desc' => 'โหวตสวนทางกับเสียงส่วนใหญ่ของทีม (คุณถูกคนเดียว)', 'target' => 1, 'enabled' => true],
['id' => 'd1_minigame_solver', 'g' => 4, 'title' => 'Minigame Solver', 'desc' => 'เอาชีวิตรอด / เล่นมินิเกมสำเร็จ 20 ครั้ง', 'target' => 20, 'enabled' => true],
['id' => 'd2_silent_guardian', 'g' => 4, 'title' => 'Silent Guardian', 'desc' => 'ไม่โดนโหวตใช้การ์ด Event พิเศษ เลยตลอด 5 คดี', 'target' => 5, 'enabled' => true],
['id' => 'd3_the_backbone', 'g' => 4, 'title' => 'The Backbone', 'desc' => 'ส่งมอบหลักฐานให้เพื่อนวิเคราะห์ครบ 30 ใบ', 'target' => 30, 'enabled' => true],
['id' => 'd4_flawless_diver', 'g' => 4, 'title' => 'Flawless Diver', 'desc' => 'ไม่โหวตจับผิดตัวเลยตลอด 15 คดี', 'target' => 15, 'enabled' => true],
['id' => 'e1_the_observer', 'g' => 5, 'title' => 'The Observer', 'desc' => 'เล่นจบ 15 คดีโดยไม่เคยสัมผัสหลักฐานชี้ชัด (Legendary) เลยสักครั้ง', 'target' => 15, 'enabled' => true],
['id' => 'e2_the_impostor', 'g' => 5, 'title' => 'The Impostor', 'desc' => 'รับบทเป็น "ตัวป่วน" ครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'e3_master_of_doubt', 'g' => 5, 'title' => 'Master of Doubt', 'desc' => 'เอาชนะคดีในฐานะตัวป่วนได้สำเร็จ', 'target' => 1, 'enabled' => true],
['id' => 'e4_agent_of_chaos', 'g' => 5, 'title' => 'Agent of Chaos', 'desc' => 'รับบทเป็นตัวป่วนสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'e5_slippery_eel', 'g' => 5, 'title' => 'Slippery Eel', 'desc' => 'เป็นตัวป่วนแต่รอดพ้นจากการถูกจับได้ (ไม่ถูกโหวตออก) จนจบเกม 5 ครั้ง', 'target' => 5, 'enabled' => true],
];
}
function achv_sanitize_catalog(array $arr): array
{
$out = [];
$seen = [];
foreach ($arr as $row) {
if (!is_array($row)) continue;
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($row['id'] ?? ''));
if ($id === '' || isset($seen[$id])) continue;
$seen[$id] = true;
$g = (int) ($row['g'] ?? 1);
if ($g < 1 || $g > 5) $g = 1;
$title = trim((string) ($row['title'] ?? ''));
$desc = trim((string) ($row['desc'] ?? ''));
if (function_exists('mb_substr')) {
$title = mb_substr($title, 0, 60);
$desc = mb_substr($desc, 0, 200);
}
$target = (int) ($row['target'] ?? 1);
if ($target < 1) $target = 1;
if ($target > 100000) $target = 100000;
$out[] = [
'id' => $id,
'g' => $g,
'title' => $title !== '' ? $title : $id,
'desc' => $desc,
'target' => $target,
'enabled' => !isset($row['enabled']) || !empty($row['enabled']),
];
}
return $out;
}
function achv_load_catalog(): array
{
if (is_file(ACHV_CATALOG_FILE)) {
$raw = @file_get_contents(ACHV_CATALOG_FILE);
$j = json_decode($raw ?: '[]', true);
if (is_array($j) && $j) {
$c = achv_sanitize_catalog($j);
if ($c) return $c;
}
}
return achv_default_catalog();
}
function achv_save_catalog(array $catalog): bool
{
if (!is_dir(ADMIN_PRIVATE_DIR)) {
if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) return false;
}
$tmp = ACHV_CATALOG_FILE . '.tmp.' . bin2hex(random_bytes(4));
$json = json_encode($catalog, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) return false;
if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;
if (!rename($tmp, ACHV_CATALOG_FILE)) { @unlink($tmp); return false; }
return true;
}
function achv_valid_key(string $key): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
/* หา index บัญชี guest ตาม playerKey (สร้างใหม่ถ้าไม่มี) */
function achv_find_or_create(array &$store, string $key): int
{
foreach ($store['accounts'] as $i => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
return $i;
}
}
$store['accounts'][] = [
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => 'auto: achievements',
'blocked' => false,
'coins' => 0,
'achievements' => new \stdClass(),
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
return count($store['accounts']) - 1;
}
function achv_progress_of(array $account): array
{
$p = $account['achievements'] ?? [];
if (!is_array($p)) return [];
$out = [];
foreach ($p as $k => $v) {
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $k);
if ($id === '') continue;
$out[$id] = max(0, (int) $v);
}
return $out;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = (string) ($_GET['action'] ?? '');
if ($method === 'POST') {
$body = require_json_body();
if (!$action) $action = (string) ($body['action'] ?? '');
} else {
$body = [];
}
/* ---------- Public ---------- */
if ($action === 'catalog' && $method === 'GET') {
json_response(['ok' => true, 'catalog' => achv_load_catalog()]);
}
if ($action === 'state' && $method === 'GET') {
$key = trim((string) ($_GET['playerKey'] ?? ''));
if (!achv_valid_key($key)) {
json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
}
$catalog = achv_load_catalog();
$store = read_store();
$progress = [];
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$progress = achv_progress_of($a);
break;
}
}
json_response(['ok' => true, 'catalog' => $catalog, 'progress' => (object) $progress]);
}
/* ---------- Server-only (secret) : auto-track ภายหลัง ---------- */
if ($action === 'progress' && $method === 'POST') {
$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
$secret = (string) ($body['secret'] ?? '');
if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
json_response(['ok' => false, 'error' => 'unauthorized'], 403);
}
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') {
json_response(['ok' => false, 'error' => 'bad params'], 400);
}
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
if (isset($body['set'])) {
$val = max(0, min($target, (int) $body['set']));
} else {
$inc = (int) ($body['inc'] ?? 1);
$val = max(0, min($target, ($cur[$id] ?? 0) + $inc));
}
$cur[$id] = $val;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $val, 'unlocked' => $val >= $target]);
}
/* streak — สำหรับ achievement แบบ "ติดต่อกัน" (a6/d4/e1): event 'hit' นับต่อ, 'miss' รีเซ็ต 0.
* เก็บ current streak แยกใน accounts[].achvStreak {id:count} · achievement = best streak ที่เคยถึง (ไม่ลดลงเวลารีเซ็ต) */
if ($action === 'streak' && $method === 'POST') {
$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
$secret = (string) ($body['secret'] ?? '');
if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
json_response(['ok' => false, 'error' => 'unauthorized'], 403);
}
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
$event = (string) ($body['event'] ?? '');
if (!achv_valid_key($key) || $id === '' || ($event !== 'hit' && $event !== 'miss')) {
json_response(['ok' => false, 'error' => 'bad params'], 400);
}
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$acct = $store['accounts'][$i];
$streaks = (isset($acct['achvStreak']) && is_array($acct['achvStreak'])) ? $acct['achvStreak'] : [];
$sc = max(0, (int) ($streaks[$id] ?? 0));
$sc = ($event === 'hit') ? ($sc + 1) : 0;
$streaks[$id] = $sc;
$cur = achv_progress_of($acct);
$best = max(0, min($target, max((int) ($cur[$id] ?? 0), $sc))); /* achievement = best ไม่ลดลง */
$cur[$id] = $best;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['achvStreak'] = (object) $streaks;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'streak' => $sc, 'value' => $best, 'unlocked' => $best >= $target]);
}
/* ---------- Admin ---------- */
require_login();
if ($action === 'saveCatalog' && $method === 'POST') {
$catIn = (isset($body['catalog']) && is_array($body['catalog'])) ? $body['catalog'] : null;
if ($catIn === null) json_response(['ok' => false, 'error' => 'ไม่มี catalog'], 400);
$clean = achv_sanitize_catalog($catIn);
if (!$clean) json_response(['ok' => false, 'error' => 'catalog ว่าง'], 400);
if (!achv_save_catalog($clean)) {
json_response(['ok' => false, 'error' => 'บันทึก achievements.json ไม่สำเร็จ (chown ให้ user เว็บ)'], 500);
}
json_response(['ok' => true, 'catalog' => $clean]);
}
if ($action === 'resetCatalog' && $method === 'POST') {
$def = achv_default_catalog();
if (!achv_save_catalog($def)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'catalog' => $def]);
}
if ($action === 'players' && $method === 'GET') {
$catalog = achv_load_catalog();
$byId = [];
foreach ($catalog as $c) $byId[$c['id']] = (int) $c['target'];
$store = read_store();
$rows = [];
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') !== 'guest') continue;
$key = (string) ($a['providerUserId'] ?? '');
if ($key === '') continue;
$prog = achv_progress_of($a);
$unlocked = 0;
foreach ($prog as $id => $v) {
if (isset($byId[$id]) && $v >= $byId[$id]) $unlocked++;
}
$rows[] = [
'playerKey' => $key,
'displayName' => (string) ($a['displayName'] ?? ($a['lbName'] ?? 'Guest')),
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'unlocked' => $unlocked,
'total' => count($byId),
'updatedAt' => (string) ($a['updatedAt'] ?? ''),
];
}
json_response(['ok' => true, 'players' => $rows, 'total' => count($byId)]);
}
if ($action === 'player' && $method === 'GET') {
$key = trim((string) ($_GET['playerKey'] ?? ''));
if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
$store = read_store();
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
json_response(['ok' => true, 'playerKey' => $key, 'displayName' => (string) ($a['displayName'] ?? 'Guest'), 'progress' => (object) achv_progress_of($a)]);
}
}
json_response(['ok' => true, 'playerKey' => $key, 'displayName' => 'Guest', 'progress' => (object) []]);
}
if ($action === 'setProgress' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$value = max(0, min($target, (int) ($body['value'] ?? 0)));
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
$cur[$id] = $value;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $value, 'unlocked' => $value >= $target]);
}
if ($action === 'unlock' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
$cur[$id] = $target;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $target, 'unlocked' => true]);
}
if ($action === 'resetPlayer' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
$store = read_store();
foreach ($store['accounts'] as $idx => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$store['accounts'][$idx]['achievements'] = new \stdClass();
$store['accounts'][$idx]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true]);
}
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'unknown action'], 400);
<?php
declare(strict_types=1);
/**
* Achievements — แคตตาล็อก + progress รายผู้เล่น
*
* แคตตาล็อก override : Admin/private/achievements.json (ถ้าไม่มี ใช้ default ในไฟล์นี้)
* progress รายผู้เล่น : เก็บใน store.json -> accounts[].achievements { id: count }
*
* Public (ไม่ต้องล็อกอิน):
* GET ?action=state&playerKey=KEY -> { ok, catalog:[...], progress:{id:count} }
* GET ?action=catalog -> { ok, catalog:[...] }
*
* Server-only (มี secret game-award-secret.txt) — สำหรับ auto-track ภายหลัง:
* POST {action:'progress', secret, playerKey, id, inc?, set?}
*
* Admin (ต้องล็อกอิน session):
* POST {action:'saveCatalog', catalog:[...]}
* GET ?action=players -> รายชื่อผู้เล่น + จำนวนที่ปลดล็อก
* GET ?action=player&playerKey=KEY -> progress ของผู้เล่นคนเดียว
* POST {action:'setProgress', playerKey, id, value}
* POST {action:'unlock', playerKey, id} -> set = target
* POST {action:'resetPlayer', playerKey} -> ล้าง progress ทั้งหมด
*/
require __DIR__ . '/_common.php';
date_default_timezone_set('Asia/Bangkok');
define('ACHV_CATALOG_FILE', ADMIN_PRIVATE_DIR . '/achievements.json');
function achv_default_catalog(): array
{
return [
['id' => 'a1_first_deduction', 'g' => 1, 'title' => 'First Deduction', 'desc' => 'โหวตถูกตัวคนร้ายเป็นครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'a2_sharp_eye', 'g' => 1, 'title' => 'Sharp Eye', 'desc' => 'สะสมหลักฐานระดับมีน้ำหนัก (Silver) ครบ 10 ใบ', 'target' => 10, 'enabled' => true],
['id' => 'a3_mind_architect', 'g' => 1, 'title' => 'Mind Architect', 'desc' => 'สะสมหลักฐานครบทุกระดับ (ทั่วไป, มีน้ำหนัก, ชี้ชัด) ใน 1 คดี', 'target' => 1, 'enabled' => true],
['id' => 'a4_logic_over_luck', 'g' => 1, 'title' => 'Logic Over Luck', 'desc' => 'โหวตถูกโดยไม่พึ่งหลักฐานชี้ชัด (Legendary) เลย 3 ครั้ง', 'target' => 3, 'enabled' => true],
['id' => 'a5_truth_hunter', 'g' => 1, 'title' => 'Truth Hunter', 'desc' => 'จับคนร้ายถูกตัวสะสมครบ 20 คดี', 'target' => 20, 'enabled' => true],
['id' => 'a6_unbreakable_logic', 'g' => 1, 'title' => 'Unbreakable Logic', 'desc' => 'โหวตถูกตัวติดกัน 5 คดีรวด', 'target' => 5, 'enabled' => true],
['id' => 'b1_evidence_collector', 'g' => 2, 'title' => 'Evidence Collector', 'desc' => 'สะสมหลักฐานระดับทั่วไป (Common) ครบ 20 ใบ', 'target' => 20, 'enabled' => true],
['id' => 'b2_relentless_investigator', 'g' => 2, 'title' => 'Relentless Investigator', 'desc' => 'เล่น Mini Game ครบทุกรอบ ใน 1 คดี', 'target' => 1, 'enabled' => true],
['id' => 'b3_hidden_fragment', 'g' => 2, 'title' => 'Hidden Fragment', 'desc' => 'ค้นพบหลักฐานระดับชี้ชัด (Legendary/Gold) เป็นครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'b4_data_miner', 'g' => 2, 'title' => 'Data Miner', 'desc' => 'สะสมการ์ดหลักฐานรวมครบ 100 ใบ', 'target' => 100, 'enabled' => true],
['id' => 'b5_deep_scanner', 'g' => 2, 'title' => 'Deep Scanner', 'desc' => 'เก็บไอเทมช่วยเหลือ (ตำรวจ/ทนาย) สะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'c1_early_accusation', 'g' => 3, 'title' => 'Early Accusation', 'desc' => 'ชี้ตัวคนร้ายก่อนที่จะเปิดหลักฐานครบ', 'target' => 1, 'enabled' => true],
['id' => 'c2_high_stakes', 'g' => 3, 'title' => 'High Stakes', 'desc' => 'ชี้ตัวคนร้ายถูกโดยมีหลักฐานในมือไม่เกิน 3 ใบ', 'target' => 1, 'enabled' => true],
['id' => 'c3_quick_draw', 'g' => 3, 'title' => 'Quick Draw', 'desc' => 'ชี้ตัวคนร้ายเร็วที่สุดในทีมสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'c4_clutch_mind', 'g' => 3, 'title' => 'Clutch Mind', 'desc' => 'โหวตถูกในช่วง 10 วินาทีสุดท้ายก่อนหมดเวลา', 'target' => 1, 'enabled' => true],
['id' => 'c5_lone_wolf', 'g' => 3, 'title' => 'Lone Wolf', 'desc' => 'โหวตสวนทางกับเสียงส่วนใหญ่ของทีม (คุณถูกคนเดียว)', 'target' => 1, 'enabled' => true],
['id' => 'd1_minigame_solver', 'g' => 4, 'title' => 'Minigame Solver', 'desc' => 'เอาชีวิตรอด / เล่นมินิเกมสำเร็จ 20 ครั้ง', 'target' => 20, 'enabled' => true],
['id' => 'd2_silent_guardian', 'g' => 4, 'title' => 'Silent Guardian', 'desc' => 'ไม่โดนโหวตใช้การ์ด Event พิเศษ เลยตลอด 5 คดี', 'target' => 5, 'enabled' => true],
['id' => 'd3_the_backbone', 'g' => 4, 'title' => 'The Backbone', 'desc' => 'ส่งมอบหลักฐานให้เพื่อนวิเคราะห์ครบ 30 ใบ', 'target' => 30, 'enabled' => true],
['id' => 'd4_flawless_diver', 'g' => 4, 'title' => 'Flawless Diver', 'desc' => 'ไม่โหวตจับผิดตัวเลยตลอด 15 คดี', 'target' => 15, 'enabled' => true],
['id' => 'e1_the_observer', 'g' => 5, 'title' => 'The Observer', 'desc' => 'เล่นจบ 15 คดีโดยไม่เคยสัมผัสหลักฐานชี้ชัด (Legendary) เลยสักครั้ง', 'target' => 15, 'enabled' => true],
['id' => 'e2_the_impostor', 'g' => 5, 'title' => 'The Impostor', 'desc' => 'รับบทเป็น "ตัวป่วน" ครั้งแรก', 'target' => 1, 'enabled' => true],
['id' => 'e3_master_of_doubt', 'g' => 5, 'title' => 'Master of Doubt', 'desc' => 'เอาชนะคดีในฐานะตัวป่วนได้สำเร็จ', 'target' => 1, 'enabled' => true],
['id' => 'e4_agent_of_chaos', 'g' => 5, 'title' => 'Agent of Chaos', 'desc' => 'รับบทเป็นตัวป่วนสะสมครบ 10 ครั้ง', 'target' => 10, 'enabled' => true],
['id' => 'e5_slippery_eel', 'g' => 5, 'title' => 'Slippery Eel', 'desc' => 'เป็นตัวป่วนแต่รอดพ้นจากการถูกจับได้ (ไม่ถูกโหวตออก) จนจบเกม 5 ครั้ง', 'target' => 5, 'enabled' => true],
];
}
function achv_sanitize_catalog(array $arr): array
{
$out = [];
$seen = [];
foreach ($arr as $row) {
if (!is_array($row)) continue;
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($row['id'] ?? ''));
if ($id === '' || isset($seen[$id])) continue;
$seen[$id] = true;
$g = (int) ($row['g'] ?? 1);
if ($g < 1 || $g > 5) $g = 1;
$title = trim((string) ($row['title'] ?? ''));
$desc = trim((string) ($row['desc'] ?? ''));
if (function_exists('mb_substr')) {
$title = mb_substr($title, 0, 60);
$desc = mb_substr($desc, 0, 200);
}
$target = (int) ($row['target'] ?? 1);
if ($target < 1) $target = 1;
if ($target > 100000) $target = 100000;
$out[] = [
'id' => $id,
'g' => $g,
'title' => $title !== '' ? $title : $id,
'desc' => $desc,
'target' => $target,
'enabled' => !isset($row['enabled']) || !empty($row['enabled']),
];
}
return $out;
}
function achv_load_catalog(): array
{
if (is_file(ACHV_CATALOG_FILE)) {
$raw = @file_get_contents(ACHV_CATALOG_FILE);
$j = json_decode($raw ?: '[]', true);
if (is_array($j) && $j) {
$c = achv_sanitize_catalog($j);
if ($c) return $c;
}
}
return achv_default_catalog();
}
function achv_save_catalog(array $catalog): bool
{
if (!is_dir(ADMIN_PRIVATE_DIR)) {
if (!@mkdir(ADMIN_PRIVATE_DIR, 0750, true)) return false;
}
$tmp = ACHV_CATALOG_FILE . '.tmp.' . bin2hex(random_bytes(4));
$json = json_encode($catalog, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) return false;
if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;
if (!rename($tmp, ACHV_CATALOG_FILE)) { @unlink($tmp); return false; }
return true;
}
function achv_valid_key(string $key): bool
{
return (bool) preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key);
}
/* หา index บัญชี guest ตาม playerKey (สร้างใหม่ถ้าไม่มี) */
function achv_find_or_create(array &$store, string $key): int
{
foreach ($store['accounts'] as $i => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
return $i;
}
}
$store['accounts'][] = [
'id' => new_id(),
'email' => '',
'displayName' => 'Guest',
'loginType' => 'guest',
'providerUserId' => $key,
'notes' => 'auto: achievements',
'blocked' => false,
'coins' => 0,
'achievements' => new \stdClass(),
'createdAt' => gmdate('c'),
'updatedAt' => gmdate('c'),
];
return count($store['accounts']) - 1;
}
function achv_progress_of(array $account): array
{
$p = $account['achievements'] ?? [];
if (!is_array($p)) return [];
$out = [];
foreach ($p as $k => $v) {
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $k);
if ($id === '') continue;
$out[$id] = max(0, (int) $v);
}
return $out;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = (string) ($_GET['action'] ?? '');
if ($method === 'POST') {
$body = require_json_body();
if (!$action) $action = (string) ($body['action'] ?? '');
} else {
$body = [];
}
/* ---------- Public ---------- */
if ($action === 'catalog' && $method === 'GET') {
json_response(['ok' => true, 'catalog' => achv_load_catalog()]);
}
if ($action === 'state' && $method === 'GET') {
$key = trim((string) ($_GET['playerKey'] ?? ''));
if (!achv_valid_key($key)) {
json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
}
$catalog = achv_load_catalog();
$store = read_store();
$progress = [];
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$progress = achv_progress_of($a);
break;
}
}
json_response(['ok' => true, 'catalog' => $catalog, 'progress' => (object) $progress]);
}
/* ---------- Server-only (secret) : auto-track ภายหลัง ---------- */
if ($action === 'progress' && $method === 'POST') {
$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
$secret = (string) ($body['secret'] ?? '');
if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
json_response(['ok' => false, 'error' => 'unauthorized'], 403);
}
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') {
json_response(['ok' => false, 'error' => 'bad params'], 400);
}
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
if (isset($body['set'])) {
$val = max(0, min($target, (int) $body['set']));
} else {
$inc = (int) ($body['inc'] ?? 1);
$val = max(0, min($target, ($cur[$id] ?? 0) + $inc));
}
$cur[$id] = $val;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $val, 'unlocked' => $val >= $target]);
}
/* ---------- Server-only (secret) : streak [2026-07-27] ----------
* ก่อนหน้านี้ "ไม่มี branch นี้เลย" → POST action=streak จาก Game/server.js:2264 ตกไปถึง
* require_login() ด้านล่าง แล้วได้ 401 Unauthorized ทุกครั้ง = achievement แบบ streak
* (a6_unbreakable_logic / d4_flawless_diver / e1_the_observer) ไม่เคยถูกบันทึกเลย
*
* สัญญาที่ server.js คาดไว้: {playerKey, id, event:'hit'|'miss'}
* hit → streak ปัจจุบัน +1
* miss → streak ปัจจุบัน = 0
* ค่า achievement ที่เก็บ = **สถิติสูงสุด (best) ไม่ลดลง** ตามคอมเมนต์ที่ server.js เขียนไว้
* streak ที่กำลังนับอยู่เก็บแยกใน achievementStreaks เพื่อไม่ให้ทับ progress ปกติ
*/
if ($action === 'streak' && $method === 'POST') {
$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
$secret = (string) ($body['secret'] ?? '');
if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
json_response(['ok' => false, 'error' => 'unauthorized'], 403);
}
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
$event = (string) ($body['event'] ?? '');
if (!achv_valid_key($key) || $id === '' || ($event !== 'hit' && $event !== 'miss')) {
json_response(['ok' => false, 'error' => 'bad params'], 400);
}
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$runs = [];
if (isset($store['accounts'][$i]['achievementStreaks'])) {
$runs = (array) $store['accounts'][$i]['achievementStreaks'];
}
$run = max(0, (int) ($runs[$id] ?? 0));
$run = ($event === 'miss') ? 0 : $run + 1;
$runs[$id] = $run;
$cur = achv_progress_of($store['accounts'][$i]);
/* best ไม่ลดลง — miss ไม่ทำให้ achievement ที่เคยได้หายไป */
$best = max((int) ($cur[$id] ?? 0), min($target, $run));
$cur[$id] = $best;
$store['accounts'][$i]['achievementStreaks'] = (object) $runs;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'event' => $event, 'streak' => $run, 'value' => $best, 'unlocked' => $best >= $target]);
}
/* ---------- Admin ---------- */
require_login();
require_tab('achievements');
if ($action === 'saveCatalog' && $method === 'POST') {
$catIn = (isset($body['catalog']) && is_array($body['catalog'])) ? $body['catalog'] : null;
if ($catIn === null) json_response(['ok' => false, 'error' => 'ไม่มี catalog'], 400);
$clean = achv_sanitize_catalog($catIn);
if (!$clean) json_response(['ok' => false, 'error' => 'catalog ว่าง'], 400);
if (!achv_save_catalog($clean)) {
json_response(['ok' => false, 'error' => 'บันทึก achievements.json ไม่สำเร็จ (chown ให้ user เว็บ)'], 500);
}
json_response(['ok' => true, 'catalog' => $clean]);
}
if ($action === 'resetCatalog' && $method === 'POST') {
$def = achv_default_catalog();
if (!achv_save_catalog($def)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'catalog' => $def]);
}
if ($action === 'players' && $method === 'GET') {
$catalog = achv_load_catalog();
$byId = [];
foreach ($catalog as $c) $byId[$c['id']] = (int) $c['target'];
$store = read_store();
$rows = [];
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') !== 'guest') continue;
$key = (string) ($a['providerUserId'] ?? '');
if ($key === '') continue;
$prog = achv_progress_of($a);
$unlocked = 0;
foreach ($prog as $id => $v) {
if (isset($byId[$id]) && $v >= $byId[$id]) $unlocked++;
}
$rows[] = [
'playerKey' => $key,
'displayName' => (string) ($a['displayName'] ?? ($a['lbName'] ?? 'Guest')),
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'unlocked' => $unlocked,
'total' => count($byId),
'updatedAt' => (string) ($a['updatedAt'] ?? ''),
];
}
json_response(['ok' => true, 'players' => $rows, 'total' => count($byId)]);
}
if ($action === 'player' && $method === 'GET') {
$key = trim((string) ($_GET['playerKey'] ?? ''));
if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
$store = read_store();
foreach ($store['accounts'] as $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
json_response(['ok' => true, 'playerKey' => $key, 'displayName' => (string) ($a['displayName'] ?? 'Guest'), 'progress' => (object) achv_progress_of($a)]);
}
}
json_response(['ok' => true, 'playerKey' => $key, 'displayName' => 'Guest', 'progress' => (object) []]);
}
if ($action === 'setProgress' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$value = max(0, min($target, (int) ($body['value'] ?? 0)));
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
$cur[$id] = $value;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $value, 'unlocked' => $value >= $target]);
}
if ($action === 'unlock' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
$id = preg_replace('/[^a-zA-Z0-9_]/', '', (string) ($body['id'] ?? ''));
if (!achv_valid_key($key) || $id === '') json_response(['ok' => false, 'error' => 'bad params'], 400);
$catalog = achv_load_catalog();
$target = 0;
foreach ($catalog as $c) { if ($c['id'] === $id) { $target = (int) $c['target']; break; } }
if ($target <= 0) json_response(['ok' => false, 'error' => 'unknown id'], 404);
$store = read_store();
$i = achv_find_or_create($store, $key);
$cur = achv_progress_of($store['accounts'][$i]);
$cur[$id] = $target;
$store['accounts'][$i]['achievements'] = (object) $cur;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true, 'id' => $id, 'value' => $target, 'unlocked' => true]);
}
if ($action === 'resetPlayer' && $method === 'POST') {
$key = trim((string) ($body['playerKey'] ?? ''));
if (!achv_valid_key($key)) json_response(['ok' => false, 'error' => 'playerKey ไม่ถูกต้อง'], 400);
$store = read_store();
foreach ($store['accounts'] as $idx => $a) {
if (($a['loginType'] ?? '') === 'guest' && ($a['providerUserId'] ?? '') === $key) {
$store['accounts'][$idx]['achievements'] = new \stdClass();
$store['accounts'][$idx]['updatedAt'] = gmdate('c');
if (!write_store($store)) json_response(['ok' => false, 'error' => 'save failed'], 500);
json_response(['ok' => true]);
}
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'unknown action'], 400);
+141 -127
View File
@@ -1,127 +1,141 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
$me = current_admin();
if (!$me) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('strip_admin', read_store()['admins'] ?? []);
json_response(['ok' => true, 'admins' => $list]);
}
if (!is_super($me)) {
json_response(['ok' => false, 'error' => 'เฉพาะ super admin จัดการบัญชีแอดมินได้'], 403);
}
if ($method === 'POST') {
$body = require_json_body();
$username = trim((string)($body['username'] ?? ''));
$password = (string)($body['password'] ?? '');
$role = ($body['role'] ?? 'admin') === 'super' ? 'super' : 'admin';
if ($role === 'super' && !is_super($me)) {
json_response(['ok' => false, 'error' => 'สร้าง super admin ได้เฉพาะ super เท่านั้น'], 403);
}
if ($username === '' || strlen($username) < 2) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้สั้นเกินไป'], 400);
}
if (strlen($password) < 8) {
json_response(['ok' => false, 'error' => 'รหัสผ่านอย่างน้อย 8 ตัวอักษร'], 400);
}
$store = read_store();
foreach ($store['admins'] ?? [] as $x) {
if (strcasecmp($x['username'] ?? '', $username) === 0) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้ซ้ำ'], 400);
}
}
$store['admins'][] = [
'id' => new_id(),
'username' => $username,
'passwordHash' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'createdAt' => gmdate('c'),
];
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$targetId = trim((string)($body['id'] ?? ''));
$newPassword = (string)($body['newPassword'] ?? '');
if ($targetId === '') {
json_response(['ok' => false, 'error' => 'ระบุ id แอดมิน'], 400);
}
if (strlen($newPassword) < 8) {
json_response(['ok' => false, 'error' => 'รหัสใหม่อย่างน้อย 8 ตัวอักษร'], 400);
}
if ($targetId === $me['id']) {
json_response(['ok' => false, 'error' => 'เปลี่ยนรหัสตัวเองให้ใช้เมนู "เปลี่ยนรหัสผ่านของคุณ"'], 400);
}
$store = read_store();
$updated = false;
foreach ($store['admins'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $targetId) {
continue;
}
$store['admins'][$i]['passwordHash'] = password_hash($newPassword, PASSWORD_DEFAULT);
$updated = true;
break;
}
if (!$updated) {
json_response(['ok' => false, 'error' => 'ไม่พบแอดมิน'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'message' => 'ตั้งรหัสใหม่แล้ว']);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
if ($id === $me['id']) {
json_response(['ok' => false, 'error' => 'ลบบัญชีตัวเองไม่ได้'], 400);
}
$store = read_store();
$admins = $store['admins'] ?? [];
$next = [];
$removed = false;
foreach ($admins as $a) {
if (($a['id'] ?? '') === $id) {
$removed = true;
continue;
}
$next[] = $a;
}
if (!$removed) {
json_response(['ok' => false, 'error' => 'ไม่พบผู้ใช้'], 404);
}
$supers = 0;
foreach ($next as $a) {
if (($a['role'] ?? '') === 'super') {
$supers++;
}
}
if ($supers < 1) {
json_response(['ok' => false, 'error' => 'ต้องมี super admin อย่างน้อย 1 คน'], 400);
}
$store['admins'] = $next;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_tab('admins');
$me = current_admin();
if (!$me) {
json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$list = array_map('strip_admin', read_store()['admins'] ?? []);
json_response(['ok' => true, 'admins' => $list]);
}
if (!is_super($me)) {
json_response(['ok' => false, 'error' => 'เฉพาะ super admin จัดการบัญชีแอดมินได้'], 403);
}
if ($method === 'POST') {
$body = require_json_body();
$username = trim((string)($body['username'] ?? ''));
$password = (string)($body['password'] ?? '');
$role = ($body['role'] ?? 'admin') === 'super' ? 'super' : 'admin';
if ($role === 'super' && !is_super($me)) {
json_response(['ok' => false, 'error' => 'สร้าง super admin ได้เฉพาะ super เท่านั้น'], 403);
}
if ($username === '' || strlen($username) < 2) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้สั้นเกินไป'], 400);
}
if (strlen($password) < 8) {
json_response(['ok' => false, 'error' => 'รหัสผ่านอย่างน้อย 8 ตัวอักษร'], 400);
}
$store = read_store();
foreach ($store['admins'] ?? [] as $x) {
if (strcasecmp($x['username'] ?? '', $username) === 0) {
json_response(['ok' => false, 'error' => 'ชื่อผู้ใช้ซ้ำ'], 400);
}
}
/* สิทธิ์ระดับหมวด — super ไม่ต้องเก็บ (ได้ทุกหมวดเสมอ) · admin ที่ส่ง tabs ว่างมา = ทุกหมวด (พฤติกรรมเดิม) */
$store['admins'][] = [
'id' => new_id(),
'username' => $username,
'passwordHash' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'tabs' => $role === 'super' ? [] : sanitize_tabs($body['tabs'] ?? []),
'createdAt' => gmdate('c'),
];
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
if ($method === 'PATCH') {
$body = require_json_body();
$targetId = trim((string)($body['id'] ?? ''));
$newPassword = (string)($body['newPassword'] ?? '');
/* PATCH ทำได้ 2 อย่าง: ตั้งรหัสใหม่ และ/หรือ แก้สิทธิ์หมวด — ต้องส่งมาอย่างน้อย 1 อย่าง */
$wantTabs = array_key_exists('tabs', $body);
if ($targetId === '') {
json_response(['ok' => false, 'error' => 'ระบุ id แอดมิน'], 400);
}
if ($newPassword === '' && !$wantTabs) {
json_response(['ok' => false, 'error' => 'ไม่มีอะไรให้แก้ (ส่ง newPassword หรือ tabs)'], 400);
}
if ($newPassword !== '' && strlen($newPassword) < 8) {
json_response(['ok' => false, 'error' => 'รหัสใหม่อย่างน้อย 8 ตัวอักษร'], 400);
}
if ($newPassword !== '' && $targetId === $me['id']) {
json_response(['ok' => false, 'error' => 'เปลี่ยนรหัสตัวเองให้ใช้เมนู "เปลี่ยนรหัสผ่านของคุณ"'], 400);
}
$store = read_store();
$updated = false;
foreach ($store['admins'] ?? [] as $i => $a) {
if (($a['id'] ?? '') !== $targetId) {
continue;
}
if ($newPassword !== '') {
$store['admins'][$i]['passwordHash'] = password_hash($newPassword, PASSWORD_DEFAULT);
}
if ($wantTabs) {
/* super ได้ทุกหมวดอยู่แล้ว — เก็บ tabs ว่างไว้ กันเข้าใจผิดว่าถูกจำกัด */
$store['admins'][$i]['tabs'] = (($a['role'] ?? '') === 'super') ? [] : sanitize_tabs($body['tabs']);
}
$updated = true;
break;
}
if (!$updated) {
json_response(['ok' => false, 'error' => 'ไม่พบแอดมิน'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'message' => $newPassword !== '' ? 'ตั้งรหัสใหม่แล้ว' : 'บันทึกสิทธิ์หมวดแล้ว']);
}
if ($method === 'DELETE') {
$id = trim((string)($_GET['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
if ($id === $me['id']) {
json_response(['ok' => false, 'error' => 'ลบบัญชีตัวเองไม่ได้'], 400);
}
$store = read_store();
$admins = $store['admins'] ?? [];
$next = [];
$removed = false;
foreach ($admins as $a) {
if (($a['id'] ?? '') === $id) {
$removed = true;
continue;
}
$next[] = $a;
}
if (!$removed) {
json_response(['ok' => false, 'error' => 'ไม่พบผู้ใช้'], 404);
}
$supers = 0;
foreach ($next as $a) {
if (($a['role'] ?? '') === 'super') {
$supers++;
}
}
if ($supers < 1) {
json_response(['ok' => false, 'error' => 'ต้องมี super admin อย่างน้อย 1 คน'], 400);
}
$store['admins'] = $next;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* ตั้งค่า AI แชท — อ่าน/เขียน Game/data/ai-settings.json
* ใช้ session Admin หลัก (ไม่ต้องรหัส game-ai-admin แยกเมื่อเปิดจากแผง Admin)
*
* GET -> { model, models[], hasKey, intent, rag_enabled, rag_context } (ไม่ส่ง api key ดิบ)
* PUT -> { openai_api_key?, model?, intent?, rag_enabled?, rag_context? }
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('ai-admin');
define('AI_SETTINGS_FILE', dirname(__DIR__, 2) . '/Game/data/ai-settings.json');
function ai_models(): array
{
return [
'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano',
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research',
'o1', 'o1-mini', 'o1-pro',
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4',
'gpt-3.5-turbo',
];
}
function ai_defaults(): array
{
return [
'openai_api_key' => '',
'model' => 'gpt-4o-mini',
'intent' => '',
'rag_enabled' => false,
'rag_context' => '',
];
}
function ai_read(): array
{
$base = ai_defaults();
if (!is_file(AI_SETTINGS_FILE)) {
return $base;
}
$raw = @file_get_contents(AI_SETTINGS_FILE);
$j = json_decode($raw ?: '{}', true);
if (!is_array($j)) {
return $base;
}
return array_replace($base, array_intersect_key($j, $base));
}
function ai_write(array $s): bool
{
$dir = dirname(AI_SETTINGS_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return false;
}
$json = json_encode($s, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) {
return false;
}
$tmp = AI_SETTINGS_FILE . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
if (!rename($tmp, AI_SETTINGS_FILE)) {
@unlink($tmp);
return false;
}
return true;
}
function ai_public_payload(array $s): array
{
$key = trim((string) ($s['openai_api_key'] ?? ''));
return [
'ok' => true,
'model' => (string) ($s['model'] ?? 'gpt-4o-mini') ?: 'gpt-4o-mini',
'models' => ai_models(),
'hasKey' => $key !== '',
'intent' => (string) ($s['intent'] ?? ''),
'rag_enabled' => !empty($s['rag_enabled']),
'rag_context' => (string) ($s['rag_context'] ?? ''),
];
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
json_response(ai_public_payload(ai_read()));
}
if ($method === 'PUT' || $method === 'POST') {
$body = require_json_body();
$s = ai_read();
if (array_key_exists('openai_api_key', $body)) {
$s['openai_api_key'] = trim((string) $body['openai_api_key']);
}
if (array_key_exists('model', $body)) {
$m = trim((string) $body['model']);
$s['model'] = $m !== '' ? $m : 'gpt-4o-mini';
}
if (array_key_exists('intent', $body)) {
$s['intent'] = (string) $body['intent'];
}
if (array_key_exists('rag_enabled', $body)) {
$s['rag_enabled'] = !empty($body['rag_enabled']);
}
if (array_key_exists('rag_context', $body)) {
$s['rag_context'] = (string) $body['rag_context'];
}
if (!ai_write($s)) {
json_response(['ok' => false, 'error' => 'บันทึก ai-settings.json ไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
@@ -1,69 +1,70 @@
<?php
declare(strict_types=1);
/**
* Proxy อัปโหลดรูปป้าย quiz_carry → Node POST /Game/api/quiz-carry-plaque-upload
* ใช้เมื่อแอดมินอยู่คนละ path / nginx ไม่ส่ง POST ไป Node — คล้าย game-quiz-settings.php
*/
require __DIR__ . '/_common.php';
require_login();
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl · Enable php-curl'], 500);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'POST') {
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
}
$raw = file_get_contents('php://input');
if ($raw === false) {
$raw = '';
}
$contentLen = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
if ($raw === '' && $contentLen > 0) {
json_response([
'ok' => false,
'error' => 'รับ body ไม่ได้ (มักเกิดจาก nginx client_max_body_size หรือ PHP post_max_size เล็กเกินไป) — ต้องตั้งอย่างน้อย ~20M สำหรับ /Admin/api/*.php · Request body was discarded (raise nginx body limit + PHP post_max_size)',
], 413);
}
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-carry-plaque-upload';
$ch = curl_init($target);
if ($ch === false) {
json_response(['ok' => false, 'error' => 'curl init failed'], 500);
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
json_response([
'ok' => false,
'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจ systemd/pm2 และพอร์ต ' . $port . ' · ' . $err,
], 502);
}
http_response_code($code > 0 ? $code : 500);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
echo $out;
<?php
declare(strict_types=1);
/**
* Proxy อัปโหลดรูปป้าย quiz_carry → Node POST /Game/api/quiz-carry-plaque-upload
* ใช้เมื่อแอดมินอยู่คนละ path / nginx ไม่ส่ง POST ไป Node — คล้าย game-quiz-settings.php
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('quiz-carry');
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl · Enable php-curl'], 500);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'POST') {
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
}
$raw = file_get_contents('php://input');
if ($raw === false) {
$raw = '';
}
$contentLen = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
if ($raw === '' && $contentLen > 0) {
json_response([
'ok' => false,
'error' => 'รับ body ไม่ได้ (มักเกิดจาก nginx client_max_body_size หรือ PHP post_max_size เล็กเกินไป) — ต้องตั้งอย่างน้อย ~20M สำหรับ /Admin/api/*.php · Request body was discarded (raise nginx body limit + PHP post_max_size)',
], 413);
}
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-carry-plaque-upload';
$ch = curl_init($target);
if ($ch === false) {
json_response(['ok' => false, 'error' => 'curl init failed'], 500);
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
json_response([
'ok' => false,
'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจ systemd/pm2 และพอร์ต ' . $port . ' · ' . $err,
], 502);
}
http_response_code($code > 0 ? $code : 500);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
echo $out;
@@ -12,6 +12,7 @@ declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_any_tab(['quiz','quiz-carry','quiz-battle','special-quiz','game-timing','jump-survive','mega-virus','space-shooter','stack-game','evidence-cards','postcase','troublesome','vote-timing']);
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl (ติดตั้งแพ็กเกจ php-curl แล้วรีสตาร์ท PHP-FPM)'], 500);
+95 -94
View File
@@ -1,94 +1,95 @@
<?php
declare(strict_types=1);
/**
* แอดมินเท่านั้น — จัดการกระดานผู้นำ (High Score)
* GET → รายชื่อบัญชีเรียงตามคะแนนสะสม (มาก→น้อย)
* POST { action: 'resetAll' } → ล้างคะแนนทุกคนเป็น 0
* POST { action: 'reset', id } → ล้างคะแนนคนเดียวเป็น 0
* (การตั้งคะแนนรายคน ใช้ accounts.php PATCH { id, score })
*/
require __DIR__ . '/_common.php';
require_login();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$rows = [];
foreach (($store['accounts'] ?? []) as $a) {
$score = max(0, (int) ($a['score'] ?? 0));
$name = trim((string) ($a['lbName'] ?? ''));
if ($name === '') $name = trim((string) ($a['displayName'] ?? ''));
if ($name === '') $name = 'ผู้เล่น';
$rows[] = [
'id' => (string) ($a['id'] ?? ''),
'name' => $name,
'score' => $score,
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'loginType' => (string) ($a['loginType'] ?? ''),
'key' => (string) ($a['providerUserId'] ?? ''),
];
}
usort($rows, function ($x, $y) {
if ($y['score'] !== $x['score']) {
return $y['score'] - $x['score'];
}
return strcmp((string) $x['name'], (string) $y['name']);
});
foreach ($rows as $i => &$r) {
$r['rank'] = $i + 1;
}
unset($r);
json_response(['ok' => true, 'rows' => $rows, 'total' => count($rows)]);
}
if ($method === 'POST') {
$body = require_json_body();
$action = (string) ($body['action'] ?? '');
if ($action === 'resetAll') {
$store = read_store();
$n = 0;
foreach (($store['accounts'] ?? []) as $i => $a) {
if ((int) ($a['score'] ?? 0) !== 0) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$n++;
}
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'reset' => $n]);
}
if ($action === 'reset') {
$id = trim((string) ($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach (($store['accounts'] ?? []) as $i => $a) {
if (($a['id'] ?? '') === $id) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$found = true;
break;
}
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'action ไม่ถูกต้อง'], 400);
}
json_response(['ok' => false, 'error' => 'Use GET or POST'], 405);
<?php
declare(strict_types=1);
/**
* แอดมินเท่านั้น — จัดการกระดานผู้นำ (High Score)
* GET → รายชื่อบัญชีเรียงตามคะแนนสะสม (มาก→น้อย)
* POST { action: 'resetAll' } → ล้างคะแนนทุกคนเป็น 0
* POST { action: 'reset', id } → ล้างคะแนนคนเดียวเป็น 0
* (การตั้งคะแนนรายคน ใช้ accounts.php PATCH { id, score })
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('highscore');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$rows = [];
foreach (($store['accounts'] ?? []) as $a) {
$score = max(0, (int) ($a['score'] ?? 0));
$name = trim((string) ($a['lbName'] ?? ''));
if ($name === '') $name = trim((string) ($a['displayName'] ?? ''));
if ($name === '') $name = 'ผู้เล่น';
$rows[] = [
'id' => (string) ($a['id'] ?? ''),
'name' => $name,
'score' => $score,
'coins' => max(0, (int) ($a['coins'] ?? 0)),
'blocked' => !empty($a['blocked']),
'loginType' => (string) ($a['loginType'] ?? ''),
'key' => (string) ($a['providerUserId'] ?? ''),
];
}
usort($rows, function ($x, $y) {
if ($y['score'] !== $x['score']) {
return $y['score'] - $x['score'];
}
return strcmp((string) $x['name'], (string) $y['name']);
});
foreach ($rows as $i => &$r) {
$r['rank'] = $i + 1;
}
unset($r);
json_response(['ok' => true, 'rows' => $rows, 'total' => count($rows)]);
}
if ($method === 'POST') {
$body = require_json_body();
$action = (string) ($body['action'] ?? '');
if ($action === 'resetAll') {
$store = read_store();
$n = 0;
foreach (($store['accounts'] ?? []) as $i => $a) {
if ((int) ($a['score'] ?? 0) !== 0) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$n++;
}
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'reset' => $n]);
}
if ($action === 'reset') {
$id = trim((string) ($body['id'] ?? ''));
if ($id === '') {
json_response(['ok' => false, 'error' => 'ระบุ id'], 400);
}
$store = read_store();
$found = false;
foreach (($store['accounts'] ?? []) as $i => $a) {
if (($a['id'] ?? '') === $id) {
$store['accounts'][$i]['score'] = 0;
$store['accounts'][$i]['updatedAt'] = gmdate('c');
$found = true;
break;
}
}
if (!$found) {
json_response(['ok' => false, 'error' => 'ไม่พบบัญชี'], 404);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'action ไม่ถูกต้อง'], 400);
}
json_response(['ok' => false, 'error' => 'Use GET or POST'], 405);
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* Google OAuth — แลก authorization code เป็นข้อมูลผู้ใช้ แล้วสร้าง/อัปเดตบัญชี loginType=google
*
* POST JSON: { code, redirectUri, state?, mergeGuestKey? }
*/
require __DIR__ . '/_common.php';
require __DIR__ . '/_player_account.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
json_response(['ok' => false, 'error' => 'Use POST'], 405);
}
$body = require_json_body();
$code = trim((string) ($body['code'] ?? ''));
$redirectUri = trim((string) ($body['redirectUri'] ?? ''));
$mergeGuestKey = trim((string) ($body['mergeGuestKey'] ?? ''));
if ($code === '') {
json_response(['ok' => false, 'error' => 'missing code'], 400);
}
$oauth = read_store()['oauth'] ?? [];
$clientId = trim((string) ($oauth['googleClientId'] ?? ''));
$clientSecret = trim((string) ($oauth['googleClientSecret'] ?? ''));
$configuredRedirect = trim((string) ($oauth['googleRedirectUri'] ?? ''));
if ($clientId === '' || $clientSecret === '') {
json_response(['ok' => false, 'error' => 'ยังไม่ได้ตั้งค่า Google OAuth ใน Admin'], 503);
}
if ($redirectUri === '') {
$redirectUri = $configuredRedirect;
}
if ($redirectUri === '') {
json_response(['ok' => false, 'error' => 'missing redirectUri'], 400);
}
if ($configuredRedirect !== '' && $redirectUri !== $configuredRedirect) {
json_response(['ok' => false, 'error' => 'redirectUri ไม่ตรงกับที่ตั้งใน Admin'], 400);
}
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'PHP cURL ไม่พร้อมใช้งาน'], 500);
}
$tokenPayload = http_build_query([
'code' => $code,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => $redirectUri,
'grant_type' => 'authorization_code',
]);
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $tokenPayload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_TIMEOUT => 20,
]);
$tokenRaw = curl_exec($ch);
$tokenHttp = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$tokenJson = json_decode((string) $tokenRaw, true);
if ($tokenHttp < 200 || $tokenHttp >= 300 || !is_array($tokenJson)) {
json_response(['ok' => false, 'error' => 'แลก token จาก Google ไม่สำเร็จ', 'detail' => (string) $tokenRaw], 502);
}
$accessToken = trim((string) ($tokenJson['access_token'] ?? ''));
if ($accessToken === '') {
json_response(['ok' => false, 'error' => 'Google ไม่คืน access_token', 'detail' => $tokenJson], 502);
}
$ch2 = curl_init('https://www.googleapis.com/oauth2/v3/userinfo');
curl_setopt_array($ch2, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
CURLOPT_TIMEOUT => 20,
]);
$userRaw = curl_exec($ch2);
$userHttp = (int) curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
$user = json_decode((string) $userRaw, true);
if ($userHttp < 200 || $userHttp >= 300 || !is_array($user)) {
json_response(['ok' => false, 'error' => 'อ่านข้อมูลผู้ใช้จาก Google ไม่สำเร็จ'], 502);
}
$googleSub = trim((string) ($user['sub'] ?? ''));
if ($googleSub === '') {
json_response(['ok' => false, 'error' => 'Google ไม่คืน sub (user id)'], 502);
}
$playerKey = google_player_key($googleSub);
if (!valid_player_key($playerKey)) {
json_response(['ok' => false, 'error' => 'playerKey จาก Google ไม่ถูกต้อง'], 500);
}
$email = trim((string) ($user['email'] ?? ''));
$name = trim((string) ($user['name'] ?? ''));
if ($name === '') {
$name = trim((string) ($user['given_name'] ?? ''));
}
if ($name === '') {
$name = $email !== '' ? preg_replace('/@.*$/', '', $email) : 'Google Player';
}
if (function_exists('mb_substr')) {
$name = mb_substr($name, 0, 32);
} else {
$name = substr($name, 0, 32);
}
$picture = trim((string) ($user['picture'] ?? ''));
$now = gmdate('c');
$store = read_store();
if (!isset($store['accounts']) || !is_array($store['accounts'])) {
$store['accounts'] = [];
}
$idx = find_account_index_by_player_key($store['accounts'], $playerKey);
$isNew = $idx < 0;
if ($idx < 0) {
$store['accounts'][] = [
'id' => new_id(),
'email' => $email,
'displayName' => $name,
'loginType' => 'google',
'providerUserId' => $playerKey,
'googleSub' => $googleSub,
'avatarUrl' => $picture,
'notes' => 'oauth: google',
'blocked' => false,
'coins' => 0,
'createdAt' => $now,
'updatedAt' => $now,
];
$idx = count($store['accounts']) - 1;
} else {
$store['accounts'][$idx]['loginType'] = 'google';
$store['accounts'][$idx]['email'] = $email !== '' ? $email : ($store['accounts'][$idx]['email'] ?? '');
if ($name !== '') {
$store['accounts'][$idx]['displayName'] = $name;
}
$store['accounts'][$idx]['googleSub'] = $googleSub;
if ($picture !== '') {
$store['accounts'][$idx]['avatarUrl'] = $picture;
}
$store['accounts'][$idx]['updatedAt'] = $now;
}
if (!empty($store['accounts'][$idx]['blocked'])) {
json_response(['ok' => false, 'error' => 'บัญชีนี้ถูกระงับ'], 403);
}
if ($isNew && $mergeGuestKey !== '') {
merge_guest_account_into($store, $idx, $mergeGuestKey);
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกบัญชีไม่สำเร็จ'], 500);
}
$acc = $store['accounts'][$idx] ?? [];
json_response([
'ok' => true,
'playerKey' => $playerKey,
'loginType' => 'google',
'displayName' => (string) ($acc['displayName'] ?? $name),
'email' => (string) ($acc['email'] ?? $email),
'avatarUrl' => (string) ($acc['avatarUrl'] ?? $picture),
'coins' => max(0, (int) ($acc['coins'] ?? 0)),
'accountId' => (string) ($acc['id'] ?? ''),
'isNew' => $isNew,
]);
+34 -33
View File
@@ -1,33 +1,34 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$o = read_store()['oauth'] ?? [];
json_response(['ok' => true, 'oauth' => $o]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'PUT' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['ok' => false, 'error' => 'Use GET or PUT'], 405);
}
$body = require_json_body();
$store = read_store();
$cur = $store['oauth'] ?? [];
$keys = [
'facebookAppId', 'facebookAppSecret', 'facebookRedirectUri',
'googleClientId', 'googleClientSecret', 'googleRedirectUri',
];
foreach ($keys as $k) {
if (array_key_exists($k, $body)) {
$cur[$k] = trim((string)$body[$k]);
}
}
$store['oauth'] = $cur;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'oauth' => $cur]);
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
require_tab('oauth');
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$o = read_store()['oauth'] ?? [];
json_response(['ok' => true, 'oauth' => $o]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'PUT' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['ok' => false, 'error' => 'Use GET or PUT'], 405);
}
$body = require_json_body();
$store = read_store();
$cur = $store['oauth'] ?? [];
$keys = [
'facebookAppId', 'facebookAppSecret', 'facebookRedirectUri',
'googleClientId', 'googleClientSecret', 'googleRedirectUri',
];
foreach ($keys as $k) {
if (array_key_exists($k, $body)) {
$cur[$k] = trim((string)$body[$k]);
}
}
$store['oauth'] = $cur;
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'oauth' => $cur]);
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* Player activity log
*
* Public POST: { action:'track', playerKey, event, displayName?, loginType?, page?, meta? }
* Admin GET : ?action=summary|events|players
* Admin DELETE: ล้าง log ทั้งหมด
*/
require __DIR__ . '/_common.php';
require __DIR__ . '/_player_log.php';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'POST') {
$body = require_json_body();
$action = (string) ($body['action'] ?? 'track');
if ($action !== 'track') {
json_response(['ok' => false, 'error' => 'unknown action'], 400);
}
$playerKey = trim((string) ($body['playerKey'] ?? ''));
$event = trim((string) ($body['event'] ?? ''));
$displayName = trim((string) ($body['displayName'] ?? ''));
$loginType = trim((string) ($body['loginType'] ?? 'guest'));
$page = trim((string) ($body['page'] ?? ''));
$meta = isset($body['meta']) && is_array($body['meta']) ? $body['meta'] : [];
$result = player_log_track($playerKey, $event, $displayName, $loginType, $page, $meta);
if (empty($result['ok'])) {
json_response($result, 400);
}
json_response($result);
}
if ($method === 'GET') {
require_login();
$action = (string) ($_GET['action'] ?? 'summary');
$log = player_log_read();
if ($action === 'summary') {
json_response([
'ok' => true,
'summary' => player_log_public_summary($log),
'eventTypes' => array_keys(player_log_allowed_events()),
]);
}
if ($action === 'players') {
$search = trim((string) ($_GET['search'] ?? ''));
$limit = (int) ($_GET['limit'] ?? 200);
json_response([
'ok' => true,
'players' => player_log_players_list($log, $search, $limit),
]);
}
if ($action === 'events') {
$result = player_log_events_list($log, [
'search' => (string) ($_GET['search'] ?? ''),
'event' => (string) ($_GET['event'] ?? ''),
'playerKey' => (string) ($_GET['playerKey'] ?? ''),
'limit' => (int) ($_GET['limit'] ?? 100),
'offset' => (int) ($_GET['offset'] ?? 0),
]);
json_response([
'ok' => true,
'events' => $result['rows'],
'total' => $result['total'],
]);
}
json_response(['ok' => false, 'error' => 'unknown action'], 400);
}
if ($method === 'DELETE') {
require_login();
if (!player_log_write(player_log_default())) {
json_response(['ok' => false, 'error' => 'ล้าง log ไม่สำเร็จ'], 500);
}
json_response(['ok' => true]);
}
json_response(['ok' => false, 'error' => 'Use GET, POST or DELETE'], 405);
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
/**
* เปิด/ปิด 10 โหมด (ห้อง) ของ Quiz Battle + กำหนดวัน-เวลาที่เล่นได้ถึง [2026-07-22]
*
* อ่าน/เขียน Game/data/quiz-battle-modes.json
* เวลาอ้างอิง = **เวลาไทย (Asia/Bangkok, UTC+7)** เก็บเป็นสตริง 'YYYY-MM-DD HH:MM' ตรงๆ
* (ไม่เก็บ UTC เพื่อให้ค่าที่แอดมินกรอกกับค่าที่เก็บตรงกันเป๊ะ อ่านไฟล์แล้วเข้าใจทันที)
*
* openUntil = '' → ไม่มีวันหมดเขต (เปิดตลอด)
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('quiz-battle');
define('QB_MODES_FILE', dirname(__DIR__, 2) . '/Game/data/quiz-battle-modes.json');
define('QB_ROOM_COUNT', 10);
define('QB_TZ', 'Asia/Bangkok');
define('QB_MAX_PLAYERS_CAP', 50); /* เพดานแข็ง — จำนวนคนต่อห้องปรับได้ไม่เกินนี้ */
/** จำกัดจำนวนคนต่อห้อง 1..50 (ค่าว่าง/เพี้ยน → 50) */
function qb_clamp_max($raw): int
{
$n = (int)$raw;
if ($n < 1) {
return QB_MAX_PLAYERS_CAP;
}
return min(QB_MAX_PLAYERS_CAP, $n);
}
/** ชื่อหัวข้อของแต่ละห้อง (ให้หน้า admin โชว์ — ตรงกับรูป Room-NN.png) */
function qb_default_titles(): array
{
return [
1 => 'กฎหมายใกล้ตัว',
2 => 'กฎหมายสิทธิพื้นฐาน',
3 => 'กฎหมายจราจร',
4 => 'คดีเกี่ยวกับทรัพย์',
5 => 'คดีหมิ่นประมาท',
6 => 'คดีทางเพศ',
7 => 'คดีอาชญากรรม',
8 => 'อาชญากรรมออนไลน์',
9 => 'กระบวนการยุติธรรม',
10 => 'งานบริการกระทรวงยุติธรรม',
];
}
function qb_default_modes(): array
{
$titles = qb_default_titles();
$modes = [];
for ($i = 1; $i <= QB_ROOM_COUNT; $i++) {
$modes[(string)$i] = [
'title' => $titles[$i] ?? ('ห้อง ' . $i),
'enabled' => true,
'openUntil' => '',
'maxPlayers' => QB_MAX_PLAYERS_CAP,
];
}
return $modes;
}
/** ชื่อหัวข้อห้อง — แก้ได้ [2026-07-23] · ตัดช่องว่าง/อักขระควบคุม จำกัด 60 ตัว · ว่าง → คืน default */
function qb_clean_title($raw, string $default): string
{
if (!is_string($raw)) {
return $default;
}
$s = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $raw); // ตัดอักขระควบคุม/ขึ้นบรรทัด
$s = trim(preg_replace('/\s+/u', ' ', $s ?? ''));
if ($s === '') {
return $default;
}
if (function_exists('mb_substr')) {
$s = mb_substr($s, 0, 60, 'UTF-8');
} else {
$s = substr($s, 0, 180);
}
return $s;
}
/** 'YYYY-MM-DD HH:MM' เท่านั้น (เวลาไทย) — ค่าอื่นถือว่าไม่ได้ตั้ง */
function qb_clean_until($raw): string
{
if (!is_string($raw)) {
return '';
}
$s = trim($raw);
if ($s === '') {
return '';
}
$s = str_replace('T', ' ', $s);
if (strlen($s) === 16 && preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s) === 1) {
[$d, $t] = explode(' ', $s, 2);
[$y, $m, $dd] = array_map('intval', explode('-', $d));
if (checkdate($m, $dd, $y)) {
[$hh, $mm] = array_map('intval', explode(':', $t));
if ($hh >= 0 && $hh <= 23 && $mm >= 0 && $mm <= 59) {
return $s;
}
}
}
return '';
}
function qb_read_modes(): array
{
$out = ['version' => 1, 'updatedAt' => '', 'modes' => qb_default_modes()];
$raw = @file_get_contents(QB_MODES_FILE);
if ($raw === false || $raw === '') {
return $out;
}
$j = json_decode($raw, true);
if (!is_array($j) || !isset($j['modes']) || !is_array($j['modes'])) {
return $out;
}
$titles = qb_default_titles();
foreach ($out['modes'] as $k => $def) {
$src = $j['modes'][$k] ?? null;
if (!is_array($src)) {
continue;
}
$out['modes'][$k] = [
/* [2026-07-23] หัวข้อแก้ได้แล้ว — ใช้ค่าที่เก็บไว้ ถ้าว่าง fallback เป็น default ของห้องนั้น */
'title' => qb_clean_title($src['title'] ?? '', $titles[(int)$k] ?? $def['title']),
'enabled' => !isset($src['enabled']) || (bool)$src['enabled'],
'openUntil' => qb_clean_until($src['openUntil'] ?? ''),
'maxPlayers' => qb_clamp_max($src['maxPlayers'] ?? QB_MAX_PLAYERS_CAP),
];
}
$out['updatedAt'] = is_string($j['updatedAt'] ?? null) ? $j['updatedAt'] : '';
return $out;
}
function qb_write_modes(array $data): bool
{
$dir = dirname(QB_MODES_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
$tmp = QB_MODES_FILE . '.tmp';
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false || @file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
/* เขียน tmp แล้ว rename — กันไฟล์พังครึ่งทางถ้า Node อ่านพอดีจังหวะ */
if (!@rename($tmp, QB_MODES_FILE)) {
@unlink($tmp);
return false;
}
@chmod(QB_MODES_FILE, 0664);
return true;
}
/** สถานะที่คำนวณแล้ว ณ เวลาไทยตอนนี้ — ให้หน้า admin โชว์ว่าห้องไหน "ปิดไปแล้ว" */
function qb_state(array $mode): array
{
$tz = new DateTimeZone(QB_TZ);
$now = new DateTimeImmutable('now', $tz);
$until = $mode['openUntil'] ?? '';
$expired = false;
if ($until !== '') {
$end = DateTimeImmutable::createFromFormat('Y-m-d H:i', $until, $tz);
$expired = ($end !== false) && ($now > $end);
}
return [
'open' => (bool)($mode['enabled'] ?? true) && !$expired,
'expired' => $expired,
];
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$data = qb_read_modes();
$tz = new DateTimeZone(QB_TZ);
$now = new DateTimeImmutable('now', $tz);
foreach ($data['modes'] as $k => $m) {
$data['modes'][$k] = array_merge($m, qb_state($m));
}
$data['nowThai'] = $now->format('Y-m-d H:i');
$data['tz'] = QB_TZ;
json_response(['ok' => true] + $data);
}
if ($method === 'PUT' || $method === 'POST') {
$body = require_json_body();
$src = $body['modes'] ?? null;
if (!is_array($src)) {
json_response(['ok' => false, 'error' => 'ต้องส่ง modes มาด้วย'], 400);
}
$cur = qb_read_modes();
$next = $cur['modes'];
foreach ($next as $k => $def) {
$m = $src[$k] ?? null;
if (!is_array($m)) {
continue;
}
$next[$k] = [
/* [2026-07-23] หัวข้อแก้ได้ — sanitize + ถ้าไม่ส่ง/ว่าง คงค่าเดิม (ไม่ reset เป็น default) */
'title' => qb_clean_title($m['title'] ?? ($def['title'] ?? ''), $def['title'] ?? ('ห้อง ' . $k)),
'enabled' => !isset($m['enabled']) || (bool)$m['enabled'],
'openUntil' => qb_clean_until($m['openUntil'] ?? ''),
/* [2026-07-23] จำนวนคนต่อห้อง — ถ้าไม่ส่งมา คงค่าเดิมไว้ (ไม่ reset เป็น 50) */
'maxPlayers' => qb_clamp_max($m['maxPlayers'] ?? ($def['maxPlayers'] ?? QB_MAX_PLAYERS_CAP)),
];
}
$me = current_admin();
$payload = [
'version' => 1,
'updatedAt' => (new DateTimeImmutable('now', new DateTimeZone(QB_TZ)))->format('Y-m-d H:i'),
'updatedBy' => $me['username'] ?? '',
'tz' => QB_TZ,
'modes' => $next,
];
if (!qb_write_modes($payload)) {
json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ (สิทธิ์เขียนไฟล์?)'], 500);
}
foreach ($payload['modes'] as $k => $m) {
$payload['modes'][$k] = array_merge($m, qb_state($m));
}
json_response(['ok' => true, 'message' => 'บันทึกแล้ว'] + $payload);
}
json_response(['ok' => false, 'error' => 'Use GET / PUT'], 405);
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
/**
* คัดกรองการสร้างห้องเกม — Admin proxy ไป Node + เก็บประวัติอนุมัติ/ปฏิเสธ
*/
require __DIR__ . '/_common.php';
require_login();
if (!function_exists('curl_init')) {
json_response(['ok' => false, 'error' => 'ต้องเปิด PHP extension curl'], 500);
}
function room_moderation_disk_path(): string
{
return dirname(__DIR__, 2) . '/Game/data/room-moderation.json';
}
function room_approval_history_path(): string
{
return ADMIN_PRIVATE_DIR . '/room-approval-history.json';
}
function sync_room_moderation_disk(bool $enabled): void
{
$path = room_moderation_disk_path();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$json = json_encode(['enabled' => $enabled], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json !== false) {
@file_put_contents($path, $json, LOCK_EX);
}
}
function read_room_approval_history(): array
{
$path = room_approval_history_path();
if (!is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
$j = json_decode($raw ?: '[]', true);
return is_array($j) ? $j : [];
}
function append_room_approval_history(array $row): void
{
$rows = read_room_approval_history();
array_unshift($rows, $row);
if (count($rows) > 500) {
$rows = array_slice($rows, 0, 500);
}
if (!is_dir(ADMIN_PRIVATE_DIR)) {
@mkdir(ADMIN_PRIVATE_DIR, 0750, true);
}
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json !== false) {
@file_put_contents(room_approval_history_path(), $json, LOCK_EX);
}
}
function node_room_approvals_url(string $suffix = ''): string
{
$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '13010')) ?: '13010';
$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1';
return 'http://' . $host . ':' . $port . '/Game/api/admin/room-approvals' . $suffix;
}
function proxy_node_room_approvals(string $method, ?string $body = null, string $suffix = ''): array
{
$ch = curl_init(node_room_approvals_url($suffix));
if ($ch === false) {
return ['ok' => false, 'error' => 'curl init failed', 'httpCode' => 0];
}
$headers = ['Accept: application/json'];
if ($body !== null && $method !== 'GET') {
$headers[] = 'Content-Type: application/json';
}
$opts = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 12,
];
if ($body !== null && $method !== 'GET') {
$opts[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($ch, $opts);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($out === false || $out === '') {
return ['ok' => false, 'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node): ' . $err, 'httpCode' => $code];
}
$j = json_decode($out, true);
if (!is_array($j)) {
return ['ok' => false, 'error' => 'ตอบกลับจาก Node ไม่ถูกต้อง', 'httpCode' => $code];
}
$j['httpCode'] = $code;
return $j;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$admin = current_admin();
if ($method === 'GET') {
$action = isset($_GET['action']) ? (string)$_GET['action'] : 'pending';
$store = read_store();
$enabled = !empty($store['roomModeration']['enabled']);
sync_room_moderation_disk($enabled);
if ($action === 'settings') {
json_response([
'ok' => true,
'enabled' => $enabled,
]);
}
if ($action === 'history') {
json_response([
'ok' => true,
'history' => read_room_approval_history(),
'enabled' => $enabled,
]);
}
$node = proxy_node_room_approvals('GET');
if (empty($node['ok'])) {
json_response([
'ok' => true,
'enabled' => $enabled,
'pending' => [],
'nodeError' => $node['error'] ?? 'Node unavailable',
]);
}
json_response([
'ok' => true,
'enabled' => $enabled,
'nodeModerationEnabled' => !empty($node['moderationEnabled']),
'pending' => $node['pending'] ?? [],
]);
}
if ($method === 'PATCH') {
$raw = file_get_contents('php://input') ?: '{}';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_response(['ok' => false, 'error' => 'ข้อมูลไม่ถูกต้อง'], 400);
}
$store = read_store();
if (!isset($store['roomModeration']) || !is_array($store['roomModeration'])) {
$store['roomModeration'] = ['enabled' => true];
}
if (array_key_exists('enabled', $body)) {
$store['roomModeration']['enabled'] = !!$body['enabled'];
}
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกการตั้งค่าไม่สำเร็จ'], 500);
}
sync_room_moderation_disk(!empty($store['roomModeration']['enabled']));
json_response(['ok' => true, 'enabled' => !empty($store['roomModeration']['enabled'])]);
}
if ($method === 'POST') {
$raw = file_get_contents('php://input') ?: '{}';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_response(['ok' => false, 'error' => 'ข้อมูลไม่ถูกต้อง'], 400);
}
$spaceId = isset($body['spaceId']) ? trim((string)$body['spaceId']) : '';
$act = isset($body['action']) ? trim((string)$body['action']) : '';
$note = isset($body['note']) ? trim((string)$body['note']) : '';
if ($spaceId === '' || !in_array($act, ['approve', 'reject', 'dismiss'], true)) {
json_response(['ok' => false, 'error' => 'ต้องระบุ spaceId และ action (reject|dismiss)'], 400);
}
$meta = isset($body['meta']) && is_array($body['meta']) ? $body['meta'] : [];
$payload = json_encode([
'spaceId' => $spaceId,
'action' => $act,
'note' => $note,
], JSON_UNESCAPED_UNICODE);
$result = proxy_node_room_approvals('POST', $payload, '');
if (empty($result['ok'])) {
json_response(['ok' => false, 'error' => $result['error'] ?? 'ดำเนินการไม่สำเร็จ'], !empty($result['httpCode']) && $result['httpCode'] >= 400 ? (int)$result['httpCode'] : 400);
}
append_room_approval_history([
'at' => gmdate('c'),
'action' => $act,
'spaceId' => $spaceId,
'spaceName' => $meta['spaceName'] ?? $spaceId,
'creatorDisplayName' => $meta['creatorDisplayName'] ?? '',
'creatorPlayerKey' => $meta['creatorPlayerKey'] ?? '',
'adminUsername' => is_array($admin) ? ($admin['username'] ?? '') : '',
'note' => $note,
'kicked' => isset($result['kicked']) ? (int)$result['kicked'] : null,
]);
json_response([
'ok' => true,
'action' => $act,
'spaceId' => $spaceId,
'kicked' => $result['kicked'] ?? null,
]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* ระดับเสียง Master / Music / SFX — อ่าน/เขียน Game/data/sound-settings.json
* ใช้ session Admin หลัก (ไม่ต้องล็อกอิน game_ai_admin แยก)
*
* GET -> { masterVol, musicVol, sfxVol } ค่า 0..1
* PUT -> body JSON เดียวกัน → บันทึก
*/
require __DIR__ . '/_common.php';
require_login();
require_tab('sound');
define('SOUND_SETTINGS_FILE', dirname(__DIR__, 2) . '/Game/data/sound-settings.json');
function sound_defaults(): array
{
return ['masterVol' => 1.0, 'musicVol' => 0.35, 'sfxVol' => 0.75];
}
function sound_clamp($v, float $default): float
{
$n = is_numeric($v) ? (float) $v : $default;
if (!is_finite($n)) {
return $default;
}
return max(0.0, min(1.0, $n));
}
function sound_read(): array
{
$base = sound_defaults();
if (!is_file(SOUND_SETTINGS_FILE)) {
return $base;
}
$raw = @file_get_contents(SOUND_SETTINGS_FILE);
$j = json_decode($raw ?: '{}', true);
if (!is_array($j)) {
return $base;
}
return [
'masterVol' => sound_clamp($j['masterVol'] ?? null, $base['masterVol']),
'musicVol' => sound_clamp($j['musicVol'] ?? null, $base['musicVol']),
'sfxVol' => sound_clamp($j['sfxVol'] ?? null, $base['sfxVol']),
];
}
function sound_write(array $s): bool
{
$dir = dirname(SOUND_SETTINGS_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return false;
}
$json = json_encode($s, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) {
return false;
}
$tmp = SOUND_SETTINGS_FILE . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
if (!rename($tmp, SOUND_SETTINGS_FILE)) {
@unlink($tmp);
return false;
}
return true;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
json_response(sound_read());
}
if ($method === 'PUT' || $method === 'POST') {
$body = require_json_body();
$cur = sound_read();
$next = [
'masterVol' => array_key_exists('masterVol', $body) ? sound_clamp($body['masterVol'], $cur['masterVol']) : $cur['masterVol'],
'musicVol' => array_key_exists('musicVol', $body) ? sound_clamp($body['musicVol'], $cur['musicVol']) : $cur['musicVol'],
'sfxVol' => array_key_exists('sfxVol', $body) ? sound_clamp($body['sfxVol'], $cur['sfxVol']) : $cur['sfxVol'],
];
if (!sound_write($next)) {
json_response(['ok' => false, 'error' => 'บันทึก sound-settings.json ไม่สำเร็จ'], 500);
}
json_response(['ok' => true, 'settings' => $next]);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require __DIR__ . '/_common.php';
require_login();
const ROOM_RETENTION_MIN_DAYS = 3;
const ROOM_RETENTION_MAX_DAYS = 7;
const GAME_SYSTEM_SETTINGS = __DIR__ . '/../../Game/data/system-settings.json';
function clamp_room_retention_days($value): int
{
$n = (int)$value;
if ($n < ROOM_RETENTION_MIN_DAYS) {
return ROOM_RETENTION_MIN_DAYS;
}
if ($n > ROOM_RETENTION_MAX_DAYS) {
return ROOM_RETENTION_MAX_DAYS;
}
return $n;
}
function system_settings_payload(array $store): array
{
$settings = $store['systemSettings'] ?? [];
return [
'roomRetentionDays' => clamp_room_retention_days($settings['roomRetentionDays'] ?? ROOM_RETENTION_MIN_DAYS),
'minRoomRetentionDays' => ROOM_RETENTION_MIN_DAYS,
'maxRoomRetentionDays' => ROOM_RETENTION_MAX_DAYS,
];
}
function sync_game_system_settings(array $payload): bool
{
$dir = dirname(GAME_SYSTEM_SETTINGS);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return false;
}
$data = [
'roomRetentionDays' => $payload['roomRetentionDays'],
'updatedAt' => gmdate('c'),
];
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
return $json !== false && file_put_contents(GAME_SYSTEM_SETTINGS, $json, LOCK_EX) !== false;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET') {
$store = read_store();
$payload = system_settings_payload($store);
sync_game_system_settings($payload);
json_response(['ok' => true] + $payload);
}
if ($method === 'PATCH' || $method === 'PUT' || $method === 'POST') {
$body = require_json_body();
if (!array_key_exists('roomRetentionDays', $body)) {
json_response(['ok' => false, 'error' => 'กรุณาระบุจำนวนวันจัดเก็บห้อง'], 400);
}
$store = read_store();
$store['systemSettings'] = $store['systemSettings'] ?? [];
$store['systemSettings']['roomRetentionDays'] = clamp_room_retention_days($body['roomRetentionDays']);
if (!write_store($store)) {
json_response(['ok' => false, 'error' => 'บันทึกการตั้งค่าไม่สำเร็จ'], 500);
}
$payload = system_settings_payload($store);
if (!sync_game_system_settings($payload)) {
json_response(['ok' => false, 'error' => 'บันทึกค่าไปยัง Game/data ไม่สำเร็จ'], 500);
}
json_response(['ok' => true] + $payload);
}
json_response(['ok' => false, 'error' => 'Method not allowed'], 405);
+1492 -1432
View File
File diff suppressed because it is too large Load Diff
+315 -3
View File
@@ -1511,7 +1511,7 @@
"blocked": false,
"coins": 95,
"createdAt": "2026-07-08T05:26:41+00:00",
"updatedAt": "2026-07-12T07:34:23+00:00",
"updatedAt": "2026-07-24T06:43:56+00:00",
"daily": {
"anchorMs": 1783530000000,
"claimedDays": [
@@ -1523,7 +1523,7 @@
false,
false
],
"lockUntilMs": 1783875600000
"lockUntilMs": 0
},
"lobbyColorThemeIndex": 5,
"lobbySkinToneIndex": 1,
@@ -1766,7 +1766,9 @@
"blocked": false,
"coins": 0,
"createdAt": "2026-07-08T12:09:31+00:00",
"updatedAt": "2026-07-08T12:09:31+00:00"
"updatedAt": "2026-07-24T06:58:25+00:00",
"lobbyColorThemeIndex": 1,
"lobbySkinToneIndex": 1
},
{
"id": "5a94f9199f1412978b1aee8a",
@@ -2550,6 +2552,316 @@
"coins": 0,
"createdAt": "2026-07-22T10:30:33+00:00",
"updatedAt": "2026-07-22T10:30:33+00:00"
},
{
"id": "8657a2946b21496af9ccdea3",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1784801056518_7957fwa372e",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-23T10:04:16+00:00",
"updatedAt": "2026-07-23T10:04:16+00:00"
},
{
"id": "f024cf64e179b05a77477135",
"email": "",
"displayName": "ผู้เล่น",
"loginType": "guest",
"providerUserId": "p_1785154354326_2awlhldhd43",
"notes": "auto: player-coins",
"blocked": false,
"coins": 70,
"createdAt": "2026-07-27T12:12:35+00:00",
"updatedAt": "2026-07-27T12:46:20+00:00",
"daily": {
"anchorMs": 1785085200000,
"claimedDays": [
true,
false,
false,
false,
false,
false,
false
],
"lockUntilMs": 1785171600000
},
"lobbyColorThemeIndex": 1,
"lobbySkinToneIndex": 2,
"achievements": {
"d1_minigame_solver": 6,
"b4_data_miner": 4,
"b1_evidence_collector": 1,
"a2_sharp_eye": 3
},
"score": 60,
"scoreByCase": {
"1": 60
},
"lbName": "ผู้เล่น"
},
{
"id": "e0dee6ff0f043ae1185cfb75",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1785155025679_2e0ebuyh4jf",
"notes": "auto: player-coins",
"blocked": false,
"coins": 10,
"createdAt": "2026-07-27T12:23:46+00:00",
"updatedAt": "2026-07-27T12:23:57+00:00",
"daily": {
"anchorMs": 1785085200000,
"claimedDays": [
true,
false,
false,
false,
false,
false,
false
],
"lockUntilMs": 1785171600000
}
},
{
"id": "d8112edcb8843332822ffff5",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1785440715860_m68pkewoqqj",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-30T19:45:14+00:00",
"updatedAt": "2026-07-30T19:45:14+00:00"
},
{
"id": "de6d3738a5488613f71cccd6",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1785440746710_4wt8vub7giu",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-30T19:45:45+00:00",
"updatedAt": "2026-07-30T19:46:23+00:00",
"achievements": {
"d2_silent_guardian": 1,
"d4_flawless_diver": 0
},
"achievementStreaks": {
"d4_flawless_diver": 0
}
},
{
"id": "1b190c9ddb1b40ac663bb4da",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1785440759059_1e4otuhtc83",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-30T19:45:58+00:00",
"updatedAt": "2026-07-30T19:45:58+00:00"
},
{
"id": "4b3f3099f6b09472c43d2004",
"email": "",
"displayName": "Guest",
"loginType": "guest",
"providerUserId": "p_1785441060100_8gmc11h9ok",
"notes": "auto: player-coins",
"blocked": false,
"coins": 0,
"createdAt": "2026-07-30T19:50:59+00:00",
"updatedAt": "2026-07-30T19:50:59+00:00"
},
{
"id": "3919a0a09796c8373f5c65f3",
"email": "",
"displayName": "S1",
"loginType": "guest",
"providerUserId": "sk0-6hxlcur",
"notes": "auto: game-award",
"blocked": false,
"coins": 90,
"score": 90,
"scoreByCase": {
"1": 90
},
"lbName": "S1",
"createdAt": "2026-07-31T02:58:20+00:00",
"updatedAt": "2026-07-31T03:06:42+00:00",
"achievements": {
"d1_minigame_solver": 9,
"b4_data_miner": 9,
"b1_evidence_collector": 8,
"a2_sharp_eye": 1,
"a1_first_deduction": 1,
"a5_truth_hunter": 1,
"a4_logic_over_luck": 1,
"c3_quick_draw": 1,
"b2_relentless_investigator": 1,
"d2_silent_guardian": 1,
"e1_the_observer": 1,
"a6_unbreakable_logic": 1,
"d4_flawless_diver": 1
},
"achievementStreaks": {
"e1_the_observer": 1,
"a6_unbreakable_logic": 1,
"d4_flawless_diver": 1
}
},
{
"id": "cb287a9acc916834310c3445",
"email": "",
"displayName": "S1",
"loginType": "guest",
"providerUserId": "sk0-dyhhlv1",
"notes": "auto: game-award",
"blocked": false,
"coins": 90,
"score": 90,
"scoreByCase": {
"1": 90
},
"lbName": "S1",
"createdAt": "2026-07-31T03:08:00+00:00",
"updatedAt": "2026-07-31T03:19:43+00:00",
"achievements": {
"d1_minigame_solver": 9,
"b4_data_miner": 9,
"b1_evidence_collector": 7,
"a2_sharp_eye": 2,
"b2_relentless_investigator": 1,
"d2_silent_guardian": 1,
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
},
"achievementStreaks": {
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
}
},
{
"id": "594be80630b54845b783608e",
"email": "",
"displayName": "S1",
"loginType": "guest",
"providerUserId": "sk0-yutf5bk",
"notes": "auto: game-award",
"blocked": false,
"coins": 90,
"score": 90,
"scoreByCase": {
"1": 90
},
"lbName": "S1",
"createdAt": "2026-07-31T03:21:04+00:00",
"updatedAt": "2026-07-31T03:31:06+00:00",
"achievements": {
"d1_minigame_solver": 9,
"b4_data_miner": 9,
"b1_evidence_collector": 9,
"b2_relentless_investigator": 1,
"d2_silent_guardian": 1,
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
},
"achievementStreaks": {
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
}
},
{
"id": "8da4977bb7d712cb20b5b465",
"email": "",
"displayName": "S2",
"loginType": "guest",
"providerUserId": "sk1-ioqnz6b",
"notes": "auto: game-award",
"blocked": false,
"coins": 72,
"score": 72,
"scoreByCase": {
"1": 72
},
"lbName": "S2",
"createdAt": "2026-07-31T03:21:04+00:00",
"updatedAt": "2026-07-31T03:31:06+00:00",
"achievements": {
"d1_minigame_solver": 9,
"b4_data_miner": 9,
"b1_evidence_collector": 8,
"a2_sharp_eye": 1,
"b2_relentless_investigator": 1,
"d2_silent_guardian": 1,
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
},
"achievementStreaks": {
"e1_the_observer": 1,
"a6_unbreakable_logic": 0,
"d4_flawless_diver": 0
}
},
{
"id": "f7c654b294b62cc3e32dc1b1",
"email": "",
"displayName": "S1",
"loginType": "guest",
"providerUserId": "sk0-egisog4",
"notes": "auto: achievements",
"blocked": false,
"coins": 40,
"achievements": {
"d1_minigame_solver": 5,
"b4_data_miner": 4,
"b1_evidence_collector": 4
},
"createdAt": "2026-07-31T03:31:51+00:00",
"updatedAt": "2026-07-31T03:36:43+00:00",
"score": 40,
"scoreByCase": {
"1": 40
},
"lbName": "S1"
},
{
"id": "5e6ee98b463feaaefc44f396",
"email": "",
"displayName": "S2",
"loginType": "guest",
"providerUserId": "sk1-0tsp4wv",
"notes": "auto: achievements",
"blocked": false,
"coins": 32,
"achievements": {
"a2_sharp_eye": 1,
"d1_minigame_solver": 3,
"b1_evidence_collector": 4,
"b4_data_miner": 3
},
"createdAt": "2026-07-31T03:31:51+00:00",
"updatedAt": "2026-07-31T03:36:43+00:00",
"score": 32,
"scoreByCase": {
"1": 32
},
"lbName": "S2"
}
]
}
+5 -2
View File
@@ -1,4 +1,7 @@
{
"openai_api_key": "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",
"model": "gpt-4-turbo"
"openai_api_key": "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",
"model": "gpt-4-turbo",
"intent": "",
"rag_enabled": false,
"rag_context": ""
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
{"qbroom1":[{"name":"Nam","score":18,"characterId":"char-1777017632279","ts":1782909257585},{"name":"ผู้เล่น9514","score":17,"characterId":"char-1777017632279","ts":1781850667717},{"name":"MONE","score":10,"characterId":"char-1777017632279","ts":1781850749437},{"name":"tomato","score":9,"characterId":"char-1777017632279","ts":1781850748552},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781171902962},{"name":"ผู้เล่น1991","score":5,"characterId":"char-1777017632279","ts":1781850950078},{"name":"ผู้เล่น8916","score":4,"characterId":"char-1777017632279","ts":1781074083031},{"name":"ผู้เล่น2821","score":4,"characterId":"char-1777017632279","ts":1781079179108},{"name":"Weenwild","score":4,"characterId":"char-1777017632279","ts":1781849863580},{"name":"ผู้เล่น2359","score":0,"characterId":"char-1777017632279","ts":1783664802662},{"name":"ผู้เล่น1576","score":0,"characterId":"char-1777017632279","ts":1784258335514}],"qbroom2":[{"name":"ผู้เล่น5703","score":14,"characterId":"char-1777017632279","ts":1784258779407},{"name":"ผู้เล่น4472","score":13,"characterId":"char-1777017632279","ts":1781851033914},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781175468983},{"name":"ผู้เล่น2612","score":1,"characterId":"char-1777017632279","ts":1781079252376},{"name":"ผู้เล่น1948","score":0,"characterId":"char-1777017632279","ts":1783664954451}],"qbroom3":[{"name":"Q","score":9,"characterId":"char-1777017632279","ts":1781175654893},{"name":"Weenwild","score":2,"characterId":"char-1777017632279","ts":1781849898452},{"name":"ผู้เล่น8622","score":0,"characterId":"char-1777017632279","ts":1783842317274}],"qbroom5":[{"name":"Q","score":6,"characterId":"char-1777017632279","ts":1781675299850}],"qbroom6":[{"name":"Q","score":3,"characterId":"char-1777017632279","ts":1781675383175},{"name":"ผู้เล่น1752","score":1,"characterId":"char-1777017632279","ts":1784273155119}],"qbroom9":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781696345322}],"qbroom4":[{"name":"ผู้เล่น9337","score":20,"characterId":"char-1777017632279","ts":1784270128376}],"qbroom7":[{"name":"ผู้เล่น4119","score":3,"characterId":"char-1777017632279","ts":1784270217530}]}
{"qbroom1":[{"name":"Nam","score":18,"characterId":"char-1777017632279","ts":1782909257585},{"name":"ผู้เล่น9514","score":17,"characterId":"char-1777017632279","ts":1781850667717},{"name":"MONE","score":10,"characterId":"char-1777017632279","ts":1781850749437},{"name":"tomato","score":9,"characterId":"char-1777017632279","ts":1781850748552},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781171902962},{"name":"ผู้เล่น1991","score":5,"characterId":"char-1777017632279","ts":1781850950078},{"name":"ผู้เล่น8916","score":4,"characterId":"char-1777017632279","ts":1781074083031},{"name":"ผู้เล่น2821","score":4,"characterId":"char-1777017632279","ts":1781079179108},{"name":"Weenwild","score":4,"characterId":"char-1777017632279","ts":1781849863580},{"name":"play1","score":1,"characterId":"char-1777017632279","ts":1785441076910},{"name":"ผู้เล่น2359","score":0,"characterId":"char-1777017632279","ts":1783664802662},{"name":"ผู้เล่น1576","score":0,"characterId":"char-1777017632279","ts":1784258335514}],"qbroom2":[{"name":"ผู้เล่น5703","score":14,"characterId":"char-1777017632279","ts":1784258779407},{"name":"ผู้เล่น4472","score":13,"characterId":"char-1777017632279","ts":1781851033914},{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781175468983},{"name":"ผู้เล่น2612","score":1,"characterId":"char-1777017632279","ts":1781079252376},{"name":"play2","score":1,"characterId":"char-1777017632279","ts":1785440817269},{"name":"ผู้เล่น1948","score":0,"characterId":"char-1777017632279","ts":1783664954451}],"qbroom3":[{"name":"Q","score":9,"characterId":"char-1777017632279","ts":1781175654893},{"name":"Weenwild","score":2,"characterId":"char-1777017632279","ts":1781849898452},{"name":"play3","score":1,"characterId":"char-1777017632279","ts":1785441122522},{"name":"ผู้เล่น8622","score":0,"characterId":"char-1777017632279","ts":1783842317274}],"qbroom5":[{"name":"Q","score":6,"characterId":"char-1777017632279","ts":1781675299850},{"name":"play5","score":1,"characterId":"char-1777017632279","ts":1785440912224}],"qbroom6":[{"name":"Q","score":3,"characterId":"char-1777017632279","ts":1781675383175},{"name":"ผู้เล่น1752","score":1,"characterId":"char-1777017632279","ts":1784273155119},{"name":"play6","score":1,"characterId":"char-1777017632279","ts":1785441196130}],"qbroom9":[{"name":"Q","score":7,"characterId":"char-1777017632279","ts":1781696345322},{"name":"play9","score":1,"characterId":"char-1777017632279","ts":1785441009369}],"qbroom4":[{"name":"ผู้เล่น9337","score":20,"characterId":"char-1777017632279","ts":1784270128376},{"name":"play4","score":1,"characterId":"char-1777017632279","ts":1785440881506}],"qbroom7":[{"name":"Q","score":4,"characterId":"char-1777017632279","ts":1784785097780},{"name":"ผู้เล่น4119","score":3,"characterId":"char-1777017632279","ts":1784270217530},{"name":"play7","score":1,"characterId":"char-1777017632279","ts":1785440972161}],"qbroom8":[{"name":"play8","score":1,"characterId":"char-1777017632279","ts":1785440990472}],"qbroom10":[{"name":"play10","score":1,"characterId":"char-1777017632279","ts":1785441027840}]}
+68
View File
@@ -0,0 +1,68 @@
{
"version": 1,
"updatedAt": "2026-07-27 00:17",
"updatedBy": "admin",
"tz": "Asia/Bangkok",
"modes": {
"1": {
"title": "กฎหมายใกล้ตัวสิ",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"2": {
"title": "กฎหมายสิทธิพื้นฐาน",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"3": {
"title": "กฎหมายจราจร",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"4": {
"title": "คดีเกี่ยวกับทรัพย์",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"5": {
"title": "คดีหมิ่นประมาท",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"6": {
"title": "คดีทางเพศ",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"7": {
"title": "คดีอาชญากรรม",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"8": {
"title": "อาชญากรรมออนไลน์",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"9": {
"title": "กระบวนการยุติธรรม",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
},
"10": {
"title": "งานบริการกระทรวงยุติธรรม",
"enabled": true,
"openUntil": "",
"maxPlayers": 50
}
}
}
+20 -3
View File
@@ -56,9 +56,10 @@
<div id="settings-tab" class="tab-content active">
<h2 style="font-size:1.1rem;margin-bottom:1rem;color:#c0caf5;">การตั้งค่าระบบ</h2>
<div class="form-group">
<label for="openai-key">OpenAI API Key</label>
<input type="password" id="openai-key" name="openai_api_key" placeholder="sk-..." autocomplete="off">
<small>เว้นว่างถ้าไม่ต้องการเปลี่ยน</small>
<label for="openai-key">API Key (OpenAI หรือ OpenRouter)</label>
<input type="password" id="openai-key" name="openai_api_key" placeholder="sk-... หรือ sk-or-v1-..." autocomplete="off">
<small id="key-status" style="color:#a9b1d6;">กำลังตรวจสอบ…</small>
<small>เว้นว่างถ้าไม่ต้องการเปลี่ยน · คีย์ที่ขึ้นต้น <code>sk-or-</code> ระบบจะส่งไป OpenRouter ให้อัตโนมัติ</small>
</div>
<div class="form-group">
<label for="openai-model">Model</label>
@@ -156,9 +157,24 @@
document.getElementById('rag-enabled').checked = !!data.rag_enabled;
document.getElementById('rag-context').value = data.rag_context != null ? data.rag_context : '';
document.getElementById('rag-context-wrap').style.opacity = data.rag_enabled ? '1' : '0.7';
showKeyStatus(data);
});
}
/* [2026-07-23] เดิมช่องคีย์ถูกล้างทุกครั้งหลังเซฟ และ hasKey ที่ API ส่งมาไม่เคยถูกแสดง
→ ผู้ใช้ไม่มีทางรู้ว่าคีย์ถูกเก็บไว้จริงไหม = "กดเซฟแล้วมันไม่เซฟ" */
function showKeyStatus(data) {
var el = document.getElementById('key-status');
if (!el || !data) return;
if (data.hasKey) {
el.style.color = '#9ece6a';
el.textContent = '✓ มีคีย์บันทึกอยู่แล้ว: ' + (data.keyMasked || '') + ' → ใช้ ' + (data.provider || '?');
} else {
el.style.color = '#f7768e';
el.textContent = '✗ ยังไม่มีคีย์ในระบบ';
}
}
document.getElementById('rag-enabled').addEventListener('change', function () {
document.getElementById('rag-context-wrap').style.opacity = this.checked ? '1' : '0.7';
});
@@ -204,6 +220,7 @@
msgEl.textContent = 'บันทึกแล้ว';
msgEl.style.display = 'block';
document.getElementById('openai-key').value = '';
showKeyStatus(data); /* ยืนยันทันทีว่าคีย์ที่เก็บอยู่คือใบไหน/เจ้าไหน */
} else {
errEl.textContent = (data && data.error) || 'บันทึกไม่สำเร็จ';
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+28015 -27892
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+64 -48
View File
@@ -4965,39 +4965,39 @@
<div id="gauntlet-name-overlay" aria-hidden="true"></div>
</div>
<button type="button" id="quiz-carry-grab-btn" class="is-hidden" aria-label="หยิบหรือส่งคำตอบ (Grab)" title="หยิบ / ส่งคำตอบ — เหมือนกด F">
<img src="/Game/img/quiz-carry/btn-grab.png" alt="" width="256" height="256" decoding="async" />
<img src="/Game/img/quiz-carry/btn-grab.png" alt="" width="256" height="256" decoding="async" / loading="lazy">
</button>
<button type="button" id="gauntlet-crown-jump-btn" class="is-hidden" aria-label="กระโดด (Jump)" title="กระโดด — เหมือน Space / W / ↑" aria-hidden="true">
<img src="/Game/img/gauntlet-assets/btn-jump.png" alt="" width="256" height="256" decoding="async" />
<img src="/Game/img/gauntlet-assets/btn-jump.png" alt="" width="256" height="256" decoding="async" / loading="lazy">
</button>
<button type="button" id="stack-tower-drop-btn" class="is-hidden" aria-label="ปล่อยบล็อก (Drop)" title="ปล่อยบล็อก — เหมือน Space / Enter" aria-hidden="true">
<img src="/Game/img/TowerBlock/btn-drop.png" alt="" width="256" height="256" decoding="async" />
<img src="/Game/img/TowerBlock/btn-drop.png" alt="" width="256" height="256" decoding="async" / loading="lazy">
</button>
<div id="quiz-map-question-panel" class="is-hidden" aria-hidden="true">
<img id="quiz-map-q-feedback-icon" class="quiz-map-q-feedback-icon is-hidden" alt="" width="64" height="64" decoding="async" />
<img id="quiz-map-q-feedback-icon" class="quiz-map-q-feedback-icon is-hidden" alt="" width="64" height="64" decoding="async" / loading="lazy">
<div id="quiz-map-question-kicker" class="quiz-map-question-kicker is-hidden" aria-hidden="true"></div>
<p id="quiz-map-question-text"></p>
<div id="quiz-map-question-subphase" class="quiz-map-question-subphase is-hidden" aria-hidden="true"></div>
<div id="quiz-map-q-feedback-score-wrap" class="is-hidden" aria-hidden="true">
<img id="quiz-map-q-feedback-score" alt="" width="120" height="48" decoding="async" />
<img id="quiz-map-q-feedback-score" alt="" width="120" height="48" decoding="async" / loading="lazy">
</div>
</div>
<div id="quiz-carry-timeup-desk-layer" class="is-hidden" aria-hidden="true">
<div class="qc-timeup-dim" aria-hidden="true"></div>
<div class="qc-timeup-desk-anchor">
<img id="quiz-carry-timeup-txt-img" class="qc-timeup-txt-img" src="/Game/img/quiz-carry/timeup-txt.png" alt="" width="480" height="120" decoding="async" />
<img id="quiz-carry-timeup-txt-img" class="qc-timeup-txt-img" src="/Game/img/quiz-carry/timeup-txt.png" alt="" width="480" height="120" decoding="async" / loading="lazy">
</div>
</div>
<div id="quiz-carry-result-end-layer" class="is-hidden" aria-hidden="true" role="img" aria-label="ผลจบภารกิจ">
<div class="qc-result-end-dim" aria-hidden="true"></div>
<img id="quiz-carry-result-end-img" class="qc-result-end-img" src="" alt="" width="900" height="520" decoding="async" />
<img id="quiz-carry-result-end-img" class="qc-result-end-img" src="" alt="" width="900" height="520" decoding="async" / loading="lazy">
</div>
</div>
<div id="play-cyber-hud" class="play-cyber-hud is-hidden" aria-hidden="true">
<div class="play-cyber-hud-mock-stage" aria-hidden="true">
<aside class="play-cyber-scoreboard" aria-label="SCORE">
<div id="play-cyber-crown-score-head" class="play-cyber-crown-score-head is-hidden" aria-hidden="true">
<img id="play-cyber-crown-score-img" class="mg2-score-label" src="/Game/img/gauntlet-assets/score.png" alt="SCORE :" width="144" height="71" decoding="async" />
<img id="play-cyber-crown-score-img" class="mg2-score-label" src="/Game/img/gauntlet-assets/score.png" alt="SCORE :" width="144" height="71" decoding="async" / loading="lazy">
</div>
<div class="play-cyber-panel-title">SCORE</div>
<ul id="play-cyber-score-list" class="play-cyber-score-list"></ul>
@@ -5005,7 +5005,7 @@
<div class="play-cyber-center-stack">
<div class="play-cyber-time-block">
<div class="play-cyber-time-head">
<img id="play-cyber-time-plaque-img" class="play-cyber-time-plaque-img is-hidden" alt="TIME" decoding="async" aria-hidden="true" />
<img id="play-cyber-time-plaque-img" class="play-cyber-time-plaque-img is-hidden" alt="TIME" decoding="async" aria-hidden="true" / loading="lazy">
<div class="play-cyber-time-label">TIME</div>
<span class="play-cyber-time-colon" aria-hidden="true">:</span>
<div id="play-cyber-time-val" class="play-cyber-time-val">0</div>
@@ -5014,7 +5014,7 @@
<div id="play-cyber-embed-zoom-hint" class="play-cyber-embed-zoom-hint is-hidden" aria-hidden="true"></div>
</div>
<div id="play-cyber-quiz-mission-q-band" class="play-cyber-quiz-mission-q-band is-hidden" aria-hidden="true">
<img id="play-cyber-quiz-mission-q-plaque" class="play-cyber-quiz-mission-q-plaque is-hidden" alt="" decoding="async" />
<img id="play-cyber-quiz-mission-q-plaque" class="play-cyber-quiz-mission-q-plaque is-hidden" alt="" decoding="async" / loading="lazy">
<p id="play-cyber-quiz-mission-q-text" class="play-cyber-quiz-mission-q-text"></p>
</div>
</div>
@@ -5022,15 +5022,15 @@
<div class="qb-room-profile-wrap play-cyber-profile-wrap" aria-hidden="true">
<div class="qb-room-profile-frame play-cyber-profile-frame">
<div class="qb-room-profile-avatar-viewport play-cyber-portrait-clip">
<img id="play-cyber-portrait-img" class="qb-room-profile-avatar play-cyber-portrait-img" alt="" decoding="async" />
<img id="play-cyber-portrait-img" class="qb-room-profile-avatar play-cyber-portrait-img" alt="" decoding="async" / loading="lazy">
</div>
<img id="play-cyber-profile-frame-overlay" class="play-cyber-profile-frame-overlay" alt="" decoding="async" aria-hidden="true" />
<img id="play-cyber-profile-frame-overlay" class="play-cyber-profile-frame-overlay" alt="" decoding="async" aria-hidden="true" / loading="lazy">
</div>
</div>
<img id="play-cyber-mic-img" class="play-cyber-mic-img is-hidden" alt="" decoding="async" aria-hidden="true" />
<img id="play-cyber-mic-img" class="play-cyber-mic-img is-hidden" alt="" decoding="async" aria-hidden="true" / loading="lazy">
<!-- ไมค์ในเกม (MG1-7) — WebRTC mesh เหมือนหน้า lobby; วางใต้ avatar ตามดีไซน์ (mg-mic). play.js เปิดแสดง (ซ่อน preview/Quiz Battle) -->
<button type="button" id="btn-voice" class="play-voice-btn" title="เปิดไมค์" aria-label="เปิด/ปิดไมค์" style="display:none;">
<img src="/Game/img/btn-mic-mute.png" alt="" id="btn-voice-icon-img" width="128" height="128" decoding="async" />
<img src="/Game/img/btn-mic-mute.png" alt="" id="btn-voice-icon-img" width="128" height="128" decoding="async" / loading="lazy">
</button>
<div id="play-cyber-self-status" class="play-cyber-self-status"></div>
<div id="play-cyber-op-widgets" class="play-cyber-op-widgets" aria-hidden="true">
@@ -5049,9 +5049,9 @@
<!-- ภารกิจคำถาม mng8a80o ช่วง live: virtual joystick (QUESTION/btn-joystick-*.png) -->
<div id="quiz-question-mission-joystick" class="quiz-q-mission-joystick is-hidden" aria-hidden="true">
<div class="quiz-q-mission-joystick-base" id="quiz-q-mission-joystick-base">
<img id="quiz-q-mission-joystick-bg" class="quiz-q-mission-joystick-bg" src="" alt="" decoding="async" />
<img id="quiz-q-mission-joystick-bg" class="quiz-q-mission-joystick-bg" src="" alt="" decoding="async" / loading="lazy">
<div class="quiz-q-mission-joystick-knob" id="quiz-q-mission-joystick-knob">
<img id="quiz-q-mission-joystick-knob-img" class="quiz-q-mission-joystick-knob-img" src="" alt="" decoding="async" />
<img id="quiz-q-mission-joystick-knob-img" class="quiz-q-mission-joystick-knob-img" src="" alt="" decoding="async" / loading="lazy">
</div>
</div>
</div>
@@ -5059,54 +5059,54 @@
<!-- MG3 Stack Tower (mnn93hpi) live HUD — DOM overlay (canvas object-fit:cover ครอป HUD ขอบจอ จึงย้ายมา DOM สเกลตาม stage) -->
<div id="mg3-tower-hud" class="mg3-tower-hud is-hidden" aria-hidden="true">
<div class="mg3-life">
<img class="mg3-life-bar" src="" alt="" decoding="async" />
<img class="mg3-life-bar" src="" alt="" decoding="async" / loading="lazy">
<div class="mg3-life-hearts" id="mg3-life-hearts"></div>
</div>
<div class="mg3-host">
<img class="mg3-host-frame" src="" alt="" decoding="async" />
<img class="mg3-host-face" id="mg3-host-av" src="" alt="" decoding="async" />
<img class="mg3-host-frame" src="" alt="" decoding="async" / loading="lazy">
<img class="mg3-host-face" id="mg3-host-av" src="" alt="" decoding="async" / loading="lazy">
</div>
<div class="mg3-terminal">
<img class="mg3-terminal-bg" src="" alt="" decoding="async" />
<img class="mg3-terminal-bg" src="" alt="" decoding="async" / loading="lazy">
<div class="mg3-terminal-log" id="mg3-terminal-log"></div>
</div>
<div class="mg3-progress">
<div class="mg3-progress-heading">
<img class="mg3-progress-label" src="" alt="" decoding="async" />
<img class="mg3-progress-label" src="" alt="" decoding="async" / loading="lazy">
<span class="mg3-progress-pct" id="mg3-progress-pct">[ 0% ]</span>
</div>
<div class="mg3-progress-bar">
<img class="mg3-progress-track" src="" alt="" decoding="async" />
<img class="mg3-progress-fill" id="mg3-progress-fill" src="" alt="" decoding="async" />
<img class="mg3-progress-track" src="" alt="" decoding="async" / loading="lazy">
<img class="mg3-progress-fill" id="mg3-progress-fill" src="" alt="" decoding="async" / loading="lazy">
</div>
</div>
</div>
<!-- MG6 Space Shooter (mnpz6rkp) live life — DOM overlay สเกล design 1920×1080 เหมือน MG3 (ไม่พึ่ง cyber HUD flex) -->
<div id="mg6-life-hud" class="mg6-life-hud is-hidden" aria-hidden="true">
<div class="mg6-life">
<img class="mg6-life-bar" src="" alt="" decoding="async" />
<img class="mg6-life-bar" src="" alt="" decoding="async" / loading="lazy">
<div class="mg6-life-hearts" id="mg6-life-hearts"></div>
</div>
</div>
<!-- Quiz Battle (qbroom*) in-game HUD — DOM overlay สเกล design 1920×1080 (header หมวด, PLAYERS, SCORE, host, EXIT, MY RESULT, RANKING) -->
<div id="qb-game-hud" class="qb-game-hud is-hidden" aria-hidden="true">
<img class="qb-hud-tag" id="qb-hud-tag" src="" alt="" decoding="async" />
<img class="qb-hud-tag" id="qb-hud-tag" src="" alt="" decoding="async" / loading="lazy">
<div class="qb-hud-players">PLAYERS : <span id="qb-hud-players-val">1/50</span></div>
<div class="qb-hud-score">SCORE : <span id="qb-hud-score-val">0</span></div>
<div class="qb-hud-host">
<div class="qb-hud-host-clip"><img class="qb-hud-host-av" id="qb-hud-host-av" src="" alt="" decoding="async" /></div>
<div class="qb-hud-host-clip"><img class="qb-hud-host-av" id="qb-hud-host-av" src="" alt="" decoding="async" / loading="lazy"></div>
</div>
<button type="button" class="qb-hud-btn qb-hud-exit" id="qb-hud-exit" aria-label="ออกจากห้อง">
<img src="/Game/img/qb-ui/btn-exit-room.png" alt="EXIT" decoding="async" />
<img src="/Game/img/qb-ui/btn-exit-room.png" alt="EXIT" decoding="async" / loading="lazy">
</button>
<button type="button" class="qb-hud-btn qb-hud-myresult" id="qb-hud-myresult" aria-label="ผลของฉัน">
<img src="/Game/img/qb-ui/btn-my-result.png" alt="MY RESULT" decoding="async" />
<img src="/Game/img/qb-ui/btn-my-result.png" alt="MY RESULT" decoding="async" / loading="lazy">
</button>
<button type="button" class="qb-hud-btn qb-hud-ranking" id="qb-hud-ranking" aria-label="อันดับ">
<img src="/Game/img/qb-ui/btn-ranking.png" alt="RANKING" decoding="async" />
<img src="/Game/img/qb-ui/btn-ranking.png" alt="RANKING" decoding="async" / loading="lazy">
</button>
<button type="button" class="qb-hud-btn qb-hud-mic" id="qb-hud-mic" aria-label="ไมโครโฟน">
<img id="qb-hud-mic-img" src="/Game/img/btn-mic-on.png" alt="mic" decoding="async" />
<img id="qb-hud-mic-img" src="/Game/img/btn-mic-on.png" alt="mic" decoding="async" / loading="lazy">
</button>
<div class="qb-hud-zoom" id="qb-hud-zoom">
<button type="button" class="qb-hud-zoom-btn" id="qb-hud-zoom-in" aria-label="ซูมเข้า">+</button>
@@ -5180,7 +5180,7 @@
<p id="quiz-carry-pregame-status" class="quiz-carry-pregame-status" aria-live="polite"></p>
<div class="quiz-carry-pregame-actions">
<button type="button" id="quiz-carry-pregame-primary" class="quiz-carry-pregame-primary-btn" aria-pressed="false" title="READY">
<img id="quiz-carry-pregame-primary-img" src="/Game/img/quiz-carry/btn-ready.png" width="220" height="56" alt="READY" decoding="async" />
<img id="quiz-carry-pregame-primary-img" src="/Game/img/quiz-carry/btn-ready.png" width="220" height="56" alt="READY" decoding="async" / loading="lazy">
</button>
</div>
</div>
@@ -5190,7 +5190,7 @@
<div id="quiz-carry-embed-countdown-inner">
<p id="quiz-carry-embed-countdown-kicker" class="quiz-carry-embed-countdown-kicker">คำถาม · Question</p>
<p id="quiz-carry-embed-countdown-q" class="quiz-carry-embed-countdown-q"></p>
<img id="quiz-carry-embed-countdown-num" src="/Game/img/QUESTION/3.png" width="200" height="200" alt="3" decoding="async" />
<img id="quiz-carry-embed-countdown-num" src="/Game/img/QUESTION/3.png" width="200" height="200" alt="3" decoding="async" / loading="lazy">
</div>
</div>
<div id="quiz-carry-embed-q-strip" class="is-hidden" role="status" aria-live="polite" aria-atomic="true" aria-hidden="true">
@@ -5212,9 +5212,9 @@
<button type="button" id="quiz-battle-mcq-close" aria-label="ปิด"></button>
<p id="quiz-battle-mcq-text" class="quiz-battle-mcq-text"></p>
<div class="quiz-battle-mcq-actions">
<button type="button" class="quiz-battle-choice" data-idx="0"><span class="qbc-letter">A</span><span class="qbc-text"></span><img class="qbc-mark" alt="" decoding="async" /></button>
<button type="button" class="quiz-battle-choice" data-idx="1"><span class="qbc-letter">B</span><span class="qbc-text"></span><img class="qbc-mark" alt="" decoding="async" /></button>
<button type="button" class="quiz-battle-choice" data-idx="2"><span class="qbc-letter">C</span><span class="qbc-text"></span><img class="qbc-mark" alt="" decoding="async" /></button>
<button type="button" class="quiz-battle-choice" data-idx="0"><span class="qbc-letter">A</span><span class="qbc-text"></span><img class="qbc-mark" alt="" decoding="async" / loading="lazy"></button>
<button type="button" class="quiz-battle-choice" data-idx="1"><span class="qbc-letter">B</span><span class="qbc-text"></span><img class="qbc-mark" alt="" decoding="async" / loading="lazy"></button>
<button type="button" class="quiz-battle-choice" data-idx="2"><span class="qbc-letter">C</span><span class="qbc-text"></span><img class="qbc-mark" alt="" decoding="async" / loading="lazy"></button>
</div>
</div>
</div>
@@ -5222,7 +5222,7 @@
<div class="gch-backdrop gch-legacy-backdrop" aria-hidden="true"></div>
<div class="gch-shell">
<!-- legacy (Mega Virus / แมปอื่น) -->
<img class="gch-bg gch-legacy-only" src="/Game/img/gauntlet-assets/popup-Howto.png" alt="" width="920" height="520" decoding="async" />
<img class="gch-bg gch-legacy-only" src="/Game/img/gauntlet-assets/popup-Howto.png" alt="" width="920" height="520" decoding="async" / loading="lazy">
<div class="gch-inner gch-inner--art gch-legacy-only">
<div class="gch-art-footer">
<p id="gauntlet-crown-howto-status" class="gch-status is-hidden" aria-live="polite"></p>
@@ -5234,13 +5234,13 @@
<div class="gch-mg2-stage gch-mg2-only" aria-hidden="true">
<div class="layer mg-overlay" aria-hidden="true"></div>
<div class="layer howto-popup">
<img class="gch-bg" src="/Game/img/gauntlet-assets/popup-Howto.png" alt="HOW TO PLAY" width="1500" height="880" decoding="async" />
<img class="gch-bg" src="/Game/img/gauntlet-assets/popup-Howto.png" alt="HOW TO PLAY" width="1500" height="880" decoding="async" / loading="lazy">
</div>
<div class="layer howto-status">
<p id="gauntlet-crown-howto-status-mg2" class="gch-status is-hidden" aria-live="polite"></p>
</div>
<button type="button" class="layer howto-btn btn btn-gch-ready" id="btn-gch-ready-mg2" title="READY">
<img id="btn-gch-ready-mg2-img" src="/Game/img/gauntlet-assets/btn-ready.png" alt="READY" width="299" height="120" decoding="async" />
<img id="btn-gch-ready-mg2-img" src="/Game/img/gauntlet-assets/btn-ready.png" alt="READY" width="299" height="120" decoding="async" / loading="lazy">
</button>
<div id="gch-ready-avatars-mg2" class="gch-ready-avatars layer" aria-hidden="true"></div>
</div>
@@ -5248,18 +5248,18 @@
</div>
<div id="gauntlet-crown-countdown" class="is-hidden" role="alert" aria-live="assertive" aria-atomic="true">
<div class="gcc-panel">
<img id="gauntlet-crown-countdown-num" class="gcc-num gcc-num--img" src="/Game/img/gauntlet-assets/3.png" width="240" height="240" alt="" decoding="async" />
<img id="gauntlet-crown-countdown-num" class="gcc-num gcc-num--img" src="/Game/img/gauntlet-assets/3.png" width="240" height="240" alt="" decoding="async" / loading="lazy">
<span id="gauntlet-crown-countdown-text" class="gcc-num gcc-num--text is-hidden" aria-hidden="true">3</span>
</div>
</div>
<div id="stack-tower-result-flash" class="is-hidden" role="dialog" aria-modal="true" aria-hidden="true" aria-label="ผลภารกิจ Tower">
<div class="stack-tower-result-flash-backdrop" aria-hidden="true"></div>
<img id="stack-tower-result-flash-img" src="" alt="" width="920" height="520" decoding="async" />
<img id="stack-tower-result-flash-img" src="" alt="" width="920" height="520" decoding="async" / loading="lazy">
</div>
<div id="gauntlet-crown-mission-overlay" class="is-hidden" role="dialog" aria-modal="true" aria-labelledby="gcm-heading">
<div class="gcm-backdrop" aria-hidden="true"></div>
<div class="gcm-shell">
<img class="gcm-bg" src="/Game/img/gauntlet-assets/popup-result.png" alt="" width="920" height="560" decoding="async" />
<img class="gcm-bg" src="/Game/img/gauntlet-assets/popup-result.png" alt="" width="920" height="560" decoding="async" / loading="lazy">
<div class="gcm-inner">
<div id="gcm-heading" class="gcm-header-tab">สรุปผลภารกิจ · Mission summary</div>
<div id="gcm-rank-row" class="gcm-rank-row"></div>
@@ -5270,7 +5270,7 @@
<div id="gcm-grade" class="gcm-grade sm-grade" aria-live="polite">A</div>
</div>
<div id="gcm-vrule" class="gcm-vrule sm-vrule is-hidden" aria-hidden="true">
<img class="gcm-line-2 sm-line-2" src="/Game/img/gauntlet-assets/popup-result-Line2.png" alt="" decoding="async" />
<img class="gcm-line-2 sm-line-2" src="/Game/img/gauntlet-assets/popup-result-Line2.png" alt="" decoding="async" / loading="lazy">
</div>
<div class="gcm-col-bonus">
<div class="gcm-h head">โบนัสพิเศษ</div>
@@ -5428,7 +5428,7 @@
<div class="sq-ov-panel">
<div id="sq-ov-quiz-body">
<div class="sq-ov-head">
<img id="sq-ov-icon" class="sq-ov-icon" src="/Game/img/special-quiz/icon-lawyer.png" alt="" />
<img id="sq-ov-icon" class="sq-ov-icon" src="/Game/img/special-quiz/icon-lawyer.png" alt="" / loading="lazy">
<div class="sq-ov-head-text">
<div id="sq-overlay-title" class="sq-ov-kicker">คำถามพิเศษ · Special Quiz</div>
<div id="sq-ov-progress" class="sq-ov-progress">ข้อ 1 / 1</div>
@@ -5445,10 +5445,10 @@
</div>
<div id="sq-ov-award" class="sq-ov-award is-hidden">
<div class="sq-ov-award-stage">
<img id="sq-ov-award-effect-img" class="sq-ov-award-effect" src="/Game/img/special-quiz/reward/card-effect.png" alt="" />
<img id="sq-ov-award-img" class="sq-ov-award-img" alt="" />
<img id="sq-ov-award-effect-img" class="sq-ov-award-effect" src="/Game/img/special-quiz/reward/card-effect.png" alt="" / loading="lazy">
<img id="sq-ov-award-img" class="sq-ov-award-img" alt="" / loading="lazy">
</div>
<img id="sq-ov-award-txt" class="sq-ov-award-txt" alt="" />
<img id="sq-ov-award-txt" class="sq-ov-award-txt" alt="" / loading="lazy">
<div id="sq-ov-award-msg" class="sq-ov-award-msg"></div>
<button type="button" id="sq-ov-award-continue" class="sq-ov-award-btn" aria-label="เล่นเกมต่อ">เล่นเกมต่อ</button>
</div>
@@ -5458,7 +5458,7 @@
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/sound.js?v=2"></script>
<script src="js/play.js?v=0.006301183"></script>
<script src="js/play.js?v=0.006301196"></script>
<div class="version-tag">v —</div>
<style id="qc-howto-mg2-fix">
/* HOW TO PLAY ของ quiz_carry (Minigame-4) -> ให้เหมือน Minigame-2 (gch-mg2-mock)
@@ -5665,7 +5665,23 @@
if (!isMg) return; /* เฉพาะตอนเข้ามินิเกม (detective) เท่านั้น — ฉาก detective ปกติไม่เล่น */
var ov = document.getElementById('jd-mg-transition'), vid = document.getElementById('jd-mg-vid');
if (!ov || !vid) return;
function done() { try { vid.pause(); } catch (e) {} ov.style.display = 'none'; } /* จบวิดีโอ → เอา overlay ออก (เกม/loading ที่โหลดข้างล่างโผล่มาเอง) */
/* [2026-07-23] จบวิดีโอ → เอา overlay ออก **และปล่อยวิดีโอทิ้งด้วย**
เดิมแค่ pause+ซ่อน → บัฟเฟอร์ ~3.1 MB ค้างทุกครั้งที่เข้ามินิเกม (เข้าหลายรอบ = สะสมไปเรื่อยๆ)
บนมือถือแรมไม่พอแล้วโดนเบราว์เซอร์ฆ่าแท็บ */
var released = false;
function done() {
if (released) return;
released = true;
try { vid.pause(); } catch (e) { /* ignore */ }
try {
vid.onended = null; vid.onerror = null;
vid.removeAttribute('src');
while (vid.firstChild) vid.removeChild(vid.firstChild);
vid.load();
} catch (e) { /* ignore */ }
ov.style.display = 'none';
try { if (ov.parentNode) ov.parentNode.removeChild(ov); } catch (e) { /* ignore */ }
}
vid.src = '/Game/video/minigame-transition.mp4';
vid.onended = done; vid.onerror = done; /* วิดีโอหาย/พัง → ไม่บล็อกเกม */
ov.style.display = 'flex'; /* ปิดจอระหว่างโหลด — ข้ามไม่ได้ (ไม่มีปุ่ม skip) */
+137 -14
View File
@@ -97,6 +97,7 @@
<button class="tool active" data-tool="path"><span class="ic">🛣️</span>เส้นทาง</button>
<button class="tool" data-tool="dome"><span class="ic"></span>จุดคำถาม</button>
<button class="tool" data-tool="spawn"><span class="ic">🚩</span>จุดเกิด</button>
<button class="tool" data-tool="fence"><span class="ic">🚧</span>รั้วกั้น</button>
<button class="tool" data-tool="erase"><span class="ic">🧽</span>ยางลบ</button>
</div>
</div>
@@ -105,12 +106,22 @@
<select id="brush"><option value="1">1×1</option><option value="2" selected>2×2</option><option value="3">3×3</option></select>
</div>
<div class="field" id="fence-field" style="display:none"><span>แบบรั้ว — คลิกวาง · ลากย้าย · คลิกขวาลบ</span>
<select id="fence-kind">
<option value="h1" selected>▬ แนวนอน สั้น (1 ช่อง)</option>
<option value="h2">▬▬ แนวนอน ยาว (2 ช่อง)</option>
<option value="v1">▮ แนวตั้ง สั้น (1 ช่อง)</option>
<option value="v2">▮▮ แนวตั้ง ยาว (2 ช่อง)</option>
</select>
</div>
<div class="stat grow" id="stat"></div>
<div class="row">
<button class="btn sec" id="btn-clear-path">ล้างเส้นทาง</button>
<button class="btn sec" id="btn-clear-dome">ล้างจุดคำถาม</button>
</div>
<button class="btn sec grow" id="btn-clear-fence">ล้างรั้วกั้น</button>
<button class="btn grow" id="btn-save">💾 บันทึก</button>
<button class="btn sec grow" id="btn-test">▶ ทดสอบเล่น</button>
<div class="toast" id="toast"></div>
@@ -128,6 +139,14 @@
var curId = qs.get('id') || 'qbroom1';
var map=null, W=24, H=49, path=[], dome=[], spawn={x:11,y:2};
/* รั้วกั้น — [{x,y,w,h}] หน่วยเป็น "ช่อง" ทศนิยมได้ วางอิสระไม่ยึดกริด
กริด 20x40 หยาบเกินจะกั้นตรงรั้วที่อาร์ตวาดกลางแถบได้ (รั้วอยู่กลางช่อง ไม่ใช่ขอบช่อง)
เกมเช็คด้วยกล่องชนของสไปรต์ซ้อนกล่องรั้ว (server.js quizBattleFenceBlocksServer + play.js ตัวเดียวกัน)
ในเกมมองไม่เห็น — อาร์ตวาดรั้วไว้ให้แล้ว ตรงนี้เป็นแค่ตัวกั้นการเดิน */
var fences=[], dragFence=null;
/* คลังคำถามของ Quiz Battle มี 20 ข้อต่อหัวข้อ (1 หัวข้อ = 1 ฉาก) → ฉากหนึ่งควรมีจุดคำถาม 20 ข้อพอดี
ถ้าวางเกิน เลขข้อจะวนซ้ำ (quizBattleQuestionIndexForCompPlay ใช้ (idx % poolLen)) */
var QUESTIONS_PER_MAP = 20;
var bgImg=new Image(), tool='path', brush=2, painting=false, paintVal=1;
var cv=document.getElementById('cv'), ctx=cv.getContext('2d');
@@ -171,6 +190,9 @@
document.querySelectorAll('.tool').forEach(function(x){x.classList.remove('active');});
b.classList.add('active'); tool=b.getAttribute('data-tool');
brushLabel.textContent = b.querySelector('.ic').textContent+' '+b.textContent.trim();
/* ตัวเลือกแบบรั้วโผล่เฉพาะตอนใช้เครื่องมือรั้ว · ขนาดพู่กันไม่เกี่ยวกับรั้ว (รั้วไม่ยึดกริด) */
var ff=document.getElementById('fence-field');
if(ff) ff.style.display=(tool==='fence')?'':'none';
});
});
document.getElementById('brush').addEventListener('change',function(e){ brush=parseInt(e.target.value,10)||1; });
@@ -182,6 +204,9 @@
.then(function(m){
map=m; W=m.width||24; H=m.height||49;
path=norm(m.quizBattlePathArea,W,H); dome=norm(m.quizBattleDomeArea,W,H);
fences=(Array.isArray(m.quizBattleFences)?m.quizBattleFences:[])
.map(function(f){ return f&&isFinite(f.x)&&isFinite(f.y)&&f.w>0&&f.h>0
? { x:+f.x, y:+f.y, w:+f.w, h:+f.h } : null; }).filter(Boolean);
spawn=(m.spawn&&typeof m.spawn.x==='number')?{x:m.spawn.x,y:m.spawn.y}:{x:11,y:2};
if(m.backgroundImage){ bgImg=new Image(); bgImg.onload=draw; bgImg.onerror=draw; bgImg.src=m.backgroundImage+(m.backgroundImage.indexOf('?')<0?('?_='+Date.now()):''); }
toast('โหลด "'+(m.name||curId)+'" แล้ว',true); layout();
@@ -205,29 +230,98 @@
for(var x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*cell+.5,0); ctx.lineTo(x*cell+.5,h); ctx.stroke(); }
for(var y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*cell+.5); ctx.lineTo(w,y*cell+.5); ctx.stroke(); }
ctx.fillStyle='rgba(122,162,247,.42)';
for(var py=0;py<H;py++) for(var px=0;px<W;px++) if(path[py][px]) ctx.fillRect(px*cell,py*cell,cell,cell);
var num=0;
for(var dy2=0;dy2<H;dy2++) for(var dx2=0;dx2<W;dx2++) if(dome[dy2][dx2]){
num++; var cx=dx2*cell+cell/2, cy=dy2*cell+cell/2, r=Math.max(8,cell*0.62);
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fillStyle='rgba(255,209,102,.92)'; ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='#7a5a10'; ctx.stroke();
/* [2026-07-22] กัน TypeError ตอนเปิดหน้าครั้งแรก — layout() เรียก draw() ตั้งแต่ยังไม่ได้โหลดแผนที่
ตอนนั้น path/dome ยังเป็น [] แต่ H=49 → path[py] undefined แล้ว throw ทั้ง draw() (กริด/ฉากไม่ขึ้นเลย) */
for(var py=0;py<H;py++) for(var px=0;px<W;px++) if(path[py]&&path[py][px]) ctx.fillRect(px*cell,py*cell,cell,cell);
/* [2026-07-30] "1 คำถาม" = "โดม 1 กลุ่มที่ติดกัน" ไม่ใช่ "1 ช่อง"
server รวมช่องที่ติดกันเป็นกลุ่มเดียว (normalizeQuizBattleDomeCompOnMap) แล้วให้เลขข้อ 1 เลขต่อกลุ่ม
ตั้งแต่ 07-30 โดมถูกวางเป็น "ทั้งคอลัมน์ของเลน" (2-3 ช่อง) เพราะเลนหนา 3 แถวแต่กล่องชนสูง 1.35 ช่อง
ถ้าโดมเป็นช่องเดียวผู้เล่นจะมุดใต้ได้แล้วไปตันที่ประตูถัดไปแบบแก้ไม่ได้
ของเดิมนับ/ใส่เลขทีละช่อง → แมป 20 ข้อโชว์เป็น 52-56 (user: "จริง ๆ ควรมีแค่ 20 คำถามต่อ map")
ตรงนี้จึงจัดกลุ่มแบบเดียวกับ server แล้ววาดเลขครั้งเดียวต่อกลุ่ม (ที่ช่องบน-ซ้ายสุด เหมือน play.js) */
var comp=[], nGroup=0;
for(var gy=0;gy<H;gy++){ comp[gy]=[]; for(var gx=0;gx<W;gx++) comp[gy][gx]=0; }
for(var qy=0;qy<H;qy++) for(var qx=0;qx<W;qx++){
if(!(dome[qy]&&dome[qy][qx])||comp[qy][qx]) continue;
nGroup++; var st=[[qx,qy]]; comp[qy][qx]=nGroup;
while(st.length){
var c=st.pop();
var nb=[[c[0]+1,c[1]],[c[0]-1,c[1]],[c[0],c[1]+1],[c[0],c[1]-1]];
for(var k=0;k<4;k++){
var nx=nb[k][0], ny=nb[k][1];
if(nx<0||ny<0||nx>=W||ny>=H) continue;
if(!(dome[ny]&&dome[ny][nx])||comp[ny][nx]) continue;
comp[ny][nx]=nGroup; st.push([nx,ny]);
}
}
}
for(var dy2=0;dy2<H;dy2++) for(var dx2=0;dx2<W;dx2++) if(dome[dy2]&&dome[dy2][dx2]){
var id=comp[dy2][dx2];
var cx=dx2*cell+cell/2, cy=dy2*cell+cell/2, r=Math.max(8,cell*0.62);
/* ช่องอื่นในกลุ่มเดียวกันวาดจาง ๆ ไม่ใส่เลข — ให้เห็นว่า "ก้อนนี้คือข้อเดียวกัน" */
var head=!((comp[dy2-1]&&comp[dy2-1][dx2]===id)||(comp[dy2][dx2-1]===id));
ctx.beginPath(); ctx.arc(cx,cy,head?r:r*0.5,0,Math.PI*2);
ctx.fillStyle=head?'rgba(255,209,102,.92)':'rgba(255,209,102,.34)'; ctx.fill();
ctx.lineWidth=head?2:1; ctx.strokeStyle=head?'#7a5a10':'rgba(122,90,16,.5)'; ctx.stroke();
if(!head) continue;
ctx.fillStyle='#3a2c00'; ctx.font='bold '+Math.max(10,cell*0.7)+'px Kanit,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(String(num),cx,cy+1);
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(String(id),cx,cy+1);
}
/* รั้วกั้น — วาดตามพิกัดทศนิยมจริง (ไม่ snap เข้าช่อง) จะได้เห็นว่ามันอยู่ตรงไหนของภาพจริง ๆ */
for(var fi=0;fi<fences.length;fi++){
var fr=fences[fi];
var rx=fr.x*cell, ry=fr.y*cell, rw=Math.max(2,fr.w*cell), rh=Math.max(2,fr.h*cell);
ctx.fillStyle='rgba(255,99,132,.55)'; ctx.fillRect(rx,ry,rw,rh);
ctx.lineWidth=2; ctx.strokeStyle='#ff4d6d'; ctx.strokeRect(rx+.5,ry+.5,rw-1,rh-1);
/* ขีดทแยงให้ดูเป็น "รั้ว" ไม่ใช่แค่แถบสี */
ctx.save(); ctx.beginPath(); ctx.rect(rx,ry,rw,rh); ctx.clip();
ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1;
for(var hx=rx-rh;hx<rx+rw;hx+=6){ ctx.beginPath(); ctx.moveTo(hx,ry+rh); ctx.lineTo(hx+rh,ry); ctx.stroke(); }
ctx.restore();
}
var sx=spawn.x*cell, sy=spawn.y*cell;
ctx.fillStyle='rgba(52,211,153,.9)'; ctx.fillRect(sx,sy,cell,cell);
ctx.fillStyle='#04241a'; ctx.font='bold '+Math.max(10,cell*0.6)+'px Kanit,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('🚩',sx+cell/2,sy+cell/2);
var np=0,nd=0; for(var a=0;a<H;a++) for(var b=0;b<W;b++){ if(path[a][b])np++; if(dome[a][b])nd++; }
statEl.innerHTML='ฉาก: <b>'+(map&&map.name||curId)+'</b><br>เส้นทาง <b>'+np+'</b> ช่อง · จุดคำถาม <b>'+nd+'</b> · เกิด ('+spawn.x+','+spawn.y+')';
/* กันเคสเดียวกับด้านบน — นับก่อนโหลดแผนที่ path[a] ยัง undefined */
var np=0,nd=0; for(var a=0;a<H;a++) for(var b=0;b<W;b++){ if(path[a]&&path[a][b])np++; if(dome[a]&&dome[a][b])nd++; }
/* จำนวนคำถามจริง = จำนวน "กลุ่ม" (ตรงกับที่เกมถาม) · จำนวนช่องบอกไว้ในวงเล็บเผื่อเช็คว่าโดมกั้นเลนครบไหม */
var qWarn = (nGroup===QUESTIONS_PER_MAP) ? '' :
' <span style="color:#ff9f6e">— ควรมี '+QUESTIONS_PER_MAP+' ข้อต่อฉาก</span>';
statEl.innerHTML='ฉาก: <b>'+(map&&map.name||curId)+'</b><br>เส้นทาง <b>'+np+'</b> ช่อง · จุดคำถาม <b>'+nGroup+'</b> ข้อ <span style="opacity:.6">('+nd+' ช่อง)</span>'+qWarn
+' · รั้ว <b>'+fences.length+'</b> · เกิด ('+spawn.x+','+spawn.y+')';
}
function tileAt(ev){
var p=pointAt(ev); return { x:Math.floor(p.x), y:Math.floor(p.y) };
}
/** พิกัดแบบทศนิยม (หน่วยช่อง) — รั้วใช้ตัวนี้ เพราะวางอิสระไม่ยึดกริด */
function pointAt(ev){
var rect=cv.getBoundingClientRect();
var px=(ev.clientX!=null?ev.clientX:(ev.touches&&ev.touches[0].clientX))-rect.left;
var py=(ev.clientY!=null?ev.clientY:(ev.touches&&ev.touches[0].clientY))-rect.top;
var cell=cv._cell; return { x:Math.floor(px/cell), y:Math.floor(py/cell) };
var cell=cv._cell; return { x:px/cell, y:py/cell };
}
function fenceSize(){
var k=(document.getElementById('fence-kind')||{}).value||'h1';
if(k==='h2') return { w:2.0, h:0.22 };
if(k==='v1') return { w:0.22, h:1.0 };
if(k==='v2') return { w:0.22, h:2.0 };
return { w:1.0, h:0.22 };
}
function fenceAtPoint(p){
for(var i=fences.length-1;i>=0;i--){
var f=fences[i];
if(p.x>=f.x&&p.x<=f.x+f.w&&p.y>=f.y&&p.y<=f.y+f.h) return i;
}
/* เผื่อรั้วบางมาก (0.22 ช่อง) จิ้มโดนยาก — ขยายพื้นที่จับอีก 0.18 ช่องรอบตัว */
for(var j=fences.length-1;j>=0;j--){
var g=fences[j], m=0.18;
if(p.x>=g.x-m&&p.x<=g.x+g.w+m&&p.y>=g.y-m&&p.y<=g.y+g.h+m) return j;
}
return -1;
}
function round2(v){ return Math.round(v*100)/100; }
function applyAt(tx,ty){
if(tx<0||ty<0||tx>=W||ty>=H) return;
if(tool==='spawn'){ spawn={x:tx,y:ty}; return; }
@@ -240,14 +334,42 @@
}
}
function onDown(ev){
ev.preventDefault(); var t=tileAt(ev);
ev.preventDefault();
/* รั้ว: ทำงานด้วยพิกัดทศนิยมล้วน ไม่แตะกริดเลย — คลิกที่ว่าง = วางใหม่ (จุดที่คลิกคือ "กลาง" รั้ว)
คลิกบนรั้วเดิม = เริ่มลากย้าย · ยางลบคลิกโดนรั้วก็ลบรั้วนั้น */
var p=pointAt(ev);
if(tool==='fence'||tool==='erase'){
var hit=fenceAtPoint(p);
if(tool==='erase'&&hit>=0){ fences.splice(hit,1); draw(); return; }
if(tool==='fence'){
if(hit>=0){ dragFence={ i:hit, dx:p.x-fences[hit].x, dy:p.y-fences[hit].y }; return; }
var s=fenceSize();
fences.push({ x:round2(p.x-s.w/2), y:round2(p.y-s.h/2), w:s.w, h:s.h });
dragFence={ i:fences.length-1, dx:s.w/2, dy:s.h/2 };
draw(); return;
}
}
var t=tileAt(ev);
if(tool==='path') paintVal=(path[t.y]&&path[t.y][t.x])?0:1;
else if(tool==='dome') paintVal=(dome[t.y]&&dome[t.y][t.x])?0:1;
else paintVal=1;
painting=true; applyAt(t.x,t.y); draw();
}
function onMove(ev){ if(!painting) return; ev.preventDefault(); var t=tileAt(ev); applyAt(t.x,t.y); draw(); }
function onUp(){ painting=false; }
function onMove(ev){
if(dragFence){
ev.preventDefault(); var p=pointAt(ev), f=fences[dragFence.i];
if(f){ f.x=round2(p.x-dragFence.dx); f.y=round2(p.y-dragFence.dy); draw(); }
return;
}
if(!painting) return; ev.preventDefault(); var t=tileAt(ev); applyAt(t.x,t.y); draw();
}
function onUp(){ painting=false; dragFence=null; }
/* คลิกขวาบนรั้ว = ลบ (ไม่ต้องสลับไปยางลบ) */
cv.addEventListener('contextmenu',function(ev){
var hit=fenceAtPoint(pointAt(ev));
if(hit<0) return;
ev.preventDefault(); fences.splice(hit,1); draw();
});
cv.addEventListener('mousedown',onDown);
window.addEventListener('mousemove',onMove);
window.addEventListener('mouseup',onUp);
@@ -258,11 +380,12 @@
document.getElementById('btn-clear-path').addEventListener('click',function(){ path=emptyGrid(W,H); draw(); toast('ล้างเส้นทางแล้ว',true); });
document.getElementById('btn-clear-dome').addEventListener('click',function(){ dome=emptyGrid(W,H); draw(); toast('ล้างจุดคำถามแล้ว',true); });
document.getElementById('btn-clear-fence').addEventListener('click',function(){ fences=[]; draw(); toast('ล้างรั้วกั้นแล้ว',true); });
document.getElementById('btn-save').addEventListener('click',function(){
var btn=this; btn.disabled=true; toast('กำลังบันทึก…');
fetch(BASE+'/api/maps/'+encodeURIComponent(curId),{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ quizBattlePathArea:path, quizBattleDomeArea:dome, spawn:spawn, width:W, height:H })})
body:JSON.stringify({ quizBattlePathArea:path, quizBattleDomeArea:dome, quizBattleFences:fences, spawn:spawn, width:W, height:H })})
.then(function(r){return r.json();}).then(function(res){ btn.disabled=false;
if(res&&res.ok) toast('บันทึก "'+curId+'" สำเร็จ ✓',true); else toast((res&&res.error)||'บันทึกไม่สำเร็จ',false);
}).catch(function(e){ btn.disabled=false; toast('บันทึกไม่สำเร็จ: '+e.message,false); });
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+14242 -13842
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -231,7 +231,7 @@
<script src="../Game/js/achievements.js?v=0.002" data-asset-base="/Game/img/03-6-Profile"></script>
<script src="../Game/js/sound.js?v=2"></script>
<script src="../Game/js/profile-popup.js?v=9" data-profile-trigger="#btn-myprofile" data-profile-asset-base="/Game/img/03-6-Profile"></script>
<script src="lobby.js?v=0.0194"></script>
<script src="lobby.js?v=0.0195"></script>
<script>
/* เสียงคลิกปุ่มใน Main-Lobby (เฉพาะปุ่มจริง) */
document.addEventListener('click', function (e) {
+5 -5
View File
@@ -26,11 +26,11 @@
* highlight: selector ของปมใน lobby (null = ไมวงกรอบ)
*/
var GUIDE_STEPS = [
{ position: 'bottom', highlight: null, pad: 10 },
{ position: 'top', highlight: '.lobby-footer-center', pad: 10 },
{ position: 'top', highlight: '#btn-ai-chat', pad: 8 },
{ position: 'bottom', highlight: '#btn-cloth', pad: 10 },
{ position: 'bottom', highlight: '#btn-daily', pad: 10 },
{ position: 'bottom', highlight: null, pad: 10 }, // guide-01 ทักทาย
{ position: 'bottom', highlight: '#btn-cloth', pad: 10 }, // guide-02 ห้องแต่งตัว
{ position: 'bottom', highlight: '#btn-daily', pad: 10 }, // guide-03 DAILY
{ position: 'top', highlight: '.lobby-footer-center', pad: 10 }, // guide-04 START MISSION / QUIZ BATTLE
{ position: 'top', highlight: '#btn-ai-chat', pad: 8 }, // guide-05 เทพความรู้
];
function checkLogin() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+50 -50
View File
@@ -1,50 +1,50 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Quiz Battle — JD JUSTICE DIVERS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=0.0222">
</head>
<body>
<div class="qb-bg" aria-hidden="true">
<img src="IMAGE/Quiz-Battle-bg.png" alt="" class="qb-bg-img" decoding="async">
</div>
<div class="qb-vignette" aria-hidden="true"></div>
<div class="qb-ui">
<header class="qb-top">
<button type="button" class="qb-join-back" id="btn-back" aria-label="กลับล็อบบี้">
<img src="IMAGE/btn-exit-room.png" alt="" class="qb-join-back-img" decoding="async">
</button>
<div class="qb-top-fill" aria-hidden="true"></div>
<div class="qb-room-profile-wrap" aria-hidden="true">
<div class="qb-room-profile-frame">
<div class="qb-room-profile-avatar-viewport">
<img src="../Main-Menu/char-main.png" alt="" class="qb-room-profile-avatar" id="lobby-profile-avatar" width="64" height="64" decoding="async">
</div>
</div>
</div>
</header>
<main class="qb-main">
<div class="qb-room-banner" aria-hidden="true">
<img src="IMAGE/quiz-room.png" alt="" decoding="async">
</div>
<div class="qb-grid" id="qb-grid" role="list" aria-label="เลือกห้องควิซ"></div>
<p class="qb-toast" id="qb-toast" role="status"></p>
</main>
</div>
<div class="room-lobby-br-fixed qb-room-lobby-br-fixed" id="qb-room-lobby-br-fixed">
<button type="button" id="btn-voice" class="btn-voice-icon" title="เปิด/ปิดเสียงพูด" aria-label="เปิด/ปิดเสียงพูด">
<img src="../Game/img/btn-mic-mute.png" alt="เปิดเสียง" id="btn-voice-icon-img" decoding="async">
</button>
</div>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0501"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Quiz Battle — JD JUSTICE DIVERS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=0.0224">
</head>
<body>
<div class="qb-bg" aria-hidden="true">
<img src="IMAGE/Quiz-Battle-bg.png" alt="" class="qb-bg-img" decoding="async" loading="lazy">
</div>
<div class="qb-vignette" aria-hidden="true"></div>
<div class="qb-ui">
<header class="qb-top">
<button type="button" class="qb-join-back" id="btn-back" aria-label="กลับล็อบบี้">
<img src="IMAGE/btn-exit-room.png" alt="" class="qb-join-back-img" decoding="async" loading="lazy">
</button>
<div class="qb-top-fill" aria-hidden="true"></div>
<div class="qb-room-profile-wrap" aria-hidden="true">
<div class="qb-room-profile-frame">
<div class="qb-room-profile-avatar-viewport">
<img src="../Main-Menu/char-main.png" alt="" class="qb-room-profile-avatar" id="lobby-profile-avatar" width="64" height="64" decoding="async" loading="lazy">
</div>
</div>
</div>
</header>
<main class="qb-main">
<div class="qb-room-banner" aria-hidden="true">
<img src="IMAGE/quiz-room.png" alt="" decoding="async" loading="lazy">
</div>
<div class="qb-grid" id="qb-grid" role="list" aria-label="เลือกห้องควิซ"></div>
<p class="qb-toast" id="qb-toast" role="status"></p>
</main>
</div>
<div class="room-lobby-br-fixed qb-room-lobby-br-fixed" id="qb-room-lobby-br-fixed">
<button type="button" id="btn-voice" class="btn-voice-icon" title="เปิด/ปิดเสียงพูด" aria-label="เปิด/ปิดเสียงพูด">
<img src="../Game/img/btn-mic-mute.png" alt="เปิดเสียง" id="btn-voice-icon-img" decoding="async" loading="lazy">
</button>
</div>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0505"></script>
</body>
</html>
+352 -254
View File
@@ -1,254 +1,352 @@
(function () {
'use strict';
var QB_IMAGE = typeof appPath === 'function' ? appPath('/Quiz-Battle/IMAGE') : '/Quiz-Battle/IMAGE';
var BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
var CHAR_KEY = 'gameCharacterId';
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
var VOICE_STATE_KEY = 'qbVoiceMicOn';
/* ฉากเดินตอบแบบ ZEP — แต่ละห้อง(หัวข้อ) = 1 space บนแผนที่ quiz_battle ของหัวข้อนั้น (qbroom1..10) */
function mapIdForRoom(n) { return 'qbroom' + n; }
var QB_MAX_PLAYERS = 50;
var ROOM_NAME_PREFIX = 'Quiz Battle ห้อง ';
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
return;
}
var grid = document.getElementById('qb-grid');
var toast = document.getElementById('qb-toast');
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function setToast(msg) { if (toast) toast.textContent = msg || ''; }
function getSelectedCharacterId() {
try { return (localStorage.getItem(CHAR_KEY) || '').trim(); } catch (e) { return ''; }
}
function getPlayerName() {
var keys = ['gameNickname', 'nickname', 'displayName', 'playerName', 'userName'];
for (var i = 0; i < keys.length; i++) {
try { var v = (localStorage.getItem(keys[i]) || '').trim(); if (v) return v.slice(0, 24); } catch (e) { /* ignore */ }
}
return 'ผู้เล่น' + Math.floor(1000 + Math.random() * 9000);
}
function characterDownUrls(id) {
var enc = encodeURIComponent(id);
return [BASE + '/img/characters/' + enc + '_down.png', BASE + '/img/characters/' + enc + '_down_0.png'];
}
function renderAvatarForId(av, cid, fallbackAvatar) {
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
av.onerror = function () { av.onerror = null; av.src = fallbackAvatar; };
av.src = savedLobbyAvatar;
return;
}
} catch (e0) { /* ignore */ }
var urls = characterDownUrls(cid);
var avStep = 0;
av.onerror = function () {
avStep += 1;
if (avStep === 1) av.src = urls[1];
else { av.onerror = null; av.src = fallbackAvatar; }
};
av.src = urls[0];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (cid) { renderAvatarForId(av, cid, fallbackAvatar); return; }
// ไม่มี id ใน localStorage → ดึงจาก API เหมือน lobby (กัน avatar ไม่ขึ้น/ขึ้นรูป default)
fetch(BASE + '/api/characters', { credentials: 'same-origin', cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (list) {
if (!Array.isArray(list) || !list.length) { av.onerror = null; av.src = fallbackAvatar; return; }
var last = list[list.length - 1];
var id = last && last.id ? String(last.id).trim() : '';
if (!id) { av.onerror = null; av.src = fallbackAvatar; return; }
renderAvatarForId(av, id, fallbackAvatar);
})
.catch(function () { av.onerror = null; av.src = fallbackAvatar; });
}
/* ===================== เข้าฉากเดิน (ZEP walkable) ===================== */
var entering = false;
function roomName(n) { return ROOM_NAME_PREFIX + n; }
function gotoPlay(spaceId) {
var nick = getPlayerName();
var url = BASE + '/play.html?space=' + encodeURIComponent(spaceId) +
'&map=' + encodeURIComponent(mapIdForRoom(currentRoomNo)) +
'&nick=' + encodeURIComponent(nick);
try { localStorage.setItem('lastCreatedSpaceName', roomName(currentRoomNo)); } catch (e) { /* ignore */ }
window.location.href = url;
}
var currentRoomNo = 0;
/** หา space สาธารณะของห้องนี้ที่ยังมีคนอยู่ ถ้าไม่มีให้สร้างใหม่ แล้วเข้าเล่น */
function startQuizForRoom(n) {
if (entering) return;
entering = true;
currentRoomNo = n;
setToast('กำลังเข้าห้อง ' + n + '…');
var wantName = roomName(n);
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var found = null;
if (Array.isArray(list)) {
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].spaceName === wantName && (list[i].peerCount || 0) < QB_MAX_PLAYERS) { found = list[i]; break; }
}
}
if (found) { gotoPlay(found.spaceId); return null; }
// สร้างห้องใหม่บนแผนที่ quiz_battle
return fetch(BASE + '/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mapId: mapIdForRoom(n), name: wantName, maxPlayers: QB_MAX_PLAYERS })
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.ok && res.spaceId) { gotoPlay(res.spaceId); }
else { setToast((res && res.error) || 'สร้างห้องไม่สำเร็จ'); entering = false; }
});
})
.catch(function () { setToast('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ ลองอีกครั้ง'); entering = false; });
}
/** อัปเดตจำนวนคนในแต่ละห้องจากรายชื่อ space (poll) */
function refreshRoomCounts() {
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var counts = {};
if (Array.isArray(list)) {
list.forEach(function (s) {
if (!s || !s.spaceName) return;
if (s.spaceName.indexOf(ROOM_NAME_PREFIX) === 0) {
var n = parseInt(s.spaceName.slice(ROOM_NAME_PREFIX.length), 10);
if (n >= 1 && n <= 10) counts[n] = (s.peerCount || 0);
}
});
}
for (var i = 1; i <= 10; i++) {
var el = document.getElementById('qb-count-' + i);
if (el) el.textContent = (counts[i] || 0) + '/' + QB_MAX_PLAYERS;
}
})
.catch(function () { /* keep last */ });
}
function getVoiceIconPath(micOn) {
var imgPath = micOn ? '/Game/img/btn-mic-on.png' : '/Game/img/btn-mic-mute.png';
return typeof appPath === 'function' ? appPath(imgPath) : imgPath;
}
function setVoiceButtonIcon(btn, micOn) {
if (!btn) return;
var img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img');
if (img) { img.src = getVoiceIconPath(micOn); img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; }
btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด';
btn.setAttribute('aria-label', micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด');
}
function bindVoiceButton() {
var btnVoice = document.getElementById('btn-voice');
if (!btnVoice) return;
var micOn = false;
try { micOn = localStorage.getItem(VOICE_STATE_KEY) === '1'; } catch (e) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
btnVoice.addEventListener('click', function () {
micOn = !micOn;
try { localStorage.setItem(VOICE_STATE_KEY, micOn ? '1' : '0'); } catch (e2) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
});
}
function buildGrid() {
if (!grid) return;
grid.innerHTML = '';
for (var i = 1; i <= 10; i++) {
var card = document.createElement('div');
card.className = 'qb-card';
card.setAttribute('data-room', String(i));
card.setAttribute('role', 'listitem');
card.setAttribute('aria-label', 'ห้อง ' + i);
var roomImg = document.createElement('img');
roomImg.className = 'qb-card-room';
roomImg.src = QB_IMAGE + '/Room-' + pad2(i) + '.png';
roomImg.alt = '';
roomImg.decoding = 'async';
var startBtn = document.createElement('button');
startBtn.type = 'button';
startBtn.className = 'qb-card-start';
startBtn.setAttribute('aria-label', 'เริ่มเกมห้อง ' + i);
startBtn.addEventListener('click', (function (roomNo) {
return function (ev) { ev.preventDefault(); ev.stopPropagation(); startQuizForRoom(roomNo); };
})(i));
var startImg = document.createElement('img');
startImg.src = QB_IMAGE + '/btn-start-quiz.png';
startImg.alt = 'เริ่มเกม';
startImg.decoding = 'async';
startBtn.appendChild(startImg);
var countEl = document.createElement('span');
countEl.className = 'qb-card-count';
countEl.id = 'qb-count-' + i;
countEl.textContent = '0/' + QB_MAX_PLAYERS;
card.appendChild(roomImg);
card.appendChild(startBtn);
card.appendChild(countEl);
grid.appendChild(card);
}
setToast('กดเริ่มเกมในห้องเพื่อเข้าฉากเดินตอบคำถาม');
}
function syncMainScale() {
var mainEl = document.querySelector('.qb-main');
if (!mainEl) return;
var availableW = Math.max(1, mainEl.clientWidth);
var availableH = Math.max(1, mainEl.clientHeight);
var baseWidth = (271 * 5) + (18 * 4);
var baseHeight = 60 + 151 + 12 + (351 * 2) + 14 + 8 + 24;
var scaleW = availableW / baseWidth;
var scaleH = availableH / baseHeight;
var scale = Math.max(0.22, Math.min(1, Math.min(scaleW, scaleH)));
mainEl.style.setProperty('--qb-main-scale', scale.toFixed(4));
}
function syncUiScale() {
var uiEl = document.querySelector('.qb-ui');
if (!uiEl) return;
var vw = Math.max(1, window.innerWidth || 0);
var vh = Math.max(1, window.innerHeight || 0);
var scale = Math.max(0.34, Math.min(1, Math.min(vw / 1920, vh / 1080)));
uiEl.style.setProperty('--qb-ui-scale', scale.toFixed(4));
}
document.getElementById('btn-back')?.addEventListener('click', function () {
window.location.href = typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/';
});
applyProfileAvatar();
buildGrid();
bindVoiceButton();
refreshRoomCounts();
setInterval(refreshRoomCounts, 5000);
syncUiScale();
syncMainScale();
window.addEventListener('resize', function () { syncUiScale(); syncMainScale(); });
window.addEventListener('orientationchange', function () {
setTimeout(function () { syncUiScale(); syncMainScale(); }, 120);
});
})();
(function () {
'use strict';
var QB_IMAGE = typeof appPath === 'function' ? appPath('/Quiz-Battle/IMAGE') : '/Quiz-Battle/IMAGE';
var BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
var CHAR_KEY = 'gameCharacterId';
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
var VOICE_STATE_KEY = 'qbVoiceMicOn';
/* ฉากเดินตอบแบบ ZEP — แต่ละห้อง(หัวข้อ) = 1 space บนแผนที่ quiz_battle ของหัวข้อนั้น (qbroom1..10) */
function mapIdForRoom(n) { return 'qbroom' + n; }
var QB_MAX_PLAYERS = 50; /* เพดานแข็ง (fallback ก่อน config โหลด) */
var ROOM_NAME_PREFIX = 'Quiz Battle ห้อง ';
/* [2026-07-23] จำนวนคนต่อห้องปรับได้จาก Admin — เก็บค่าจริงต่อห้อง (เติมจาก refreshRoomModes) */
var qbRoomMax = {};
function roomMax(n) { var v = qbRoomMax[String(n)]; return (v >= 1) ? Math.min(50, v) : QB_MAX_PLAYERS; }
/* [2026-07-23] (++) Admin
default = าเด (โชวนทอน config โหลด) · เม refreshRoomModes ได title จาก /api เขยนท */
var DEFAULT_TITLES = ['', 'กฎหมายใกล้ตัว', 'กฎหมายสิทธิพื้นฐาน', 'กฎหมายจราจร', 'คดีเกี่ยวกับทรัพย์', 'คดีหมิ่นประมาท', 'คดีทางเพศ', 'คดีอาชญากรรม', 'อาชญากรรมออนไลน์', 'กระบวนการยุติธรรม', 'งานบริการกระทรวงยุติธรรม'];
function roomTitle(n) {
var m = qbModeState[String(n)];
if (m && typeof m.title === 'string' && m.title.trim()) return m.title.trim();
return DEFAULT_TITLES[n] || ('ห้อง ' + n);
}
function applyRoomTitle(n) {
var el = document.getElementById('qb-title-' + n);
if (el) { var t = roomTitle(n); if (el.textContent !== t) el.textContent = t; }
}
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
return;
}
var grid = document.getElementById('qb-grid');
var toast = document.getElementById('qb-toast');
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function setToast(msg) { if (toast) toast.textContent = msg || ''; }
function getSelectedCharacterId() {
try { return (localStorage.getItem(CHAR_KEY) || '').trim(); } catch (e) { return ''; }
}
function getPlayerName() {
var keys = ['gameNickname', 'nickname', 'displayName', 'playerName', 'userName'];
for (var i = 0; i < keys.length; i++) {
try { var v = (localStorage.getItem(keys[i]) || '').trim(); if (v) return v.slice(0, 24); } catch (e) { /* ignore */ }
}
return 'ผู้เล่น' + Math.floor(1000 + Math.random() * 9000);
}
function characterDownUrls(id) {
var enc = encodeURIComponent(id);
return [BASE + '/img/characters/' + enc + '_down.png', BASE + '/img/characters/' + enc + '_down_0.png'];
}
function renderAvatarForId(av, cid, fallbackAvatar) {
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
av.onerror = function () { av.onerror = null; av.src = fallbackAvatar; };
av.src = savedLobbyAvatar;
return;
}
} catch (e0) { /* ignore */ }
var urls = characterDownUrls(cid);
var avStep = 0;
av.onerror = function () {
avStep += 1;
if (avStep === 1) av.src = urls[1];
else { av.onerror = null; av.src = fallbackAvatar; }
};
av.src = urls[0];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (cid) { renderAvatarForId(av, cid, fallbackAvatar); return; }
// ไม่มี id ใน localStorage → ดึงจาก API เหมือน lobby (กัน avatar ไม่ขึ้น/ขึ้นรูป default)
fetch(BASE + '/api/characters', { credentials: 'same-origin', cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (list) {
if (!Array.isArray(list) || !list.length) { av.onerror = null; av.src = fallbackAvatar; return; }
var last = list[list.length - 1];
var id = last && last.id ? String(last.id).trim() : '';
if (!id) { av.onerror = null; av.src = fallbackAvatar; return; }
renderAvatarForId(av, id, fallbackAvatar);
})
.catch(function () { av.onerror = null; av.src = fallbackAvatar; });
}
/* ===================== เข้าฉากเดิน (ZEP walkable) ===================== */
var entering = false;
function roomName(n) { return ROOM_NAME_PREFIX + n; }
function gotoPlay(spaceId) {
var nick = getPlayerName();
var url = BASE + '/play.html?space=' + encodeURIComponent(spaceId) +
'&map=' + encodeURIComponent(mapIdForRoom(currentRoomNo)) +
'&nick=' + encodeURIComponent(nick);
try { localStorage.setItem('lastCreatedSpaceName', roomName(currentRoomNo)); } catch (e) { /* ignore */ }
window.location.href = url;
}
var currentRoomNo = 0;
/** หา space สาธารณะของห้องนี้ที่ยังมีคนอยู่ ถ้าไม่มีให้สร้างใหม่ แล้วเข้าเล่น */
function startQuizForRoom(n) {
if (entering) return;
/* โหมดถูกปิด/หมดเวลา → ไม่ต้องสร้างห้องเปล่าทิ้งไว้ (server ก็ปฏิเสธอยู่ดี) */
if (!roomIsOpen(n)) {
var mm = qbModeState[String(n)] || {};
setToast(mm.expired ? ('ห้องนี้หมดเวลาเล่นแล้ว (ถึง ' + (mm.openUntil || '') + ' น.)') : 'ห้องนี้ปิดชั่วคราว');
return;
}
entering = true;
currentRoomNo = n;
setToast('กำลังเข้าห้อง ' + n + '…');
var wantName = roomName(n);
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var found = null;
if (Array.isArray(list)) {
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].spaceName === wantName && (list[i].peerCount || 0) < roomMax(n)) { found = list[i]; break; }
}
}
if (found) { gotoPlay(found.spaceId); return null; }
// สร้างห้องใหม่บนแผนที่ quiz_battle (server บังคับ cap ตาม config อีกชั้นตอน join)
return fetch(BASE + '/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mapId: mapIdForRoom(n), name: wantName, maxPlayers: roomMax(n) })
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.ok && res.spaceId) { gotoPlay(res.spaceId); }
else { setToast((res && res.error) || 'สร้างห้องไม่สำเร็จ'); entering = false; }
});
})
.catch(function () { setToast('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ ลองอีกครั้ง'); entering = false; });
}
/** อัปเดตจำนวนคนในแต่ละห้องจากรายชื่อ space (poll) */
function refreshRoomCounts() {
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var counts = {};
if (Array.isArray(list)) {
list.forEach(function (s) {
if (!s || !s.spaceName) return;
if (s.spaceName.indexOf(ROOM_NAME_PREFIX) === 0) {
var n = parseInt(s.spaceName.slice(ROOM_NAME_PREFIX.length), 10);
if (n >= 1 && n <= 10) counts[n] = (s.peerCount || 0);
}
});
}
for (var i = 1; i <= 10; i++) {
var el = document.getElementById('qb-count-' + i);
if (el) el.textContent = (counts[i] || 0) + '/' + roomMax(i);
}
})
.catch(function () { /* keep last */ });
}
function getVoiceIconPath(micOn) {
var imgPath = micOn ? '/Game/img/btn-mic-on.png' : '/Game/img/btn-mic-mute.png';
return typeof appPath === 'function' ? appPath(imgPath) : imgPath;
}
function setVoiceButtonIcon(btn, micOn) {
if (!btn) return;
var img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img');
if (img) { img.src = getVoiceIconPath(micOn); img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; }
btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด';
btn.setAttribute('aria-label', micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด');
}
function bindVoiceButton() {
var btnVoice = document.getElementById('btn-voice');
if (!btnVoice) return;
var micOn = false;
try { micOn = localStorage.getItem(VOICE_STATE_KEY) === '1'; } catch (e) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
btnVoice.addEventListener('click', function () {
micOn = !micOn;
try { localStorage.setItem(VOICE_STATE_KEY, micOn ? '1' : '0'); } catch (e2) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
});
}
function buildGrid() {
if (!grid) return;
grid.innerHTML = '';
for (var i = 1; i <= 10; i++) {
var card = document.createElement('div');
card.className = 'qb-card';
card.setAttribute('data-room', String(i));
card.setAttribute('role', 'listitem');
card.setAttribute('aria-label', 'ห้อง ' + i);
/* [2026-07-23] : (room-frame) + (room-icon-N) + ()
แทน Room-NN.png เดมทงชอในร เปลยนชอตาม Admin ได */
var roomImg = document.createElement('img');
roomImg.className = 'qb-card-room';
roomImg.src = QB_IMAGE + '/room-frame.png';
roomImg.alt = '';
roomImg.decoding = 'async';
roomImg.loading = 'lazy'; /* [2026-07-23] การ์ด 10 ห้อง — บนมือถือเห็นไม่ครบจอ โหลดเท่าที่เห็นพอ */
var iconImg = document.createElement('img');
iconImg.className = 'qb-card-icon';
iconImg.src = QB_IMAGE + '/room-icon-' + i + '.png';
iconImg.alt = '';
iconImg.decoding = 'async';
iconImg.loading = 'lazy';
var titleEl = document.createElement('div');
titleEl.className = 'qb-card-title';
titleEl.id = 'qb-title-' + i;
titleEl.textContent = roomTitle(i);
var startBtn = document.createElement('button');
startBtn.type = 'button';
startBtn.className = 'qb-card-start';
startBtn.setAttribute('aria-label', 'เริ่มเกมห้อง ' + i);
startBtn.addEventListener('click', (function (roomNo) {
return function (ev) { ev.preventDefault(); ev.stopPropagation(); startQuizForRoom(roomNo); };
})(i));
var startImg = document.createElement('img');
startImg.src = QB_IMAGE + '/btn-start-quiz.png';
startImg.alt = 'เริ่มเกม';
startImg.decoding = 'async';
startImg.loading = 'lazy';
startBtn.appendChild(startImg);
var countEl = document.createElement('span');
countEl.className = 'qb-card-count';
countEl.id = 'qb-count-' + i;
countEl.textContent = '0/' + roomMax(i);
/* ป้ายสถานะโหมด (ปิด / หมดเวลา / ถึงวันที่…) — เติมค่าโดย refreshRoomModes() */
var lockEl = document.createElement('span');
lockEl.className = 'qb-card-lock';
lockEl.id = 'qb-lock-' + i;
lockEl.hidden = true;
card.appendChild(roomImg);
card.appendChild(iconImg);
card.appendChild(titleEl);
card.appendChild(startBtn);
card.appendChild(countEl);
card.appendChild(lockEl);
grid.appendChild(card);
}
setToast('กดเริ่มเกมในห้องเพื่อเข้าฉากเดินตอบคำถาม');
}
/* ===== / + () [2026-07-22] =====
วตดสนจรงอย server (join-space ปฏเสธหองท) ตรงนแคบอกผเลนใหอนกด */
var qbModeState = {};
function roomIsOpen(n) {
var m = qbModeState[String(n)];
return !m || m.open !== false;
}
function applyRoomModeUi(n, m) {
var card = grid && grid.querySelector('.qb-card[data-room="' + n + '"]');
var lock = document.getElementById('qb-lock-' + n);
var btn = card && card.querySelector('.qb-card-start');
var open = !m || m.open !== false;
if (card) card.classList.toggle('is-locked', !open);
if (btn) {
btn.disabled = !open;
btn.setAttribute('aria-disabled', open ? 'false' : 'true');
}
if (!lock) return;
if (open) {
if (m && m.openUntil) { lock.hidden = false; lock.className = 'qb-card-lock is-until'; lock.textContent = 'ถึง ' + m.openUntil + ' น.'; }
else { lock.hidden = true; lock.textContent = ''; }
} else {
lock.hidden = false;
lock.className = 'qb-card-lock is-closed';
lock.textContent = (m && m.expired) ? ('หมดเวลาแล้ว · ' + (m.openUntil || '')) : 'ปิดชั่วคราว';
}
}
function refreshRoomModes() {
fetch(BASE + '/api/quiz-battle-modes', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j || !j.modes) return;
qbModeState = j.modes;
for (var i = 1; i <= 10; i++) {
var mm = j.modes[String(i)] || {};
if (mm.maxPlayers >= 1) qbRoomMax[String(i)] = Math.min(50, mm.maxPlayers | 0);
applyRoomModeUi(i, mm);
applyRoomTitle(i); /* [2026-07-23] ชื่อห้องจาก Admin → เขียนทับข้อความบนการ์ด */
/* อัปเดตป้าย X/max ให้ตัวหารตรง config (ตัวเศษ = คนจริง อัปโดย refreshRoomCounts) */
var cEl = document.getElementById('qb-count-' + i);
if (cEl) { var cur = (cEl.textContent || '').split('/')[0] || '0'; cEl.textContent = cur + '/' + roomMax(i); }
}
})
.catch(function () { /* อ่านไม่ได้ → ปล่อยเปิดไว้ ให้ server เป็นคนปฏิเสธเอง */ });
}
function syncMainScale() {
var mainEl = document.querySelector('.qb-main');
if (!mainEl) return;
var availableW = Math.max(1, mainEl.clientWidth);
var availableH = Math.max(1, mainEl.clientHeight);
var baseWidth = (271 * 5) + (18 * 4);
var baseHeight = 60 + 151 + 12 + (351 * 2) + 14 + 8 + 24;
var scaleW = availableW / baseWidth;
var scaleH = availableH / baseHeight;
var scale = Math.max(0.22, Math.min(1, Math.min(scaleW, scaleH)));
mainEl.style.setProperty('--qb-main-scale', scale.toFixed(4));
}
function syncUiScale() {
var uiEl = document.querySelector('.qb-ui');
if (!uiEl) return;
var vw = Math.max(1, window.innerWidth || 0);
var vh = Math.max(1, window.innerHeight || 0);
var scale = Math.max(0.34, Math.min(1, Math.min(vw / 1920, vh / 1080)));
uiEl.style.setProperty('--qb-ui-scale', scale.toFixed(4));
}
document.getElementById('btn-back')?.addEventListener('click', function () {
window.location.href = typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/';
});
applyProfileAvatar();
buildGrid();
bindVoiceButton();
refreshRoomCounts();
setInterval(refreshRoomCounts, 5000);
refreshRoomModes();
setInterval(refreshRoomModes, 30000); /* เผื่อแอดมินปิดโหมดระหว่างที่ค้างหน้านี้อยู่ */
syncUiScale();
syncMainScale();
window.addEventListener('resize', function () { syncUiScale(); syncMainScale(); });
window.addEventListener('orientationchange', function () {
setTimeout(function () { syncUiScale(); syncMainScale(); }, 120);
});
})();
+69
View File
@@ -257,6 +257,43 @@ body {
user-select: none;
}
/* [2026-07-23] การ์ดประกอบเอง: ไอคอน (วางบน) + ชื่อเป็นข้อความ (เปลี่ยนตาม Admin) */
.qb-card-icon {
position: absolute;
left: 50%;
top: calc(52px * var(--qb-main-scale)); /* ~15% ของการ์ด 351px */
transform: translateX(-50%);
width: calc(150px * var(--qb-main-scale));
height: calc(150px * var(--qb-main-scale));
object-fit: contain;
pointer-events: none;
user-select: none;
z-index: 2;
}
.qb-card-title {
position: absolute;
left: 50%;
top: calc(214px * var(--qb-main-scale)); /* ~61% — ใต้ไอคอน ตรงกับแถบชื่อใน mock เดิม */
transform: translateX(-50%);
width: calc(240px * var(--qb-main-scale));
text-align: center;
font-family: 'NotoSansThaiMedium', 'NotoSansThai', 'Kanit', 'Sarabun', system-ui, sans-serif;
font-size: calc(24px * var(--qb-main-scale));
font-weight: 600;
line-height: 1.1;
color: #ffffff;
text-shadow:
0 0 calc(12px * var(--qb-main-scale)) rgba(87, 217, 255, 0.7),
0 0 calc(3px * var(--qb-main-scale)) rgba(0, 0, 0, 0.7);
pointer-events: none;
user-select: none;
z-index: 3;
/* ชื่อยาว (เช่น "งานบริการกระทรวงยุติธรรม") → ย่อลงพอดี ไม่ล้นการ์ด */
overflow-wrap: break-word;
word-break: break-word;
}
.qb-card-start {
position: absolute;
left: 50%;
@@ -634,3 +671,35 @@ body {
/* ===== โหมดที่แอดมินปิด / มีวันหมดเขต (เวลาไทย) [2026-07-22] ===== */
.qb-card-lock {
position: absolute;
left: 50%;
bottom: calc(96px * var(--qb-main-scale));
transform: translateX(-50%);
max-width: calc(230px * var(--qb-main-scale));
padding: calc(6px * var(--qb-main-scale)) calc(12px * var(--qb-main-scale));
border-radius: 999px;
font-family: 'NotoSansThaiMedium', 'NotoSansThai', 'Kanit', 'Sarabun', system-ui, sans-serif;
font-size: calc(17px * var(--qb-main-scale));
line-height: 1.15;
text-align: center;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.qb-card-lock.is-until {
background: rgba(8, 20, 40, 0.82);
border: 1px solid rgba(120, 220, 255, 0.55);
color: #bfe9ff;
}
.qb-card-lock.is-closed {
background: rgba(48, 8, 18, 0.9);
border: 1px solid rgba(255, 120, 150, 0.75);
color: #ffc2d0;
}
/* ห้องที่ปิด: หรี่ทั้งใบ + ปุ่มกดไม่ได้ (server ปฏิเสธซ้ำอีกชั้นอยู่แล้ว) */
.qb-card.is-locked .qb-card-room { filter: grayscale(0.85) brightness(0.5); }
.qb-card.is-locked .qb-card-start { filter: grayscale(1) brightness(0.55); cursor: not-allowed; }
.qb-card.is-locked .qb-card-start:hover { transform: none; }
File diff suppressed because it is too large Load Diff