Выдача наград за открытие сундуков

Оказалось несколько сложнее чем “поменять пару вызовов”.

Класс ChestRewardCalculator использует редкость сундука как главный модификатор, заменяя собой сложность боя из оригинального кода. Вся логика построена на трех принципах: Множитель Редкости, Накопительный Штраф Шанса и Штраф Качества за гарантированные предметы.

1. Множитель Редкости (CHEST_RARITY_MULTIPLIERS)

Это основа всех расчетов. Чем выше редкость сундука (common, epic, legendary), тем выше базовые награды:

Редкость Множитель Влияние на Базовый Шанс/Деньги
common 1.0x Базовые значения
rare 2.5x 250% от базы
legendary 7.0x 700% от базы

Этот множитель применяется к базовому шансу выпадения первого предмета (BASE_ITEM_DROP_CHANCE) и к базовому количеству денег (BASE_MONEY).


2. Расчет Денег (Money)

  1. База: Расчет начинается с базового количества денег, умноженного на Множитель Редкости и Бонус Игрока (moneyBonus).
  2. Масштабирование по Уровню: Результат масштабируется в зависимости от Уровня Игрока (чтобы награда не обесценивалась).
  3. Случайность: Применяется взвешенное случайное распределение, как и в оригинальном коде (60% — мало, 30% — средне, 10% — много).
  4. Шанс Дропа: Деньги выпадают только с заданным шансом (MONEY_DROP_CHANCE, по умолчанию 80%).

3. Комплексная Логика Выпадения Предметов (Items)

Метод calculateItems теперь работает в три последовательных этапа, обеспечивая корректное применение штрафов:

1. Подготовка и Инициализация Шанса

Начальный шанс выпадения предмета ($currentDropChance) определяется как базовая величина (BASE_ITEM_DROP_CHANCE - 70%), умноженная на Множитель Редкости Сундука. Чем реже сундук, тем выше шанс выпадения первого предмета.

2. Применение Штрафа за Гарантированные Предметы

Итерация проходит по всем гарантированным предметам:

  • Накопительный Штраф Шанса: За каждый гарантированный предмет ($item) шанс $currentDropChance снижается на ITEM_DROP_CHANCE_STEP (20%). Это означает, что гарантированные предметы расходуют шанс, делая выпадение следующих случайных предметов менее вероятным.
  • Штраф Качества: Количество гарантированных предметов определенной редкости ($guaranteedQualities) используется в determineItemQuality для понижения шансов этой редкости для всех последующих случайных роллов.

3. Ролл Случайных Предметов

После того как шанс скорректирован гарантированными предметами, начинается основной цикл:

  • Происходит проверка сниженного шанса ($currentDropChance).
  • Если шанс выпадает, определяется качество предмета (с учетом штрафа качества), и шанс снова снижается для следующего предмета.
  • Если шанс не выпадает, цикл прекращается.

Таким образом, гарантированные предметы тратят шанс выпадения и влияют на качество всех последующих случайных предметов.

/**
 * Класс для расчета и выдачи наград из сундуков
 */
class ChestRewardCalculator
{
    // Константы по умолчанию
    private const DEFAULT_MAX_MONEY = 5000;

    // Базовые значения наград
    private const BASE_MONEY = 20;
    private const BASE_ITEM_DROP_CHANCE = 0.7; // 70% базовый шанс выпадения предмета

    // Модификаторы шанса выпадения
    private const ITEM_DROP_CHANCE_STEP = 0.20; // -20% от текущего шанса для следующего предмета (НАКОПИТЕЛЬНЫЙ ШТРАФ)

    // Степень уменьшения шанса качества, если оно было гарантировано (например, -50% за каждый гарантированный)
    private const GUARANTEED_QUALITY_PENALTY = 0.5; // -50%

    // Множители редкости сундука (для денег и базового шанса выпадения)
    private const CHEST_RARITY_MULTIPLIERS = [
        'common' => 1.0,      // Обычный
        'uncommon' => 1.5,    // Необычный
        'rare' => 2.5,        // Редкий
        'epic' => 4.0,        // Эпический
        'legendary' => 7.0    // Легендарный
    ];

