coin; if ($coin < $minCoin) { throw new ApiException(ApiLang::translateParams('当前玩家余额%s小于%s无法继续游戏', [$coin, $minCoin])); } $paid = (int) ($player->paid_ticket_count ?? 0); $free = (int) ($player->free_ticket_count ?? 0); if ($paid + $free <= 0) { throw new ApiException('Insufficient lottery tickets'); } $lotteryService = LotteryService::getOrCreate($playerId); $ticketType = LotteryService::drawTicketType($paid, $free); $configType0 = DiceLotteryPoolConfig::where('name', 'default')->find(); $configType1 = DiceLotteryPoolConfig::where('name', 'killScore')->find(); if (!$configType0) { throw new ApiException('Lottery pool config not found (name=default required)'); } // 玩家累计盈利:仅统计 lottery_config_id=type=0 的成功对局(中奖金额-100*局数) $playerQuery = DicePlayRecord::where('player_id', $playerId) ->where('lottery_config_id', $configType0->id) ->where('status', self::RECORD_STATUS_SUCCESS); $playerWinSum = (float) $playerQuery->sum('win_coin'); $playerPlayCount = (int) $playerQuery->count(); $playerProfitTotal = $playerWinSum - 100.0 * $playerPlayCount; $safetyLine = (int) ($configType0->safety_line ?? 0); $killEnabled = ((int) ($configType0->kill_enabled ?? 1)) === 1; // 玩家累计盈利>=安全线时杀分:无论付费/免费,都用 type=1 的 T*_weight;未达到时一律按玩家权重 // 记录 lottery_config_id:杀分记 type=1;未杀分统一记当前池 type=0 $usePoolWeights = $killEnabled && $playerProfitTotal >= $safetyLine && $configType1 !== null; $config = $usePoolWeights ? $configType1 : $configType0; // 按档位 T1-T5 抽取后,从 DiceReward 表按当前方向取该档位数据,再按 weight 抽取一条得到 grid_number $rewardInstance = DiceReward::getCachedInstance(); $byTierDirection = $rewardInstance['by_tier_direction'] ?? []; $maxTierRetry = 10; $chosen = null; $tier = null; for ($tierAttempt = 0; $tierAttempt < $maxTierRetry; $tierAttempt++) { $tier = $usePoolWeights ? LotteryService::drawTierByWeights($config) : LotteryService::drawTierByPlayerWeights($player); $tierRewards = $byTierDirection[$tier][$direction] ?? []; if (empty($tierRewards)) { Log::warning("档位 {$tier} 方向 {$direction} 无任何 DiceReward,重新摇取档位"); continue; } try { $chosen = self::drawRewardByWeight($tierRewards); } catch (\RuntimeException $e) { if ($e->getMessage() === self::EXCEPTION_WEIGHT_ALL_ZERO) { Log::warning("档位 {$tier} 下所有奖励权重均为 0,重新摇取档位"); continue; } throw $e; } break; } if ($chosen === null) { Log::error("多次摇取档位后仍无有效 DiceReward"); throw new ApiException('No available reward config'); } $startIndex = (int) ($chosen['start_index'] ?? 0); $targetIndex = (int) ($chosen['end_index'] ?? 0); $rollNumber = (int) ($chosen['grid_number'] ?? 0); $realEv = (float) ($chosen['real_ev'] ?? 0); $isTierT5 = (string) ($chosen['tier'] ?? '') === 'T5'; $rewardWinCoin = $isTierT5 ? $realEv : (100 + $realEv); // 豹子判定:5/30 必豹子;10/15/20/25 按 DiceRewardConfig 中 BIGWIN 该点数的 weight 判定(0-10000,10000=100%) $superWinCoin = 0; $isWin = 0; $bigWinRealEv = 0.0; if (in_array($rollNumber, self::SUPER_WIN_GRID_NUMBERS, true)) { $bigWinConfig = DiceRewardConfig::getCachedByTierAndGridNumber('BIGWIN', $rollNumber); $alwaysSuperWin = in_array($rollNumber, self::SUPER_WIN_ALWAYS_GRID_NUMBERS, true); $doSuperWin = $alwaysSuperWin; if (!$doSuperWin) { $bigWinWeight = 10000; if ($bigWinConfig !== null && isset($bigWinConfig['weight'])) { $bigWinWeight = min(10000, max(0, (int) $bigWinConfig['weight'])); $bigWinRealEv = (float) ($bigWinConfig['real_ev'] ?? 0); } $roll = (random_int(0, PHP_INT_MAX - 1) / (float) PHP_INT_MAX); $doSuperWin = $bigWinWeight > 0 && $roll < ($bigWinWeight / 10000.0); } else { if ($bigWinConfig !== null) { $bigWinRealEv = (float) ($bigWinConfig['real_ev'] ?? 0); } } if ($doSuperWin) { $rollArray = $this->getSuperWinRollArray($rollNumber); $isWin = 1; $superWinCoin = $bigWinRealEv > 0 ? $bigWinRealEv : self::SUPER_WIN_BONUS; // 中 BIGWIN 豹子:不走原奖励流程,不记录原奖励,不触发 T5 再来一次,仅发放豹子奖金 $rewardWinCoin = 0; $realEv = 0; $isTierT5 = false; } else { $rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber); } } else { $rollArray = $this->generateRollArrayFromSum($rollNumber); } Log::info(sprintf( '摇取点数 roll_number=%d, 方向=%d, start_index=%d, target_index=%d', $rollNumber, $direction, $startIndex, $targetIndex )); $winCoin = $superWinCoin + $rewardWinCoin; // 赢取平台币 = 中大奖 + 摇色子中奖(豹子时 rewardWinCoin 已为 0) $record = null; $configId = (int) $config->id; $type0ConfigId = (int) $configType0->id; $rewardId = ($isWin === 1 && $superWinCoin > 0) ? 0 : $targetIndex; // 中豹子不记录原奖励配置 id $configName = (string) ($config->name ?? ''); $adminId = ($player->admin_id ?? null) ? (int) $player->admin_id : null; try { Db::transaction(function () use ( $playerId, $adminId, $configId, $type0ConfigId, $rewardId, $configName, $ticketType, $winCoin, $superWinCoin, $rewardWinCoin, $isWin, $realEv, $bigWinRealEv, $direction, $startIndex, $targetIndex, $rollArray, $isTierT5, &$record ) { $record = DicePlayRecord::create([ 'player_id' => $playerId, 'admin_id' => $adminId, 'lottery_config_id' => $configId, 'lottery_type' => $ticketType, 'is_win' => $isWin, 'win_coin' => $winCoin, 'super_win_coin' => $superWinCoin, 'reward_win_coin' => $rewardWinCoin, 'use_coins' => 0, 'direction' => $direction, 'reward_config_id' => $rewardId, 'start_index' => $startIndex, 'target_index' => $targetIndex, 'roll_array' => is_array($rollArray) ? json_encode($rollArray) : $rollArray, 'roll_number' => is_array($rollArray) ? array_sum($rollArray) : 0, 'lottery_name' => $configName, 'status' => self::RECORD_STATUS_SUCCESS, ]); $p = DicePlayer::find($playerId); if (!$p) { throw new \RuntimeException('玩家不存在'); } $coinBefore = (float) $p->coin; $coinAfter = $coinBefore + $winCoin; $p->coin = $coinAfter; $p->total_ticket_count = max(0, (int) $p->total_ticket_count - 1); if ($ticketType === self::LOTTERY_TYPE_PAID) { $p->paid_ticket_count = max(0, (int) $p->paid_ticket_count - 1); } else { $p->free_ticket_count = max(0, (int) $p->free_ticket_count - 1); } // 若本局中奖档位为 T5,则额外赠送 1 次免费抽奖次数(总次数也 +1),并记录抽奖券获取记录 if ($isTierT5) { $p->free_ticket_count = (int) $p->free_ticket_count + 1; $p->total_ticket_count = (int) $p->total_ticket_count + 1; DicePlayerTicketRecord::create([ 'player_id' => $playerId, 'admin_id' => $adminId, 'free_ticket_count' => 1, 'remark' => '中奖结果为T5', ]); } $p->save(); // 玩家累计盈利累加在 type=0 彩金池上:每局按“当前中奖金额(含 BIGWIN) - 抽奖券费用 100” $perPlayProfit = $winCoin - 100.0; $addProfit = $perPlayProfit; try { DiceLotteryPoolConfig::where('id', $type0ConfigId)->update([ 'profit_amount' => Db::raw('IFNULL(profit_amount,0) + ' . (float) $addProfit), ]); } catch (\Throwable $e) { Log::warning('彩金池盈利累加失败', [ 'config_id' => $type0ConfigId, 'add_profit' => $addProfit, 'message' => $e->getMessage(), ]); } DicePlayerWalletRecord::create([ 'player_id' => $playerId, 'admin_id' => $adminId, 'coin' => $winCoin, 'type' => self::WALLET_TYPE_DRAW, 'wallet_before' => $coinBefore, 'wallet_after' => $coinAfter, 'remark' => '抽奖|play_record_id=' . $record->id, ]); }); } catch (\Throwable $e) { if ($record === null) { try { $record = DicePlayRecord::create([ 'player_id' => $playerId, 'admin_id' => $adminId ?? null, 'lottery_config_id' => $configId ?? 0, 'lottery_type' => $ticketType, 'is_win' => 0, 'win_coin' => 0, 'super_win_coin' => 0, 'reward_win_coin' => 0, 'use_coins' => 0, 'direction' => $direction, 'reward_config_id' => 0, 'start_index' => $startIndex, 'target_index' => 0, 'roll_array' => '[]', 'roll_number' => 0, 'status' => self::RECORD_STATUS_TIMEOUT, ]); } catch (\Throwable $_) { // 表可能无 status 字段时忽略 } } throw $e; } $updated = DicePlayer::find($playerId); if ($updated) { UserCache::setUser($playerId, $updated->hidden(['password'])->toArray()); } if (!$record instanceof DicePlayRecord) { throw new \RuntimeException('对局记录创建失败'); } $arr = $record->toArray(); if (isset($arr['roll_array']) && is_string($arr['roll_array'])) { $arr['roll_array'] = json_decode($arr['roll_array'], true) ?? []; } $arr['roll_number'] = is_array($arr['roll_array'] ?? null) ? array_sum($arr['roll_array']) : 0; $arr['tier'] = $tier ?? ''; // 记录完数据后返回当前玩家余额与抽奖次数 $arr['coin'] = $updated ? (float) $updated->coin : 0; $arr['total_ticket_count'] = $updated ? (int) $updated->total_ticket_count : 0; return $arr; } /** 该组配置权重均为 0 时抛出,供调用方重试 */ private const EXCEPTION_WEIGHT_ALL_ZERO = 'REWARD_WEIGHT_ALL_ZERO'; /** * 按权重抽取一条配置:仅 weight>0 参与抽取(weight=0 不会被摇到) * 使用 [0, total) 浮点随机,支持最小权重 0.1%(如 weight=0.1),避免整数随机导致小权重失真 * 全部 weight 为 0 时抛出 RuntimeException(EXCEPTION_WEIGHT_ALL_ZERO) */ private static function drawRewardByWeight(array $rewards): array { if (empty($rewards)) { throw new \InvalidArgumentException('rewards 不能为空'); } $candidateWeights = []; foreach ($rewards as $i => $row) { $w = isset($row['weight']) ? (float) $row['weight'] : 0.0; if ($w > 0) { $candidateWeights[$i] = $w; } } $total = (float) array_sum($candidateWeights); if ($total > 0) { $r = (random_int(0, PHP_INT_MAX - 1) / (float) PHP_INT_MAX) * $total; $acc = 0.0; foreach ($candidateWeights as $i => $w) { $acc += $w; if ($r < $acc) { return $rewards[$i]; } } return $rewards[array_key_last($candidateWeights)]; } throw new \RuntimeException(self::EXCEPTION_WEIGHT_ALL_ZERO); } /** * 根据摇取点数(5-30)生成 5 个色子数组,每个 1-6,总和为 $sum * @return int[] 如 [1,2,3,4,5] */ private function generateRollArrayFromSum(int $sum): array { $sum = max(5, min(30, $sum)); $arr = [1, 1, 1, 1, 1]; $remain = $sum - 5; for ($i = 0; $i < $remain; $i++) { $candidates = array_keys(array_filter($arr, function ($v) { return $v < 6; })); if (empty($candidates)) { break; } $idx = $candidates[random_int(0, count($candidates) - 1)]; $arr[$idx]++; } shuffle($arr); return array_values($arr); } /** * 豹子组合:5->[1,1,1,1,1],10->[2,2,2,2,2],15->[3,3,3,3,3],20->[4,4,4,4,4],25->[5,5,5,5,5],30->[6,6,6,6,6] * @return int[] */ private function getSuperWinRollArray(int $gridNumber): array { if ($gridNumber === 30) { return array_fill(0, 5, 6); } $n = (int) ($gridNumber / 5); $n = max(1, min(5, $n)); return array_fill(0, 5, $n); } /** * 生成总和为 $sum 且非豹子的 5 个色子(1-6);sum=5 时仅 [1,1,1,1,1] 可能,仍返回该组合 * @return int[] */ private function generateNonSuperWinRollArrayWithSum(int $sum): array { $sum = max(5, min(30, $sum)); $super = $this->getSuperWinRollArray($sum); if ($sum === 5) { return $super; } $arr = $super; $maxAttempts = 20; for ($a = 0; $a < $maxAttempts; $a++) { $idx = random_int(0, count($arr) - 1); $j = random_int(0, count($arr) - 1); if ($idx === $j) { $j = ($j + 1) % 5; } $i = $idx; if ($arr[$i] >= 2 && $arr[$j] <= 5) { $arr[$i]--; $arr[$j]++; shuffle($arr); return array_values($arr); } if ($arr[$i] <= 5 && $arr[$j] >= 2) { $arr[$i]++; $arr[$j]--; shuffle($arr); return array_values($arr); } } return $this->generateRollArrayFromSum($sum); } /** * 模拟一局抽奖(不写库、不扣玩家),用于权重测试写入 dice_play_record_test * @param \app\dice\model\lottery_pool_config\DiceLotteryPoolConfig|null $config 奖池配置,自定义档位时可为 null * @param int $direction 0=顺时针 1=逆时针 * @param int $lotteryType 0=付费 1=免费 * @param array|null $customTierWeights 自定义档位权重 ['T1'=>x, 'T2'=>x, ...],非空时忽略 config 的档位权重 * @return array 可直接用于 DicePlayRecordTest::create 的字段 + tier(用于统计档位概率) */ public function simulateOnePlay($config, int $direction, int $lotteryType = 0, ?array $customTierWeights = null): array { $rewardInstance = DiceReward::getCachedInstance(); $byTierDirection = $rewardInstance['by_tier_direction'] ?? []; $maxTierRetry = 10; $chosen = null; $tier = null; for ($tierAttempt = 0; $tierAttempt < $maxTierRetry; $tierAttempt++) { if ($customTierWeights !== null && $customTierWeights !== []) { $tier = LotteryService::drawTierByWeightsFromArray($customTierWeights); } else { if ($config === null) { throw new \RuntimeException('模拟抽奖:未提供奖池配置或自定义档位权重'); } $tier = LotteryService::drawTierByWeights($config); } $tierRewards = $byTierDirection[$tier][$direction] ?? []; if (empty($tierRewards)) { continue; } try { $chosen = self::drawRewardByWeight($tierRewards); } catch (\RuntimeException $e) { if ($e->getMessage() === self::EXCEPTION_WEIGHT_ALL_ZERO) { continue; } throw $e; } break; } if ($chosen === null) { throw new \RuntimeException('模拟抽奖:无可用奖励配置'); } $startIndex = (int) ($chosen['start_index'] ?? 0); $targetIndex = (int) ($chosen['end_index'] ?? 0); $rollNumber = (int) ($chosen['grid_number'] ?? 0); $realEv = (float) ($chosen['real_ev'] ?? 0); $isTierT5 = (string) ($chosen['tier'] ?? '') === 'T5'; $rewardWinCoin = $isTierT5 ? $realEv : (100 + $realEv); $superWinCoin = 0; $isWin = 0; $bigWinRealEv = 0.0; if (in_array($rollNumber, self::SUPER_WIN_GRID_NUMBERS, true)) { $bigWinConfig = DiceRewardConfig::getCachedByTierAndGridNumber('BIGWIN', $rollNumber); $alwaysSuperWin = in_array($rollNumber, self::SUPER_WIN_ALWAYS_GRID_NUMBERS, true); $doSuperWin = $alwaysSuperWin; if (!$doSuperWin) { $bigWinWeight = 10000; if ($bigWinConfig !== null && isset($bigWinConfig['weight'])) { $bigWinWeight = min(10000, max(0, (int) $bigWinConfig['weight'])); } $roll = (random_int(0, PHP_INT_MAX - 1) / (float) PHP_INT_MAX); $doSuperWin = $bigWinWeight > 0 && $roll < ($bigWinWeight / 10000.0); } if ($bigWinConfig !== null && isset($bigWinConfig['real_ev'])) { $bigWinRealEv = (float) $bigWinConfig['real_ev']; } if ($doSuperWin) { $rollArray = $this->getSuperWinRollArray($rollNumber); $isWin = 1; $superWinCoin = $bigWinRealEv > 0 ? $bigWinRealEv : self::SUPER_WIN_BONUS; $rewardWinCoin = 0; } else { $rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber); } } else { $rollArray = $this->generateRollArrayFromSum($rollNumber); } $winCoin = $superWinCoin + $rewardWinCoin; $configId = $config !== null ? (int) $config->id : 0; $rewardId = ($isWin === 1 && $superWinCoin > 0) ? 0 : $targetIndex; $configName = $config !== null ? (string) ($config->name ?? '') : '自定义'; $costRealEv = $realEv + ($isWin === 1 ? $bigWinRealEv : 0.0); return [ 'player_id' => 0, 'admin_id' => 0, 'lottery_config_id' => $configId, 'lottery_type' => $lotteryType, 'is_win' => $isWin, 'win_coin' => $winCoin, 'super_win_coin' => $superWinCoin, 'reward_win_coin' => $rewardWinCoin, 'use_coins' => 0, 'direction' => $direction, 'reward_config_id' => $rewardId, 'start_index' => $startIndex, 'target_index' => $targetIndex, 'roll_array' => json_encode($rollArray), 'roll_number' => array_sum($rollArray), 'lottery_name' => $configName, 'status' => self::RECORD_STATUS_SUCCESS, 'tier' => $tier, 'roll_number_for_count' => $rollNumber, 'real_ev' => $realEv, 'bigwin_real_ev' => $isWin === 1 ? $bigWinRealEv : 0.0, 'cost_ev' => $costRealEv, ]; } }