Files
dafuweng-saiadmin6.x/server/app/api/logic/PlayStartLogic.php

548 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\api\logic;
use app\api\cache\UserCache;
use app\api\service\LotteryService;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player\DicePlayer;
use app\dice\model\player_ticket_record\DicePlayerTicketRecord;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use app\dice\model\reward\DiceReward;
use app\dice\model\reward\DiceRewardConfig;
use plugin\saiadmin\exception\ApiException;
use support\Log;
use support\think\Cache;
use support\think\Db;
/**
* 开始游戏 / 抽奖一局
*/
class PlayStartLogic
{
/** 抽奖类型:付费 */
public const LOTTERY_TYPE_PAID = 0;
/** 抽奖类型:免费 */
public const LOTTERY_TYPE_FREE = 1;
/** 钱包流水类型:抽奖 */
public const WALLET_TYPE_DRAW = 5;
/** 对局状态:成功 */
public const RECORD_STATUS_SUCCESS = 1;
/** 对局状态:超时/失败 */
public const RECORD_STATUS_TIMEOUT = 0;
/** 开启对局最低余额 = |DiceRewardConfig 最小 real_ev + 100| */
private const MIN_COIN_EXTRA = 100;
/** 豹子号中大奖额外平台币(无 BIGWIN 配置时兜底) */
private const SUPER_WIN_BONUS = 500;
/** 可触发超级大奖的 grid_number5=全1 10=全2 15=全3 20=全4 25=全5 30=全6 */
private const SUPER_WIN_GRID_NUMBERS = [5, 10, 15, 20, 25, 30];
/** 5 和 30 抽到即豹子,不参与 BIGWIN 权重判定10/15/20/25 按 BIGWIN weight 判定是否豹子 */
private const SUPER_WIN_ALWAYS_GRID_NUMBERS = [5, 30];
/**
* 执行一局游戏
* @param int $playerId 玩家ID
* @param int $direction 方向 0=无/顺时针 1=中奖/逆时针(前端 direction
* @return array 成功返回 DicePlayRecord 数据;余额不足时抛 ApiExceptionmessage 为约定文案
*/
public function run(int $playerId, int $direction): array
{
$player = DicePlayer::find($playerId);
if (!$player) {
throw new ApiException('用户不存在');
}
$minEv = DiceRewardConfig::getCachedMinRealEv();
$minCoin = abs($minEv + self::MIN_COIN_EXTRA);
$coin = (float) $player->coin;
if ($coin < $minCoin) {
throw new ApiException('当前玩家余额'.$coin.'小于'.$minCoin.'无法继续游戏');
}
$paid = (int) ($player->paid_ticket_count ?? 0);
$free = (int) ($player->free_ticket_count ?? 0);
if ($paid + $free <= 0) {
throw new ApiException('抽奖券不足');
}
$lotteryService = LotteryService::getOrCreate($playerId);
$ticketType = LotteryService::drawTicketType($paid, $free);
$config = $ticketType === self::LOTTERY_TYPE_PAID
? ($lotteryService->getConfigType0Id() ? DiceLotteryPoolConfig::find($lotteryService->getConfigType0Id()) : null)
: ($lotteryService->getConfigType1Id() ? DiceLotteryPoolConfig::find($lotteryService->getConfigType1Id()) : null);
// 未找到付费/免费对应配置时,统一回退到 type=0 的彩金池
if (!$config) {
$config = DiceLotteryPoolConfig::where('type', 0)->find();
}
if (!$config) {
throw new ApiException('奖池配置不存在');
}
// 计算当前玩家在该彩金池中的累计盈利金额:当前中奖金额(含 BIGWIN减去抽奖券费用 100
$playerQuery = DicePlayRecord::where('player_id', $playerId)
->where('lottery_config_id', $config->id)
->where('status', self::RECORD_STATUS_SUCCESS);
$playerWinSum = (float) $playerQuery->sum('win_coin');
$playerPlayCount = (int) $playerQuery->count();
$playerProfitTotal = $playerWinSum - 100.0 * $playerPlayCount;
$safetyLine = (int) ($config->safety_line ?? 0);
// 玩家累计盈利金额达到或超过安全线时,按奖池 T*_weight 杀分;否则按玩家 T*_weight 抽档位
$usePoolWeights = $playerProfitTotal >= $safetyLine;
// 按档位 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('暂无可用奖励配置');
}
$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-1000010000=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;
$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,
$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();
// 玩家累计盈利底层仍使用 profit_amount 字段存储:每局按“当前中奖金额(含 BIGWIN - 抽奖券费用 100”累加
// 需确保表有 profit_amount 字段(见 db/dice_lottery_config_add_profit_amount.sql
$perPlayProfit = $winCoin - 100.0;
$addProfit = $perPlayProfit;
try {
DiceLotteryPoolConfig::where('id', $configId)->update([
'profit_amount' => Db::raw('IFNULL(profit_amount,0) + ' . (float) $addProfit),
]);
} catch (\Throwable $e) {
Log::warning('彩金池盈利累加失败,请确认表 dice_lottery_config 已存在 profit_amount 字段并执行 db/dice_lottery_config_add_profit_amount.sql', [
'config_id' => $configId,
'add_profit' => $addProfit,
'real_ev' => $realEv,
'bigwin_ev' => $bigWinRealEv,
'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-6sum=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,
];
}
}