    // Шансы на деньги
    private const MONEY_DROP_CHANCE = 0.8; // 80%

    // Шансы качества предметов (в промилле для точности)
    private const ITEM_QUALITIES = [
        'common' => 700,      // 70.0%
        'uncommon' => 200,    // 20.0%
        'rare' => 80,         // 8.0%
        'epic' => 15,         // 1.5%
        'legendary' => 5      // 0.5%
    ];

    private $maxMoney;

    public function __construct(
        int $maxMoney = self::DEFAULT_MAX_MONEY
    ) {
        $this->maxMoney = $maxMoney;
    }

    /**
     * Рассчитывает и выдает награды из сундука
     */
    public function calculateRewards(array $chestData): array
    {
        $rarity = strtolower($chestData['rarity'] ?? 'common');
        $playerLevel = $chestData['playerLevel'] ?? 1;
        $guaranteedItems = $chestData['guaranteedItems'] ?? [];
        $playerBonuses = $chestData['playerBonuses'] ?? [];

        $rarityMultiplier = self::CHEST_RARITY_MULTIPLIERS[$rarity] ?? self::CHEST_RARITY_MULTIPLIERS['common'];

        $rewards = [
            'money' => 0,
            'items' => []
        ];

        if ($this->rollChance(self::MONEY_DROP_CHANCE)) {
            $rewards['money'] = $this->calculateMoney(
                $rarityMultiplier,
                $playerLevel,
                $playerBonuses['moneyBonus'] ?? 1.0
            );
        }

        // Расчет всех предметов (случайных и гарантированных)
        $rewards['items'] = $this->calculateItems(
            $rarityMultiplier,
            $playerLevel,
            $playerBonuses['itemQualityBonus'] ?? [],
            $guaranteedItems
        );

        $this->giveRewardsToPlayer($rewards);

        return $rewards;
    }

    /**
     * Расчет денег
     */
    private function calculateMoney(float $rarityMultiplier, int $playerLevel, float $moneyBonus): int
    {
        $baseMoney = self::BASE_MONEY * $rarityMultiplier * $moneyBonus * ($playerLevel / 10 + 1);

        $roll = mt_rand(1, 100);
        if ($roll <= 60) {
            $money = $baseMoney * mt_rand(50, 80) / 100;
        } elseif ($roll <= 90) {
            $money = $baseMoney * mt_rand(80, 120) / 100;
        } else {
            $money = $baseMoney * mt_rand(120, 200) / 100;
        }

        return min(round($money), $this->maxMoney);
    }

    /**
     * Рассчитывает все выпавшие предметы (гарантированные + случайные)
     */
    private function calculateItems(
        float $rarityMultiplier,
        int $playerLevel,
        array $qualityBonuses,
        array $guaranteedItems
    ): array {
        $items = $guaranteedItems;
        $currentDropChance = self::BASE_ITEM_DROP_CHANCE * $rarityMultiplier;

        // 1. Подсчет гарантированных для ШТРАФА КАЧЕСТВА
        $guaranteedQualities = array_count_values(array_column($guaranteedItems, 'quality'));

        // 2. !!! ПРИМЕНЯЕМ ШТРАФ ШАНСА ЗА КАЖДЫЙ ГАРАНТИРОВАННЫЙ ПРЕДМЕТ !!!
        foreach ($guaranteedItems as $item) {
            // Гарантированный предмет выпал, поэтому он тратит шанс для следующего
            $currentDropChance *= (1 - self::ITEM_DROP_CHANCE_STEP);
        }

        // 3. Продолжаем цикл для СЛУЧАЙНЫХ предметов
        $maxItems = 10;

        while ($currentDropChance >= 0.01) {

            // Проверяем шанс выпадения для следующего СЛУЧАЙНОГО предмета
            if ($this->rollChance($currentDropChance)) {

                // Шанс выпал: определяем качество с учетом ШТРАФА за гарантированные
                $quality = $this->determineItemQuality($qualityBonuses, $guaranteedQualities);

                $items[] = [
                    'quality' => $quality,
                    'id' => $this->generateRandomItemId($quality, $playerLevel)
                ];

                // Накладываем накопительный штраф на шанс выпадения для СЛЕДУЮЩЕГО предмета
                $currentDropChance *= (1 - self::ITEM_DROP_CHANCE_STEP);
            } else {
                // Шанс не выпал, прекращаем цикл
                break;
            }
            if (count($items) >= $maxItems) break;
        }

        return $items;
    }

