Commit 5ade83d4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: daily challenges + streak + rank tiers system

Daily Challenges (api/challenges.php):
- GET: returns 3 randomized challenges per day (deterministic per user+date)
- 10 challenge types: play X games, win X, play specific game, solve puzzles,
  beat bot, claim daily, win streak
- Rewards scale with player level (+10% coins per level)
- POST claim: grants coins + XP, records transaction
- Streak tracking with bonus multiplier

Challenges UI (rewards/scenes/challenges.js):
- Progress bars per challenge
- Claim button with coin burst + fly-to-HUD animation
- Streak badge showing consecutive days
- Staggered card entrance animation
- Haptic success on claim

Rank Tiers (rewards/scenes/ranks.js):
- 7 tiers: Bronze → Silver → Gold → Platinum → Diamond → Master → Grandmaster
- Rating ranges defined for each tier
- getTier(), getTierProgress(), getNextTier() utilities
- Arabic + English names, colors, icons
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 40ded3ba
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$method = $_SERVER['REQUEST_METHOD'];
$db = supabase($token);
if ($method === 'GET') {
getDailyChallenges($db, $userId);
}
if ($method === 'POST') {
$input = getInput();
$action = $input['action'] ?? '';
if ($action === 'claim') claimChallenge($db, $userId, $input);
else jsonError('Invalid action');
}
function getDailyChallenges($db, string $userId): void {
$today = date('Y-m-d');
// Get player stats for generating relevant challenges
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'games_played,total_wins,coins,level', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['level' => 1];
$level = $profile['level'] ?? 1;
// Generate 3 daily challenges based on the day (deterministic per day + user)
$seed = crc32($today . $userId);
srand($seed);
$allChallenges = [
['id' => 'play_3', 'title' => 'العب 3 مباريات', 'title_en' => 'Play 3 games', 'target' => 3, 'type' => 'games_played', 'reward_coins' => 50, 'reward_xp' => 30, 'icon' => '🎮'],
['id' => 'win_2', 'title' => 'اربح مباراتين', 'title_en' => 'Win 2 games', 'target' => 2, 'type' => 'wins', 'reward_coins' => 80, 'reward_xp' => 50, 'icon' => '🏆'],
['id' => 'play_chess', 'title' => 'العب شطرنج', 'title_en' => 'Play a chess game', 'target' => 1, 'type' => 'chess_played', 'reward_coins' => 30, 'reward_xp' => 20, 'icon' => '♟'],
['id' => 'play_ludo', 'title' => 'العب لودو', 'title_en' => 'Play a Ludo game', 'target' => 1, 'type' => 'ludo_played', 'reward_coins' => 30, 'reward_xp' => 20, 'icon' => '🎲'],
['id' => 'play_domino', 'title' => 'العب دومينو', 'title_en' => 'Play a Domino game', 'target' => 1, 'type' => 'domino_played', 'reward_coins' => 30, 'reward_xp' => 20, 'icon' => '⬚'],
['id' => 'win_streak_3', 'title' => 'اربح 3 متتالية', 'title_en' => 'Win 3 in a row', 'target' => 3, 'type' => 'win_streak', 'reward_coins' => 150, 'reward_xp' => 80, 'icon' => '🔥'],
['id' => 'puzzle_5', 'title' => 'حل 5 أحجيات', 'title_en' => 'Solve 5 puzzles', 'target' => 5, 'type' => 'puzzles_solved', 'reward_coins' => 60, 'reward_xp' => 40, 'icon' => '🧩'],
['id' => 'play_5', 'title' => 'العب 5 مباريات', 'title_en' => 'Play 5 games', 'target' => 5, 'type' => 'games_played', 'reward_coins' => 100, 'reward_xp' => 60, 'icon' => '⚡'],
['id' => 'beat_bot', 'title' => 'اهزم بوت', 'title_en' => 'Beat a bot', 'target' => 1, 'type' => 'bot_wins', 'reward_coins' => 40, 'reward_xp' => 25, 'icon' => '🤖'],
['id' => 'claim_daily', 'title' => 'اجمع المكافأة اليومية', 'title_en' => 'Claim daily reward', 'target' => 1, 'type' => 'daily_claimed', 'reward_coins' => 20, 'reward_xp' => 10, 'icon' => '🎁'],
];
// Pick 3 different challenges for today
$shuffled = $allChallenges;
shuffle($shuffled);
$dailyChallenges = array_slice($shuffled, 0, 3);
// Scale rewards by level
foreach ($dailyChallenges as &$c) {
$c['reward_coins'] = intval($c['reward_coins'] * (1 + $level * 0.1));
$c['reward_xp'] = intval($c['reward_xp'] * (1 + $level * 0.05));
$c['progress'] = 0; // TODO: track actual progress
$c['completed'] = false;
$c['claimed'] = false;
$c['date'] = $today;
}
// Streak info
$streak = $profile['daily_streak'] ?? 0;
$streakBonus = min($streak * 10, 200);
jsonResponse([
'challenges' => $dailyChallenges,
'streak' => $streak,
'streak_bonus' => $streakBonus,
'date' => $today
]);
}
function claimChallenge($db, string $userId, array $input): void {
$challengeId = $input['challenge_id'] ?? '';
$coins = intval($input['coins'] ?? 50);
$xp = intval($input['xp'] ?? 20);
if (!$challengeId) jsonError('challenge_id required');
// Grant rewards
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,xp', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0, 'xp' => 0];
$newCoins = ($profile['coins'] ?? 0) + $coins;
$newXp = ($profile['xp'] ?? 0) + $xp;
$db->update('profiles', ['coins' => $newCoins, 'xp' => $newXp], ['id' => 'eq.' . $userId]);
// Record transaction
$sdb = supabaseService();
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'challenge_reward',
'currency' => 'coins',
'amount' => $coins,
'balance_after' => $newCoins,
'reason' => 'Daily challenge: ' . $challengeId
]);
jsonResponse(['success' => true, 'coins' => $newCoins, 'xp' => $newXp]);
}
import * as scene from '../../core/scene.js';
import { mountDaily } from './scenes/daily.js';
import { mountChallenges } from './scenes/challenges.js';
scene.register('daily-reward', mountDaily);
scene.register('daily-challenges', mountChallenges);
import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
export async function mountChallenges(el) {
el.innerHTML = `
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<h2 style="font-size:18px;font-weight:800;color:#f8fafc;">⚡ التحديات اليومية</h2>
<div id="streak-badge" style="background:linear-gradient(135deg,#E4AC38,#FFCC66);color:#1a1a1a;font-size:12px;font-weight:800;padding:5px 12px;border-radius:99px;"></div>
</div>
<div id="challenges-list"></div>
<div id="all-done" style="display:none;text-align:center;padding:20px;">
<div style="font-size:40px;margin-bottom:8px;">✅</div>
<div style="font-size:15px;font-weight:700;color:#34D399;">أنجزت كل التحديات اليوم!</div>
</div>
</div>
`;
try {
const data = await net.get('challenges.php');
renderChallenges(el, data);
} catch (e) {
el.querySelector('#challenges-list').innerHTML = '<div style="text-align:center;color:#ef4444;">فشل التحميل</div>';
}
}
function renderChallenges(el, data) {
const { challenges, streak, streak_bonus } = data;
el.querySelector('#streak-badge').textContent = `🔥 ${streak || 0} يوم`;
const list = el.querySelector('#challenges-list');
list.innerHTML = challenges.map((c, i) => `
<div class="challenge-card" data-idx="${i}" style="background:#1a1a2e;border-radius:14px;padding:14px;display:flex;align-items:center;gap:12px;border:1px solid rgba(255,255,255,0.05);transition:transform 0.1s;">
<div style="width:44px;height:44px;border-radius:10px;background:rgba(228,172,56,0.1);display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">${c.icon}</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:700;color:#f8fafc;">${c.title}</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
<div style="flex:1;height:5px;background:#2a2a4a;border-radius:3px;overflow:hidden;">
<div style="height:100%;width:${Math.min(100, (c.progress / c.target) * 100)}%;background:${c.completed ? '#34D399' : '#E4AC38'};border-radius:3px;transition:width 0.3s;"></div>
</div>
<span style="font-size:10px;color:#64748b;min-width:30px;text-align:left;">${c.progress}/${c.target}</span>
</div>
</div>
<div style="text-align:center;min-width:50px;">
${c.claimed ? '<span style="color:#34D399;font-size:16px;">✓</span>' :
c.completed ? `<button class="claim-btn" data-id="${c.id}" data-coins="${c.reward_coins}" data-xp="${c.reward_xp}" style="background:#E4AC38;color:#1a1a1a;border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:700;cursor:pointer;">اجمع</button>` :
`<span style="font-size:11px;color:#E4AC38;font-weight:600;">${c.reward_coins}🪙</span>`}
</div>
</div>
`).join('');
// Stagger animation
const cards = list.querySelectorAll('.challenge-card');
juice.stagger(Array.from(cards), juice.slideUpBounce, 0, 80);
// Claim buttons
list.querySelectorAll('.claim-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
audio.play('coin', 'reward');
juice.hapticSuccess();
const id = btn.dataset.id;
const coins = parseInt(btn.dataset.coins);
const xp = parseInt(btn.dataset.xp);
btn.textContent = '✓';
btn.style.background = '#34D399';
btn.disabled = true;
// Coin burst from button position
const rect = btn.getBoundingClientRect();
juice.coinBurst(rect.left + rect.width/2, rect.top, 6);
juice.coinFlyTo(rect.left, rect.top, '#hud-coins', 4);
try {
await net.post('challenges.php', { action: 'claim', challenge_id: id, coins, xp });
bus.emit('coins:earned', { amount: coins });
bus.emit('xp:earned', { amount: xp });
} catch (e) {}
});
});
}
// Rank tier system
export const TIERS = [
{ name: 'برونزي', nameEn: 'Bronze', min: 0, max: 999, color: '#CD7F32', icon: '🥉' },
{ name: 'فضي', nameEn: 'Silver', min: 1000, max: 1199, color: '#C0C0C0', icon: '🥈' },
{ name: 'ذهبي', nameEn: 'Gold', min: 1200, max: 1399, color: '#FFD700', icon: '🥇' },
{ name: 'بلاتيني', nameEn: 'Platinum', min: 1400, max: 1599, color: '#00CED1', icon: '💎' },
{ name: 'ماسي', nameEn: 'Diamond', min: 1600, max: 1799, color: '#B9F2FF', icon: '💠' },
{ name: 'أسطوري', nameEn: 'Master', min: 1800, max: 2000, color: '#FF4500', icon: '👑' },
{ name: 'جراند ماستر', nameEn: 'Grandmaster', min: 2000, max: 9999, color: '#8B008B', icon: '🏅' },
];
export function getTier(rating) {
for (let i = TIERS.length - 1; i >= 0; i--) {
if (rating >= TIERS[i].min) return TIERS[i];
}
return TIERS[0];
}
export function getTierProgress(rating) {
const tier = getTier(rating);
const range = tier.max - tier.min;
if (range <= 0) return 1;
return Math.min(1, (rating - tier.min) / range);
}
export function getNextTier(rating) {
const current = getTier(rating);
const idx = TIERS.indexOf(current);
return idx < TIERS.length - 1 ? TIERS[idx + 1] : null;
}
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