Commit c0f05e75 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(settings): edit reward_config, the table the game pays from

The settings page edited system_config, which nothing in the game reads.
It now edits reward_config: coins and XP per game and place (read by
complete_match() and domino-match), the seven-day daily reward ladder and
the chess Elo K-factor steps (El3ab includes/game-config.php). All 47 keys
have an Arabic label and group and say whether the game reads them today;
chess draw/loss and backgammon loss rows are marked unpaid because those
games report only the winner to complete_match(). Values are validated
whole numbers stored as plain text (complete_match casts value::int); only
changed rows are written and the change is audited.

system_config is shown read-only for reference, with where each value
really comes from. Its update no longer json_encode()s the value before
PostgREST encodes it again (the cause of elo_k_factor_master = "\"20\"",
repaired in the migration).
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 10af61a4
...@@ -186,6 +186,7 @@ return [ ...@@ -186,6 +186,7 @@ return [
// Settings // Settings
'settings' => ['module' => 'settings', 'action' => 'index'], 'settings' => ['module' => 'settings', 'action' => 'index'],
'settings/update' => ['module' => 'settings', 'action' => 'update'], 'settings/update' => ['module' => 'settings', 'action' => 'update'],
'settings/rewards' => ['module' => 'settings', 'action' => 'updateRewards'],
// Branding // Branding
'branding' => ['module' => 'branding', 'action' => 'index'], 'branding' => ['module' => 'branding', 'action' => 'index'],
......
<?php <?php
/**
* Game settings.
*
* The game reads its economy from reward_config (key => text value):
* complete_match() pays coins and XP per game and place from it, domino-match
* pays its round bonus from it, and the daily-reward ladder and the chess Elo
* K-factor come from it (El3ab includes/game-config.php). This page edits that
* table.
*
* system_config, which this page used to edit, is read by nothing in the game;
* it is shown for reference, read-only, with where each value really lives.
* Its update also used to json_encode() the value before PostgREST encoded it
* again, which is how elo_k_factor_master came to hold "\"20\"" (repaired by
* El3ab migrations/2026-10-26-manager-columns.sql).
*/
class SettingsController class SettingsController
{ {
private Database $db; private Database $db;
/**
* Every reward_config key the platform has: Arabic label, group, unit, and
* whether the game reads it. A key not listed here still shows, under
* "أخرى", so nothing staff add is hidden.
*/
public const REWARDS = [
// Chess: complete_match() is called with the winner only, so only the win row pays.
'chess_win_coins' => ['عملات الفوز', 'الشطرنج', 'coins', true],
'chess_win_xp' => ['خبرة الفوز', 'الشطرنج', 'xp', true],
'chess_draw_coins' => ['عملات التعادل', 'الشطرنج', 'coins', false, 'لا يُصرف: الشطرنج يرسل الفائز فقط إلى complete_match، فالتعادل لا يدفع شيئًا'],
'chess_draw_xp' => ['خبرة التعادل', 'الشطرنج', 'xp', false, 'لا يُصرف للسبب نفسه'],
'chess_loss_coins' => ['عملات الخسارة', 'الشطرنج', 'coins', false, 'لا يُصرف: الخاسر لا يُرسل إلى complete_match'],
'chess_loss_xp' => ['خبرة الخسارة', 'الشطرنج', 'xp', false, 'لا يُصرف للسبب نفسه'],
'backgammon_win_coins' => ['عملات الفوز', 'الطاولة', 'coins', true],
'backgammon_win_xp' => ['خبرة الفوز', 'الطاولة', 'xp', true],
'backgammon_loss_coins' => ['عملات الخسارة', 'الطاولة', 'coins', false, 'لا يُصرف: الطاولة ترسل الفائز فقط'],
'backgammon_loss_xp' => ['خبرة الخسارة', 'الطاولة', 'xp', false, 'لا يُصرف للسبب نفسه'],
'domino_win_coins' => ['عملات المركز الأول', 'الدومينو', 'coins', true],
'domino_win_xp' => ['خبرة المركز الأول', 'الدومينو', 'xp', true],
'domino_2nd_coins' => ['عملات المركز الثاني', 'الدومينو', 'coins', true],
'domino_2nd_xp' => ['خبرة المركز الثاني', 'الدومينو', 'xp', true],
'domino_3rd_coins' => ['عملات المركز الثالث', 'الدومينو', 'coins', true],
'domino_3rd_xp' => ['خبرة المركز الثالث', 'الدومينو', 'xp', true],
'domino_4th_coins' => ['عملات المركز الرابع', 'الدومينو', 'coins', true],
'domino_4th_xp' => ['خبرة المركز الرابع', 'الدومينو', 'xp', true],
'domino_loss_coins' => ['عملات الخسارة (احتياطي)', 'الدومينو', 'coins', true, 'يُستخدم فقط إن غاب صف المركز الرابع'],
'domino_loss_xp' => ['خبرة الخسارة (احتياطي)', 'الدومينو', 'xp', true, 'يُستخدم فقط إن غاب صف المركز الرابع'],
'domino_round_coins' => ['عملات لكل جولة فوز', 'الدومينو', 'coins', true],
'domino_round_xp' => ['خبرة لكل جولة فوز', 'الدومينو', 'xp', true],
'ludo_win_coins' => ['عملات المركز الأول', 'اللودو', 'coins', true],
'ludo_win_xp' => ['خبرة المركز الأول', 'اللودو', 'xp', true],
'ludo_2nd_coins' => ['عملات المركز الثاني', 'اللودو', 'coins', true],
'ludo_2nd_xp' => ['خبرة المركز الثاني', 'اللودو', 'xp', true],
'ludo_3rd_coins' => ['عملات المركز الثالث', 'اللودو', 'coins', true],
'ludo_3rd_xp' => ['خبرة المركز الثالث', 'اللودو', 'xp', true],
'ludo_4th_coins' => ['عملات المركز الأخير', 'اللودو', 'coins', true],
'ludo_4th_xp' => ['خبرة المركز الأخير', 'اللودو', 'xp', true],
'daily_reward_day_1' => ['اليوم الأول', 'المكافأة اليومية', 'coins', true],
'daily_reward_day_2' => ['اليوم الثاني', 'المكافأة اليومية', 'coins', true],
'daily_reward_day_3' => ['اليوم الثالث', 'المكافأة اليومية', 'coins', true],
'daily_reward_day_4' => ['اليوم الرابع', 'المكافأة اليومية', 'coins', true],
'daily_reward_day_5' => ['اليوم الخامس', 'المكافأة اليومية', 'coins', true],
'daily_reward_day_6' => ['اليوم السادس', 'المكافأة اليومية', 'coins', true],
'daily_reward_day_7' => ['اليوم السابع', 'المكافأة اليومية', 'coins', true, 'بعده تبدأ السلسلة من اليوم الأول'],
'daily_reward_base' => ['المكافأة الأساسية (قديم)', 'المكافأة اليومية', 'coins', false, 'لا يُقرأ: الأيام السبعة أعلاه هي ما يُصرف'],
'daily_streak_bonus' => ['زيادة لكل يوم (قديم)', 'المكافأة اليومية', 'coins', false, 'لا يُقرأ'],
'elo_k_factor_default' => ['معامل K الأساسي', 'تقييم الشطرنج', 'k', true],
'elo_k_high_rating' => ['تقييم بداية المعامل المرتفع', 'تقييم الشطرنج', 'rating', true],
'elo_k_factor_high' => ['معامل K فوق هذا التقييم', 'تقييم الشطرنج', 'k', true],
'elo_k_master_rating' => ['تقييم بداية معامل الأساتذة', 'تقييم الشطرنج', 'rating', true],
'elo_k_factor_master' => ['معامل K للأساتذة', 'تقييم الشطرنج', 'k', true],
'abandon_coins' => ['عملات الانسحاب', 'أخرى', 'coins', false, 'لا يُقرأ: المنسحب لا يأخذ شيئًا دائمًا'],
'abandon_xp' => ['خبرة الانسحاب', 'أخرى', 'xp', false, 'لا يُقرأ'],
'challenge_multiplier' => ['مضاعف التحديات لكل مستوى', 'أخرى', 'multiplier', false, 'لا يُقرأ في التطبيق'],
];
public const GROUPS = ['الشطرنج', 'الطاولة', 'الدومينو', 'اللودو', 'المكافأة اليومية', 'تقييم الشطرنج', 'أخرى'];
public const UNITS = ['coins' => 'عملة', 'xp' => 'خبرة', 'k' => 'K', 'rating' => 'تقييم', 'multiplier' => 'ضعف'];
/** Where each system_config value actually comes from in the game. */
public const SYSTEM_SOURCES = [
'coins_per_win_casual' => 'عملات الفوز لكل لعبة في تبويب المكافآت',
'coins_per_win_ranked' => 'عملات الفوز لكل لعبة في تبويب المكافآت',
'xp_per_game' => 'الخبرة لكل لعبة في تبويب المكافآت',
'xp_per_win_bonus' => 'الخبرة لكل لعبة في تبويب المكافآت',
'daily_reward_base' => 'المكافأة اليومية في تبويب المكافآت',
'daily_reward_streak_bonus' => 'المكافأة اليومية في تبويب المكافآت',
'elo_k_factor_new' => 'تقييم الشطرنج في تبويب المكافآت',
'elo_k_factor_established' => 'تقييم الشطرنج في تبويب المكافآت',
'elo_k_factor_master' => 'تقييم الشطرنج في تبويب المكافآت',
'elo_default_rating' => 'القيمة الافتراضية لأعمدة التقييم في جدول اللاعبين (1200)',
'tournament_max_players' => 'الحد الأقصى في كل بطولة على حدة',
'matchmaking_max_wait_sec' => 'ثابت في كود التطبيق',
'matchmaking_range_expansion' => 'ثابت في كود التطبيق',
'anticheat_cpl_threshold' => 'غير مستخدم',
'anticheat_correlation_threshold' => 'غير مستخدم',
'platform_name' => 'غير مستخدم',
'platform_name_ar' => 'غير مستخدم',
];
public function __construct() public function __construct()
{ {
$this->db = Database::getInstance(); $this->db = Database::getInstance();
...@@ -13,22 +115,105 @@ class SettingsController ...@@ -13,22 +115,105 @@ class SettingsController
{ {
Auth::requireRole('superadmin'); Auth::requireRole('superadmin');
$settings = $this->db->select('system_config', [ $rows = $this->db->select('reward_config', ['select' => '*', 'order' => 'key.asc']);
'select' => '*', $rewards = [];
'order' => 'category.asc,key.asc', foreach (self::GROUPS as $g) $rewards[$g] = [];
]); $known = array_keys(self::REWARDS);
foreach ($rows as $r) {
$grouped = []; $meta = self::REWARDS[$r['key']] ?? [$r['key'], 'أخرى', null, false, 'مفتاح غير معروف للوحة'];
foreach ($settings as $s) { $r['label'] = $meta[0];
$grouped[$s['category']][] = $s; $r['unit'] = $meta[2] ? (self::UNITS[$meta[2]] ?? '') : '';
$r['kind'] = $meta[2];
$r['live'] = (bool)$meta[3];
$r['note'] = $meta[4] ?? null;
$r['order'] = array_search($r['key'], $known, true);
$rewards[$meta[1]][] = $r;
} }
foreach ($rewards as &$list) {
usort($list, fn($a, $b) => ($a['order'] === false ? PHP_INT_MAX : $a['order']) <=> ($b['order'] === false ? PHP_INT_MAX : $b['order']));
}
unset($list);
$rewards = array_filter($rewards);
$system = $this->db->select('system_config', ['select' => '*', 'order' => 'category.asc,key.asc']);
$tab = ($_GET['tab'] ?? 'rewards') === 'system' ? 'system' : 'rewards';
$pageTitle = 'الإعدادات'; $pageTitle = 'الإعدادات';
$moduleCSS = 'settings'; $moduleCSS = 'settings';
$moduleJS = 'settings'; $moduleJS = 'settings';
View::render('settings/index', compact('grouped', 'pageTitle', 'moduleCSS', 'moduleJS')); View::render('settings/index', compact('rewards', 'system', 'tab', 'pageTitle', 'moduleCSS', 'moduleJS'));
} }
/**
* Save one group of reward_config values. Only changed values are written.
* Values are whole numbers stored as plain text (complete_match() casts
* value::int), except the multiplier, which is a decimal.
*/
public function updateRewards(array $params, string $method): void
{
Auth::requireCsrf();
Auth::requireRole('superadmin');
$values = (array)($_POST['values'] ?? []);
if (!$values) {
Response::error('لا توجد قيم للحفظ', '/settings');
return;
}
$current = [];
foreach ($this->db->select('reward_config', ['select' => 'key,value']) as $r) $current[$r['key']] = $r['value'];
$adminId = Auth::user()['id'] ?? null;
$changed = [];
foreach ($values as $key => $raw) {
$key = (string)$key;
if (!array_key_exists($key, $current)) continue;
$raw = trim((string)$raw);
$kind = self::REWARDS[$key][2] ?? null;
if ($kind === 'multiplier') {
if (!preg_match('/^\d+(\.\d+)?$/', $raw) || (float)$raw <= 0 || (float)$raw > 100) {
Response::error("قيمة غير صالحة لـ {$key}: رقم موجب مثل 1.1", '/settings');
return;
}
} else {
if (!preg_match('/^\d+$/', $raw) || (int)$raw > 1000000) {
Response::error("قيمة غير صالحة لـ {$key}: عدد صحيح من 0 إلى 1,000,000", '/settings');
return;
}
if ($kind === 'k' && (int)$raw < 1) {
Response::error("معامل K لا يكون صفرًا ({$key})", '/settings');
return;
}
$raw = (string)(int)$raw;
}
if ((string)$current[$key] === $raw) continue;
$ok = $this->db->update('reward_config', ['key' => 'eq.' . $key], [
'value' => $raw,
'updated_by' => $adminId,
'updated_at' => date('c'),
]);
if ($ok === null) {
Response::error('لم يُحفظ ' . $key . ': ' . $this->db->lastErrorMessage(), '/settings');
return;
}
$changed[$key] = ['old' => $current[$key], 'new' => $raw];
}
if (!$changed) {
Response::success('لا تغييرات', '/settings');
return;
}
AuditLog::log('update', 'reward_config', null, null, $changed);
Response::success('تم حفظ ' . count($changed) . ' قيمة. يراها التطبيق خلال دقيقة', '/settings');
}
/**
* Kept for completeness; the page shows system_config read-only. The value
* is written as its own JSON type (number, boolean, text), never a
* pre-encoded string.
*/
public function update(array $params, string $method): void public function update(array $params, string $method): void
{ {
Auth::requireCsrf(); Auth::requireCsrf();
...@@ -38,28 +223,39 @@ class SettingsController ...@@ -38,28 +223,39 @@ class SettingsController
$value = $_POST['value'] ?? ''; $value = $_POST['value'] ?? '';
if (empty($key)) { if (empty($key)) {
Response::error('مفتاح غير صحيح', '/settings'); Response::error('مفتاح غير صحيح', '/settings?tab=system');
return; return;
} }
$setting = $this->db->selectOne('system_config', ['key' => "eq.{$key}"]); $setting = $this->db->selectOne('system_config', ['key' => "eq.{$key}"]);
if (!$setting) { if (!$setting) {
Response::error('الإعداد غير موجود', '/settings'); Response::error('الإعداد غير موجود', '/settings?tab=system');
return; return;
} }
if ($setting['is_secret'] ?? false) { if ($setting['is_secret'] ?? false) {
Response::error('هذا الإعداد محمي', '/settings'); Response::error('هذا الإعداد محمي', '/settings?tab=system');
return; return;
} }
$old = $setting['value']; $old = $setting['value'];
$typed = $value;
if (is_int($old) || is_float($old)) {
if (!is_numeric($value)) {
Response::error('القيمة يجب أن تكون رقمًا', '/settings?tab=system');
return;
}
$typed = str_contains((string)$value, '.') ? (float)$value : (int)$value;
} elseif (is_bool($old)) {
$typed = in_array(strtolower((string)$value), ['1', 'true', 'on', 'yes'], true);
}
$this->db->update('system_config', ['key' => "eq.{$key}"], [ $this->db->update('system_config', ['key' => "eq.{$key}"], [
'value' => json_encode($value), 'value' => $typed,
'updated_at' => date('c'), 'updated_at' => date('c'),
]); ]);
AuditLog::log('update', 'system_config', $key, ['value' => $old], ['value' => $value]); AuditLog::log('update', 'system_config', $key, ['value' => $old], ['value' => $typed]);
Response::success('تم تحديث الإعداد', '/settings'); Response::success('تم تحديث الإعداد', '/settings?tab=system');
} }
} }
<div class="content-header"> <div class="content-header">
<h1>إعدادات النظام</h1> <h1>الإعدادات</h1>
</div> </div>
<?php if (empty($grouped)): ?> <div class="tabs mb-5">
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد إعدادات</h3><p class="empty-state-text">لم يتم تكوين أي إعدادات بعد</p></div></div> <a href="/settings" class="tab <?= $tab === 'rewards' ? 'active' : '' ?>">المكافآت والتقييم</a>
<?php else: ?> <a href="/settings?tab=system" class="tab <?= $tab === 'system' ? 'active' : '' ?>">إعدادات قديمة (مرجع)</a>
</div>
<?php if ($tab === 'rewards'): ?>
<p class="text-sm text-muted mb-4">هذه القيم يقرؤها التطبيق مباشرة (جدول reward_config): العملات والخبرة لكل مباراة حسب المركز، المكافأة اليومية، ومعامل تقييم الشطرنج. القيم المعلّمة "لا يقرؤها التطبيق" محفوظة لكنها لا تغيّر شيئًا حاليًا.</p>
<?php if (empty($rewards)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد قيم</h3><p class="empty-state-text">جدول المكافآت فارغ أو تعذرت قراءته</p></div></div>
<?php endif; ?>
<?php foreach ($rewards as $group => $items): ?>
<div class="card mb-5">
<form method="POST" action="/settings/rewards">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<div class="card-header flex justify-between items-center">
<h3 class="card-title"><?= View::e($group) ?></h3>
<button type="submit" class="btn btn-primary btn-sm">حفظ <?= View::e($group) ?></button>
</div>
<div class="flex flex-col">
<?php foreach ($items as $r): ?>
<div class="flex items-center justify-between p-4 gap-4 flex-wrap" style="border-bottom: 1px solid var(--border);">
<div class="flex-1" style="min-width: 220px;">
<div class="flex items-center gap-2">
<span class="font-medium text-sm"><?= View::e($r['label']) ?></span>
<?php if ($r['live']): ?>
<span class="badge badge-success badge-sm">يقرؤه التطبيق</span>
<?php else: ?>
<span class="badge badge-default badge-sm">لا يقرؤه التطبيق</span>
<?php endif; ?>
</div>
<div class="text-xs text-muted" dir="ltr"><?= View::e($r['key']) ?></div>
<?php if (!empty($r['note'])): ?>
<div class="text-xs text-secondary mt-1"><?= View::e($r['note']) ?></div>
<?php endif; ?>
</div>
<div class="flex items-center gap-2">
<input type="text" inputmode="<?= $r['kind'] === 'multiplier' ? 'decimal' : 'numeric' ?>" name="values[<?= View::e($r['key']) ?>]" class="form-input tabular-nums" style="width: 120px; padding: var(--space-2) var(--space-3);" value="<?= View::e($r['value']) ?>" dir="ltr" aria-label="<?= View::e($r['label']) ?>">
<span class="text-xs text-muted" style="min-width: 36px;"><?= View::e($r['unit']) ?></span>
</div>
</div>
<?php endforeach; ?>
</div>
</form>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($tab === 'system'): ?>
<div class="card mb-5 p-4">
<p class="text-sm">لا يقرأ التطبيق أي قيمة من هذا الجدول (system_config). تُعرض هنا للمرجع فقط، مع المكان الذي تُضبط منه القيمة الفعلية.</p>
</div>
<?php <?php
$categoryLabels = [ $categoryLabels = [
'matchmaking' => 'التوفيق', 'matchmaking' => 'التوفيق بين اللاعبين',
'economy' => 'الاقتصاد', 'economy' => 'الاقتصاد',
'moderation' => 'الإشراف', 'rating' => 'التقييم',
'platform' => 'المنصة', 'anticheat' => 'مكافحة الغش',
'limits' => 'الحدود', 'tournaments' => 'البطولات',
'general' => 'عام', 'general' => 'عام',
]; ];
$grouped = [];
foreach ($system as $s) $grouped[$s['category'] ?? 'general'][] = $s;
?> ?>
<?php foreach ($grouped as $category => $settings): ?> <?php foreach ($grouped as $category => $settings): ?>
<div class="card mb-5"> <div class="card mb-5">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"><?= View::e($categoryLabels[$category] ?? $category) ?></h3> <h3 class="card-title"><?= View::e($categoryLabels[$category] ?? $category) ?></h3>
<span class="badge badge-default"><?= count($settings) ?> إعداد</span>
</div> </div>
<div class="flex flex-col"> <table class="data-table">
<thead><tr><th>المفتاح</th><th>القيمة المحفوظة</th><th>القيمة الفعلية تُضبط من</th></tr></thead>
<tbody>
<?php foreach ($settings as $setting): ?> <?php foreach ($settings as $setting): ?>
<div class="flex items-center justify-between p-4" style="border-bottom: 1px solid var(--border);"> <?php $val = is_array($setting['value']) || is_bool($setting['value']) ? json_encode($setting['value'], JSON_UNESCAPED_UNICODE) : (string)($setting['value'] ?? ''); ?>
<div class="flex-1"> <tr>
<div class="font-medium text-sm"><?= View::e($setting['label_ar'] ?? $setting['label'] ?? $setting['key']) ?></div> <td><span class="text-xs" dir="ltr"><?= View::e($setting['key']) ?></span><?php if (!empty($setting['description'])): ?><div class="text-xs text-muted"><?= View::e($setting['description']) ?></div><?php endif; ?></td>
<div class="text-xs text-muted" dir="ltr"><?= View::e($setting['key']) ?></div> <td class="tabular-nums" dir="ltr"><?= ($setting['is_secret'] ?? false) ? '******' : View::e($val) ?></td>
<?php if ($setting['description']): ?> <td class="text-sm text-secondary"><?= View::e(SettingsController::SYSTEM_SOURCES[$setting['key']] ?? 'غير مستخدم') ?></td>
<div class="text-xs text-secondary mt-1"><?= View::e($setting['description']) ?></div> </tr>
<?php endif; ?>
</div>
<div class="flex items-center gap-3">
<?php
$val = is_array($setting['value']) ? json_encode($setting['value']) : (string)($setting['value'] ?? '');
$editable = !($setting['is_secret'] ?? false);
?>
<?php if ($val === 'true' || $val === 'false'): ?>
<form method="POST" action="/settings/update">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="key" value="<?= View::e($setting['key']) ?>">
<input type="hidden" name="value" value="<?= $val === 'true' ? 'false' : 'true' ?>">
<label class="toggle">
<input type="checkbox" <?= $val === 'true' ? 'checked' : '' ?> <?= !$editable ? 'disabled' : '' ?> onchange="this.closest('form').submit()">
<span class="toggle-track"></span>
</label>
</form>
<?php else: ?>
<form method="POST" action="/settings/update" class="flex items-center gap-2">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<input type="hidden" name="key" value="<?= View::e($setting['key']) ?>">
<input type="text" name="value" class="form-input" style="width: 200px; padding: var(--space-2) var(--space-3);" value="<?= View::e($val) ?>" <?= !$editable ? 'disabled' : '' ?> dir="ltr">
<?php if ($editable): ?>
<button type="submit" class="btn btn-primary btn-sm">حفظ</button>
<?php endif; ?>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </tbody>
</table>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment