diff --git a/www/html/Admin/admin.css b/www/html/Admin/admin.css
index 988d16e..658c308 100644
--- a/www/html/Admin/admin.css
+++ b/www/html/Admin/admin.css
@@ -892,12 +892,14 @@ code {
border: 1px solid var(--border);
}
-.input-coins {
+.input-coins,
+.input-score {
width: 5.5rem;
min-width: 0;
}
-.btn-coins-save {
+.btn-coins-save,
+.btn-score-save {
font-size: 0.74rem;
padding: 0.32rem 0.55rem;
margin-top: 0.3rem;
diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js
index be67fea..c7e1f8f 100644
--- a/www/html/Admin/admin.js
+++ b/www/html/Admin/admin.js
@@ -3895,6 +3895,11 @@
var bb = Number(data.balloonBossMissionTimeSec);
inpT.value = String(Number.isFinite(bb) && bb > 0 ? Math.floor(bb) : 0);
}
+ var inpBp = el('mega-virus-balloons-per-player');
+ if (inpBp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBalloonsPerPlayer')) {
+ var bp = Number(data.balloonBossBalloonsPerPlayer);
+ inpBp.value = String(Number.isFinite(bp) && bp > 0 ? Math.min(12, Math.floor(bp)) : 0);
+ }
var bossInp = el('mega-virus-boss-url');
if (bossInp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBossImageUrl')) {
bossInp.value = data.balloonBossBossImageUrl != null ? String(data.balloonBossBossImageUrl) : '';
@@ -3939,12 +3944,16 @@
var fbUrl = normalizeMegaVirusAssetUrl(el('mega-virus-balloon-fallback-url') && el('mega-virus-balloon-fallback-url').value
? String(el('mega-virus-balloon-fallback-url').value).trim()
: '');
+ var balloonsPer = el('mega-virus-balloons-per-player') ? parseInt(String(el('mega-virus-balloons-per-player').value), 10) : 0;
+ if (Number.isNaN(balloonsPer) || balloonsPer < 0) balloonsPer = 0;
+ balloonsPer = Math.max(0, Math.min(12, balloonsPer));
gameTimingFetch('GET')
.then(function (data) {
data.balloonBossMissionTimeSec = limSs;
data.balloonBossBossImageUrl = bossUrl;
data.balloonBossPlayerBalloonImageUrls = balloonUrls;
data.balloonBossPlayerBalloonFallbackUrl = fbUrl;
+ data.balloonBossBalloonsPerPlayer = balloonsPer;
return gameTimingFetch('PUT', data);
})
.then(function (res) {
@@ -4477,6 +4486,7 @@
if (name === 'mega-virus') loadMegaVirusPanel();
if (name === 'game-timing') loadGameTimingPanel();
if (name === 'stack-game') loadStackGamePanel();
+ if (name === 'highscore') loadHighscore();
}
function boot() {
@@ -4902,6 +4912,71 @@
});
}
+ /* ===== กระดานผู้นำ (High Score) ===== */
+ function renderHighscore(rows) {
+ var tbEl = el('table-highscore');
+ if (!tbEl) return;
+ var tb = tbEl.querySelector('tbody');
+ tb.innerHTML = '';
+ (rows || []).forEach(function (r) {
+ var rank = parseInt(r.rank, 10) || 0;
+ var score = Math.max(0, parseInt(r.score, 10) || 0);
+ var coins = Math.max(0, parseInt(r.coins, 10) || 0);
+ var medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ('#' + rank);
+ var tr = document.createElement('tr');
+ tr.innerHTML =
+ '
' + medal + ' | ' +
+ '' + escapeHtml(r.name || 'ผู้เล่น') + (r.blocked ? ' บล็อก' : '') + ' | ' +
+ '' +
+ '' +
+ '' +
+ ' | ' +
+ '' + coins + ' | ' +
+ ' | ';
+ tb.appendChild(tr);
+ });
+ tb.querySelectorAll('.btn-score-save').forEach(function (b) {
+ b.addEventListener('click', function () {
+ var id = b.getAttribute('data-id');
+ var row = b.closest('tr');
+ var inp = row ? row.querySelector('input.input-score') : null;
+ var score = Math.max(0, parseInt(inp && inp.value, 10) || 0);
+ api('accounts.php', { method: 'PATCH', body: { id: id, score: score } })
+ .then(function () { setMsg('highscore-msg', 'บันทึกคะแนนแล้ว', 'ok'); return loadHighscore(); })
+ .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); });
+ });
+ });
+ tb.querySelectorAll('.btn-score-reset').forEach(function (b) {
+ b.addEventListener('click', function () {
+ var id = b.getAttribute('data-id');
+ if (!confirm('รีเซ็ตคะแนนคนนี้เป็น 0?')) return;
+ api('leaderboard-admin.php', { method: 'POST', body: { action: 'reset', id: id } })
+ .then(function () { return loadHighscore(); })
+ .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); });
+ });
+ });
+ }
+
+ function loadHighscore() {
+ return api('leaderboard-admin.php').then(function (r) {
+ renderHighscore(r.rows);
+ var n = (r.rows && r.rows.length) || 0;
+ setMsg('highscore-msg', n ? ('ผู้เล่นมีคะแนน ' + r.total + ' คน') : 'ยังไม่มีผู้เล่นทำคะแนน', '');
+ }).catch(function (err) { setMsg('highscore-msg', err.message, 'error'); });
+ }
+
+ (function bindHighscoreControls() {
+ var refresh = el('btn-highscore-refresh');
+ if (refresh) refresh.addEventListener('click', function () { loadHighscore(); });
+ var resetAll = el('btn-highscore-reset-all');
+ if (resetAll) resetAll.addEventListener('click', function () {
+ if (!confirm('รีเซ็ตคะแนนสะสมของผู้เล่นทุกคนเป็น 0? (ย้อนกลับไม่ได้)')) return;
+ api('leaderboard-admin.php', { method: 'POST', body: { action: 'resetAll' } })
+ .then(function (r) { setMsg('highscore-msg', 'รีเซ็ตแล้ว ' + (r.reset || 0) + ' คน', 'ok'); return loadHighscore(); })
+ .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); });
+ });
+ })();
+
el('form-account-add').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(e.target);
diff --git a/www/html/Admin/api/accounts.php b/www/html/Admin/api/accounts.php
index a1804da..aa78d24 100644
--- a/www/html/Admin/api/accounts.php
+++ b/www/html/Admin/api/accounts.php
@@ -8,6 +8,7 @@ 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;
}
@@ -80,6 +81,9 @@ if ($method === 'PATCH') {
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']);
diff --git a/www/html/Admin/api/game-award.php b/www/html/Admin/api/game-award.php
new file mode 100644
index 0000000..c3c50b1
--- /dev/null
+++ b/www/html/Admin/api/game-award.php
@@ -0,0 +1,119 @@
+",
+ * "awards": [ { "playerKey": "...", "nickname": "...", "coins": 10 }, ... ]
+ * }
+ * - บวก coins (ใช้จ่ายได้) และ score (สะสมถาวร ไม่ลด) เท่ากับจำนวนเหรียญที่ได้
+ * - cap ต่อรายการ 0–100 กันยิงมั่ว
+ */
+require __DIR__ . '/_common.php';
+
+date_default_timezone_set('Asia/Bangkok');
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
+ json_response(['ok' => false, 'error' => 'Use POST'], 405);
+}
+
+$secretFile = ADMIN_PRIVATE_DIR . '/game-award-secret.txt';
+$expected = is_file($secretFile) ? trim((string) @file_get_contents($secretFile)) : '';
+
+$body = require_json_body();
+$secret = (string) ($body['secret'] ?? '');
+if ($expected === '' || strlen($secret) < 16 || !hash_equals($expected, $secret)) {
+ json_response(['ok' => false, 'error' => 'unauthorized'], 403);
+}
+
+$awards = (isset($body['awards']) && is_array($body['awards'])) ? $body['awards'] : [];
+if (!$awards) {
+ json_response(['ok' => true, 'updated' => 0]);
+}
+
+$store = read_store();
+if (!isset($store['accounts']) || !is_array($store['accounts'])) {
+ $store['accounts'] = [];
+}
+
+/* index บัญชี guest ตาม playerKey */
+$byKey = [];
+foreach ($store['accounts'] as $i => $a) {
+ if (($a['loginType'] ?? '') === 'guest') {
+ $byKey[(string) ($a['providerUserId'] ?? '')] = $i;
+ }
+}
+
+$updated = 0;
+foreach ($awards as $aw) {
+ if (!is_array($aw)) {
+ continue;
+ }
+ $key = trim((string) ($aw['playerKey'] ?? ''));
+ if (!preg_match('/^[a-zA-Z0-9_-]{8,128}$/', $key)) {
+ continue;
+ }
+ $coins = (int) ($aw['coins'] ?? 0);
+ if ($coins < 0) $coins = 0;
+ if ($coins > 100) $coins = 100;
+ $nick = trim((string) ($aw['nickname'] ?? ''));
+ if (function_exists('mb_substr')) {
+ $nick = $nick !== '' ? mb_substr($nick, 0, 24) : '';
+ } else {
+ $nick = $nick !== '' ? substr($nick, 0, 24) : '';
+ }
+
+ $caseId = preg_replace('/[^0-9]/', '', (string) ($aw['caseId'] ?? ''));
+ $noScore = !empty($aw['noScore']); // Fund ฯลฯ: บวกแค่ coins ไม่บวก high-score
+
+ if (isset($byKey[$key])) {
+ $i = $byKey[$key];
+ if (!empty($store['accounts'][$i]['blocked'])) {
+ continue;
+ }
+ $store['accounts'][$i]['coins'] = max(0, (int) ($store['accounts'][$i]['coins'] ?? 0)) + $coins;
+ if (!$noScore) {
+ $store['accounts'][$i]['score'] = max(0, (int) ($store['accounts'][$i]['score'] ?? 0)) + $coins;
+ }
+ if (!$noScore && $caseId !== '') {
+ if (!isset($store['accounts'][$i]['scoreByCase']) || !is_array($store['accounts'][$i]['scoreByCase'])) {
+ $store['accounts'][$i]['scoreByCase'] = [];
+ }
+ $store['accounts'][$i]['scoreByCase'][$caseId] = max(0, (int) ($store['accounts'][$i]['scoreByCase'][$caseId] ?? 0)) + $coins;
+ }
+ if ($nick !== '') {
+ $store['accounts'][$i]['lbName'] = $nick;
+ if (($store['accounts'][$i]['displayName'] ?? '') === 'Guest' || ($store['accounts'][$i]['displayName'] ?? '') === '') {
+ $store['accounts'][$i]['displayName'] = $nick;
+ }
+ }
+ $store['accounts'][$i]['updatedAt'] = gmdate('c');
+ } else {
+ $store['accounts'][] = [
+ 'id' => new_id(),
+ 'email' => '',
+ 'displayName' => ($nick !== '' ? $nick : 'Guest'),
+ 'loginType' => 'guest',
+ 'providerUserId' => $key,
+ 'notes' => 'auto: game-award',
+ 'blocked' => false,
+ 'coins' => $coins,
+ 'score' => ($noScore ? 0 : $coins),
+ 'scoreByCase' => ((!$noScore && $caseId !== '') ? [$caseId => $coins] : []),
+ 'lbName' => $nick,
+ 'createdAt' => gmdate('c'),
+ 'updatedAt' => gmdate('c'),
+ ];
+ $byKey[$key] = count($store['accounts']) - 1;
+ }
+ $updated++;
+}
+
+if (!write_store($store)) {
+ json_response(['ok' => false, 'error' => 'บันทึกไม่สำเร็จ'], 500);
+}
+
+json_response(['ok' => true, 'updated' => $updated]);
diff --git a/www/html/Admin/api/leaderboard-admin.php b/www/html/Admin/api/leaderboard-admin.php
new file mode 100644
index 0000000..ef911a0
--- /dev/null
+++ b/www/html/Admin/api/leaderboard-admin.php
@@ -0,0 +1,94 @@
+ (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);
diff --git a/www/html/Admin/api/leaderboard.php b/www/html/Admin/api/leaderboard.php
new file mode 100644
index 0000000..503b879
--- /dev/null
+++ b/www/html/Admin/api/leaderboard.php
@@ -0,0 +1,77 @@
+
+ * คืน { ok, total, top: [ { rank, name, score, coins } ], me?: { rank, name, score } }
+ * จัดอันดับจากค่า score สะสม (ไม่ลดเมื่อใช้เหรียญ)
+ */
+require __DIR__ . '/_common.php';
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
+ json_response(['ok' => false, 'error' => 'Use GET'], 405);
+}
+
+$limit = (int) ($_GET['limit'] ?? 50);
+if ($limit < 1) $limit = 1;
+if ($limit > 200) $limit = 200;
+
+$caseId = preg_replace('/[^0-9]/', '', (string) ($_GET['caseId'] ?? ''));
+
+$store = read_store();
+$accounts = (isset($store['accounts']) && is_array($store['accounts'])) ? $store['accounts'] : [];
+
+$rows = [];
+foreach ($accounts as $a) {
+ if ($caseId !== '') {
+ $score = max(0, (int) (($a['scoreByCase'][$caseId] ?? 0)));
+ } else {
+ $score = max(0, (int) ($a['score'] ?? 0));
+ }
+ if ($score <= 0) {
+ continue;
+ }
+ $name = trim((string) ($a['lbName'] ?? ''));
+ if ($name === '') $name = trim((string) ($a['displayName'] ?? ''));
+ if ($name === '' || $name === 'Guest') $name = 'ผู้เล่น';
+ $rows[] = [
+ 'name' => $name,
+ 'score' => $score,
+ 'coins' => max(0, (int) ($a['coins'] ?? 0)),
+ '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']);
+});
+
+$total = count($rows);
+
+$me = null;
+$myKey = trim((string) ($_GET['playerKey'] ?? ''));
+if ($myKey !== '') {
+ for ($i = 0; $i < $total; $i++) {
+ if ($rows[$i]['key'] === $myKey) {
+ $me = ['rank' => $i + 1, 'name' => $rows[$i]['name'], 'score' => $rows[$i]['score'], 'coins' => $rows[$i]['coins']];
+ break;
+ }
+ }
+}
+
+$top = [];
+$n = min($limit, $total);
+for ($i = 0; $i < $n; $i++) {
+ $top[] = [
+ 'rank' => $i + 1,
+ 'name' => $rows[$i]['name'],
+ 'score' => $rows[$i]['score'],
+ 'coins' => $rows[$i]['coins'],
+ ];
+}
+
+json_response(['ok' => true, 'total' => $total, 'top' => $top, 'me' => $me]);
diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html
index 939b0fc..a20962a 100644
--- a/www/html/Admin/index.html
+++ b/www/html/Admin/index.html
@@ -118,6 +118,7 @@
ระบบ & บัญชี
+
@@ -775,6 +776,7 @@
+
-
+