    /**
     * Определяет качество выпавшего предмета, корректируя шансы на основе гарантированных
     */
    private function determineItemQuality(array $bonuses, array $guaranteedQualities): string
    {
        $qualities = self::ITEM_QUALITIES;

        // 1. Применяем бонусы игрока
        foreach (['legendary', 'epic', 'rare', 'uncommon'] as $q) {
            if (isset($bonuses[$q])) {
                $qualities[$q] = round($qualities[$q] * $bonuses[$q]);
            }
        }

        // 2. Применяем ШТРАФ за гарантированные предметы (Quality Penalty)
        foreach ($guaranteedQualities as $quality => $count) {
            if ($count > 0 && in_array($quality, ['uncommon', 'rare', 'epic', 'legendary'])) {
                $penalty = self::GUARANTEED_QUALITY_PENALTY ** $count;
                $qualities[$quality] = round($qualities[$quality] * $penalty);
            }
        }

        // 3. Пересчитываем шанс обычного качества
        $totalSpecial = 0;
        foreach (['uncommon', 'rare', 'epic', 'legendary'] as $q) {
            $totalSpecial += $qualities[$q];
        }
        $qualities['common'] = max(0, 1000 - $totalSpecial);

        // 4. Выбираем качество
        $roll = mt_rand(1, 1000);
        $cumulative = 0;

        foreach ($qualities as $quality => $chance) {
            $cumulative += $chance;
            if ($roll <= $cumulative) {
                return $quality;
            }
        }

        return 'common';
    }

    // ... (Методы generateRandomItemId, rollChance, giveRewardsToPlayer остаются без изменений)
    private function generateRandomItemId(string $quality, int $playerLevel): int
    {
        $baseRanges = [
            'common' => 1000,
            'uncommon' => 2000,
            'rare' => 3000,
            'epic' => 4000,
            'legendary' => 5000,
        ];

        $baseId = $baseRanges[$quality] ?? 1000;

        $minId = $baseId + ($playerLevel - 1) * 10;
        $maxId = $baseId + $playerLevel * 10;

        return mt_rand($minId, $maxId);
    }

    private function rollChance(float $chance): bool
    {
        return mt_rand(1, 10000) <= ($chance * 10000);
    }

    private function giveRewardsToPlayer(array $rewards): void
    {
        if ($rewards['money'] > 0) {
            // Player::giveMoney($rewards['money']);
        }

        foreach ($rewards['items'] as $item) {
            // Player::giveItem($item['id'], $item['quality']);
        }
    }
}

Пример работы:

// Пример использования
$calculator = new ChestRewardCalculator();

// Открытие сундука с 1 гарантированным легендарным предметом
$rewards = $calculator->calculateRewards([
    'rarity' => 'epic',
    'playerLevel' => 35,
    'guaranteedItems' => [
        ['quality' => 'legendary', 'id' => 5000],
    ],
    'playerBonuses' => [
        'moneyBonus' => 1.5,
        'itemQualityBonus' => []
    ]
]);


echo "<pre>";
print_r($rewards);
echo "</pre>";
Array
(
    [money] => 0
    [items] => Array
        (
            [0] => Array
                (
                    [quality] => legendary
                    [id] => 5000
                )

            [1] => Array
                (
                    [quality] => common
                    [id] => 1344
                )

            [2] => Array
                (
                    [quality] => common
                    [id] => 1344
                )

            [3] => Array
                (
                    [quality] => common
                    [id] => 1349
                )

            [4] => Array
                (
                    [quality] => common
                    [id] => 1343
                )

            [5] => Array
                (
                    [quality] => rare
                    [id] => 3349
                )

            [6] => Array
                (
                    [quality] => uncommon
                    [id] => 2340
                )

        )

)