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