doge 3 дней назад
Родитель
Сommit
63ee27151b

+ 1 - 1
app/Services/PromotionService.php

@@ -459,7 +459,7 @@ class PromotionService
                 'status' => $round->status ?? 'not_started', 'completed_tasks' => $flags,
                 'conditions_complete' => count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === 7,
                 'prizes' => Rules::PRIZES, 'valid_member_policy' => $this->facts->policy(),
-                'ordinary_pool_rule' => '固定奖项仅在累计后小于666时参与普通奖池;普通奖池可用奖项等概率,浮动奖项按分等概率取值。'];
+                'ordinary_pool_rule' => Rules::ORDINARY_POOL_RULE];
             $today = substr(PromotionFacts::now(), 0, 10);
             $result['today_granted_tasks'] = $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)
                 ->where(function ($q) use ($today, $account) { $q->where('event_key', $today)->orWhere('event_key', $account->round_no . ':' . $today); })->pluck('task')->all() : [];

+ 18 - 3
app/Support/PromotionRules.php

@@ -16,6 +16,7 @@ final class PromotionRules
     public const TASKS = ['online', 'login', 'invite', 'deposit', 'team_deposit', 'bet', 'team_bet'];
     public const DAILY = ['online', 'login'];
     public const PRIZES = ['0-666', '100', '50', '200', '350', '500', '150', '366'];
+    public const ORDINARY_POOL_RULE = '未完成本轮全部任务时,普通抽奖最多累计至600。任务完成后,未命中666时按剩余差额和达成概率控制单次奖额。可用奖项等概率,固定奖项按面额发放。';
     public const REWARD_MODES = ['cash', 'coupon'];
     public const COUPON_CATEGORIES = ['self:sport', 'self:lottery'];
     private const COUPON_KEYS = ['reward_mode', 'coupon_valid_from', 'coupon_valid_until', 'coupon_categories', 'inviter_coupons', 'invitee_coupons'];
@@ -143,13 +144,27 @@ final class PromotionRules
         $random ??= fn (int $min, int $max) => random_int($min, $max);
         $remaining = 66600 - $coins;
         if ($remaining <= 0) throw new \LogicException('请先领取本轮奖金');
-        $hit = $complete && $random(1, 10000) <= (int)bcmul($probability, '100', 0);
+        $probabilityBps = (int)bcmul($probability, '100', 0);
+        $hit = $complete && $random(1, 10000) <= $probabilityBps;
         if ($hit) return ['prize_index' => 0, 'amount_cents' => $remaining, 'reached' => true];
+
+        if ($complete) {
+            // Use twice the expected draws, rounded up and capped at ten. Budget one unit per draw,
+            // then spread the remaining cents across that many misses once inside the buffer.
+            $pacedSpins = (int)min(10, ceil(20000 / max(1, $probabilityBps)));
+            $maxAmount = min($remaining - 1, max($remaining - $pacedSpins * 100, intdiv($remaining - 1, $pacedSpins), 1));
+        } else {
+            // Leave 66 for the qualified stage and award at most a quarter of the available room, rounded up to a cent.
+            $headroom = max(0, 60000 - $coins);
+            $maxAmount = intdiv($headroom + 3, 4);
+        }
+        if ($maxAmount === 0) return ['prize_index' => 0, 'amount_cents' => 0, 'reached' => false];
+
         // Choose eligible sectors before drawing. A fixed face value is never truncated.
         $eligible = [0];
-        foreach (self::PRIZES as $i => $amount) if ($i && (int)$amount * 100 < $remaining) $eligible[] = $i;
+        foreach (self::PRIZES as $i => $amount) if ($i && (int)$amount * 100 <= $maxAmount) $eligible[] = $i;
         $index = $eligible[$random(0, count($eligible) - 1)];
-        $amount = $index === 0 ? $random(0, $remaining - 1) : (int)self::PRIZES[$index] * 100;
+        $amount = $index === 0 ? $random(1, $maxAmount) : (int)self::PRIZES[$index] * 100;
         return ['prize_index' => $index, 'amount_cents' => $amount, 'reached' => false];
     }
 }

+ 1 - 1
docs/总后台/独立活动/转盘活动-后台接口.md

@@ -90,7 +90,7 @@
 
 奖项顺序固定:`0–666、100、50、200、350、500、150、366`,浮动奖项支持两位小数。金币是活动进度,只有累计达到666并领取才发放666奖金。
 
-全部条件完成后,每次抽奖先按配置概率判定;命中时浮动奖项补齐剩余差额。未命中或条件未完成时进入普通奖池:只保留累计后小于666的固定奖项及浮动奖项;可用奖项等概率,浮动金额在0至剩余差额减0.01之间按分等概率抽取。固定500不会被截成较小金额,页面须展示奖池规则。
+本轮七项任务未全部完成时,普通抽奖最多累计至600。任务完成后,每次抽奖先按配置概率判定是否补齐至666,未命中时按差额和配置概率控制单次奖额。普通奖池内可用奖项等概率,符合当次上限的固定奖项按面额发放。页面展示接口返回的 `ordinary_pool_rule`。
 
 达到666时立即预留奖金,停止本轮抽奖;领取不会再次消耗预算,已达到后活动下架仍可领取。领取成功后:金币清零、进入下一轮、未用次数保留、登录和在线完成标记保留,其余五项清零重做。当日登录/在线不因换轮重复发次;旧充值、旧投注及已经计奖的有效下级不能重复贡献。
 

+ 24 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -309,6 +309,30 @@ class PromotionActivitiesTest extends TestCase
         $this->invalid(fn () => PromotionRules::normalize(9, ['completion_probability' => 100.01]), 'between');
     }
 
+    public function test_wheel_limits_early_prizes_and_leaves_room_for_ten_misses_at_twenty_percent(): void
+    {
+        // Choose the floating sector, force a miss, then take the maximum allowed amount.
+        $maxFloat = fn ($min, $max) => $min === 0 ? 0 : $max;
+        $this->assertSame(15000, PromotionRules::draw(0, false, '20.00', $maxFloat)['amount_cents']);
+        $this->assertSame(0, PromotionRules::draw(60000, false, '100.00', $maxFloat)['amount_cents']);
+        $fixed = PromotionRules::draw(0, false, '20.00', fn ($min, $max) => $max);
+        $this->assertSame(6, $fixed['prize_index']);
+        $this->assertSame(15000, $fixed['amount_cents']);
+
+        $coins = 60000;
+        for ($i = 0; $i < 10; ++$i) {
+            $draw = PromotionRules::draw($coins, true, '20.00', $maxFloat);
+            $this->assertFalse($draw['reached']);
+            $this->assertGreaterThan(0, $draw['amount_cents']);
+            $coins += $draw['amount_cents'];
+        }
+        $this->assertLessThan(66500, $coins);
+        $this->assertSame(99, PromotionRules::draw(65600, true, '20.00', $maxFloat)['amount_cents']);
+        $this->assertSame(600, PromotionRules::draw(65600, true, '50.00', $maxFloat)['amount_cents']);
+        $this->assertSame(1, PromotionRules::draw(66598, true, '0.00', $maxFloat)['amount_cents']);
+        $this->assertSame(0, PromotionRules::draw(66599, true, '0.00', $maxFloat)['amount_cents']);
+    }
+
     public function test_day_amounts_do_not_add_together_but_completed_flags_survive_midnight(): void
     {
         $id = $this->activity(); $this->service->progress($id, 1);