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 [
// Settings
'settings' => ['module' => 'settings', 'action' => 'index'],
'settings/update' => ['module' => 'settings', 'action' => 'update'],
'settings/rewards' => ['module' => 'settings', 'action' => 'updateRewards'],
// Branding
'branding' => ['module' => 'branding', 'action' => 'index'],
......
<?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
{
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()
{
$this->db = Database::getInstance();
......@@ -13,22 +115,105 @@ class SettingsController
{
Auth::requireRole('superadmin');
$settings = $this->db->select('system_config', [
'select' => '*',
'order' => 'category.asc,key.asc',
]);
$grouped = [];
foreach ($settings as $s) {
$grouped[$s['category']][] = $s;
$rows = $this->db->select('reward_config', ['select' => '*', 'order' => 'key.asc']);
$rewards = [];
foreach (self::GROUPS as $g) $rewards[$g] = [];
$known = array_keys(self::REWARDS);
foreach ($rows as $r) {
$meta = self::REWARDS[$r['key']] ?? [$r['key'], 'أخرى', null, false, 'مفتاح غير معروف للوحة'];
$r['label'] = $meta[0];
$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 = 'الإعدادات';
$moduleCSS = '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
{
Auth::requireCsrf();
......@@ -38,28 +223,39 @@ class SettingsController
$value = $_POST['value'] ?? '';
if (empty($key)) {
Response::error('مفتاح غير صحيح', '/settings');
Response::error('مفتاح غير صحيح', '/settings?tab=system');
return;
}
$setting = $this->db->selectOne('system_config', ['key' => "eq.{$key}"]);
if (!$setting) {
Response::error('الإعداد غير موجود', '/settings');
Response::error('الإعداد غير موجود', '/settings?tab=system');
return;
}
if ($setting['is_secret'] ?? false) {
Response::error('هذا الإعداد محمي', '/settings');
Response::error('هذا الإعداد محمي', '/settings?tab=system');
return;
}
$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}"], [
'value' => json_encode($value),
'value' => $typed,
'updated_at' => date('c'),
]);
AuditLog::log('update', 'system_config', $key, ['value' => $old], ['value' => $value]);
Response::success('تم تحديث الإعداد', '/settings');
AuditLog::log('update', 'system_config', $key, ['value' => $old], ['value' => $typed]);
Response::success('تم تحديث الإعداد', '/settings?tab=system');
}
}
<div class="content-header">
<h1>إعدادات النظام</h1>
<h1>الإعدادات</h1>
</div>
<?php if (empty($grouped)): ?>
<div class="card"><div class="empty-state"><h3 class="empty-state-title">لا توجد إعدادات</h3><p class="empty-state-text">لم يتم تكوين أي إعدادات بعد</p></div></div>
<?php else: ?>
<div class="tabs mb-5">
<a href="/settings" class="tab <?= $tab === 'rewards' ? 'active' : '' ?>">المكافآت والتقييم</a>
<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
$categoryLabels = [
'matchmaking' => 'التوفيق',
'matchmaking' => 'التوفيق بين اللاعبين',
'economy' => 'الاقتصاد',
'moderation' => 'الإشراف',
'platform' => 'المنصة',
'limits' => 'الحدود',
'rating' => 'التقييم',
'anticheat' => 'مكافحة الغش',
'tournaments' => 'البطولات',
'general' => 'عام',
];
$grouped = [];
foreach ($system as $s) $grouped[$s['category'] ?? 'general'][] = $s;
?>
<?php foreach ($grouped as $category => $settings): ?>
<div class="card mb-5">
<div class="card-header">
<h3 class="card-title"><?= View::e($categoryLabels[$category] ?? $category) ?></h3>
<span class="badge badge-default"><?= count($settings) ?> إعداد</span>
</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): ?>
<div class="flex items-center justify-between p-4" style="border-bottom: 1px solid var(--border);">
<div class="flex-1">
<div class="font-medium text-sm"><?= View::e($setting['label_ar'] ?? $setting['label'] ?? $setting['key']) ?></div>
<div class="text-xs text-muted" dir="ltr"><?= View::e($setting['key']) ?></div>
<?php if ($setting['description']): ?>
<div class="text-xs text-secondary mt-1"><?= View::e($setting['description']) ?></div>
<?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 $val = is_array($setting['value']) || is_bool($setting['value']) ? json_encode($setting['value'], JSON_UNESCAPED_UNICODE) : (string)($setting['value'] ?? ''); ?>
<tr>
<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>
<td class="tabular-nums" dir="ltr"><?= ($setting['is_secret'] ?? false) ? '******' : View::e($val) ?></td>
<td class="text-sm text-secondary"><?= View::e(SettingsController::SYSTEM_SOURCES[$setting['key']] ?? 'غير مستخدم') ?></td>
</tr>
<?php endforeach; ?>
</div>
</tbody>
</table>
</div>
<?php endforeach; ?>
<?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