655 lines
30 KiB
PHP
655 lines
30 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\api\logic;
|
||
|
||
use app\api\cache\UserCache;
|
||
use app\api\util\ApiLang;
|
||
use app\api\service\LotteryService;
|
||
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
|
||
use app\dice\model\play_record\DicePlayRecord;
|
||
use app\dice\model\ante_config\DiceAnteConfig;
|
||
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;
|
||
|
||
/** 单注费用(对应原票价 100) */
|
||
private const UNIT_COST = 100;
|
||
/** 免费抽奖注数缓存 key 前缀(用于强制下一局注数一致) */
|
||
private const FREE_ANTE_KEY_PREFIX = 'api:game:free_ante:';
|
||
/** 免费抽奖注数缓存过期(秒) */
|
||
private const FREE_ANTE_TTL = 86400 * 7;
|
||
/** 豹子号中大奖额外平台币(无 BIGWIN 配置时兜底) */
|
||
private const SUPER_WIN_BONUS = 500;
|
||
/** 可触发超级大奖的 grid_number(5=全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)
|
||
* @param int $ante 注数(必须在 DiceAnteConfig.mult 中存在)
|
||
* @return array 成功返回 DicePlayRecord 数据;余额不足时抛 ApiException,message 为约定文案
|
||
*/
|
||
public function run(int $playerId, int $direction, int $ante): array
|
||
{
|
||
$player = DicePlayer::find($playerId);
|
||
if (!$player) {
|
||
throw new ApiException('User not found');
|
||
}
|
||
|
||
$coin = (float) $player->coin;
|
||
if ($ante <= 0) {
|
||
throw new ApiException('ante must be a positive integer');
|
||
}
|
||
|
||
// 注数合规校验:ante 必须存在于 dice_ante_config.mult
|
||
$anteConfigModel = new DiceAnteConfig();
|
||
$exists = $anteConfigModel->where('mult', $ante)->count();
|
||
if ($exists <= 0) {
|
||
throw new ApiException('当前注数不合规,请选择正确的注数');
|
||
}
|
||
|
||
// 免费抽奖:不再使用抽奖券作为开始条件,仅用 free_ticket_count 表示“免费抽奖次数”
|
||
$freeCount = (int) ($player->free_ticket_count ?? 0);
|
||
$isFree = $freeCount > 0;
|
||
$ticketType = $isFree ? self::LOTTERY_TYPE_FREE : self::LOTTERY_TYPE_PAID;
|
||
|
||
// 若为免费抽奖:注数必须与上一次触发免费抽奖时的注数一致
|
||
if ($isFree) {
|
||
$requiredAnte = Cache::get(self::FREE_ANTE_KEY_PREFIX . $playerId);
|
||
if ($requiredAnte !== null && $requiredAnte !== '' && (int) $requiredAnte !== $ante) {
|
||
throw new ApiException('免费抽奖注数必须与上一次一致,请修改注数后继续');
|
||
}
|
||
}
|
||
|
||
$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)');
|
||
}
|
||
|
||
// 余额校验:统一校验 ante * min(real_ev)
|
||
$minEv = DiceRewardConfig::getCachedMinRealEv();
|
||
$needMinBalance = abs((float) $minEv) * $ante;
|
||
if ($coin < $needMinBalance) {
|
||
throw new ApiException('未达抽奖余额 ' . $needMinBalance . ',无法开始游戏');
|
||
}
|
||
|
||
// 付费抽奖:开始前扣除费用 ante * 100,不足则提示余额不足
|
||
$paidAmount = $ticketType === self::LOTTERY_TYPE_PAID ? ($ante * self::UNIT_COST) : 0;
|
||
if ($ticketType === self::LOTTERY_TYPE_PAID && $coin < $paidAmount) {
|
||
throw new ApiException('余额不足');
|
||
}
|
||
|
||
// 彩金池累计盈利:用于判断是否触发杀分(不再依赖单个玩家累计盈利)
|
||
// 该值来自 dice_lottery_pool_config.profit_amount
|
||
$poolProfitTotal = $configType0->profit_amount ?? 0;
|
||
$safetyLine = (int) ($configType0->safety_line ?? 0);
|
||
$killEnabled = ((int) ($configType0->kill_enabled ?? 1)) === 1;
|
||
// 盈利>=安全线且开启杀分:付费/免费都用 killScore;盈利<安全线:付费用玩家权重,免费用 killScore(无则用 default)
|
||
// 记录 lottery_config_id:用池权重时记对应池,付费用玩家权重时记 default
|
||
$usePoolWeights = ($ticketType === self::LOTTERY_TYPE_PAID && $killEnabled && $poolProfitTotal >= $safetyLine && $configType1 !== null)
|
||
|| ($ticketType === self::LOTTERY_TYPE_FREE);
|
||
$config = $usePoolWeights
|
||
? (($ticketType === self::LOTTERY_TYPE_FREE && $configType1 === null) ? $configType0 : $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;
|
||
}
|
||
if ($usePoolWeights) {
|
||
$tierRewards = self::filterOutSuperWinOnlyGrids($tierRewards);
|
||
if (empty($tierRewards)) {
|
||
Log::warning("档位 {$tier} 方向 {$direction} 杀分档位下排除 5/30 后无可用奖励,重新摇取档位");
|
||
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';
|
||
// 摇色子中奖:按 dice_reward_config.real_ev 直接结算(已乘 ante);不再叠加票价 100
|
||
$rewardWinCoin = $realEv * $ante;
|
||
|
||
// 豹子判定:5/30 必豹子;10/15/20/25 按 DiceRewardConfig 中 BIGWIN 该点数的 weight 判定(0-10000,10000=100%)
|
||
// 杀分档位:不触发豹子,5/30 已在上方抽取时排除,10/15/20/25 仅生成非豹子组合
|
||
$superWinCoin = 0;
|
||
$isWin = 0;
|
||
$bigWinRealEv = 0.0;
|
||
if (in_array($rollNumber, self::SUPER_WIN_GRID_NUMBERS, true)) {
|
||
if ($usePoolWeights) {
|
||
// 杀分档位:绝不触发豹子,仅生成非豹子组合,不发放豹子奖金
|
||
$isWin = 0;
|
||
$superWinCoin = 0.0;
|
||
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
|
||
} else {
|
||
$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;
|
||
$bigWinEv = $bigWinRealEv > 0 ? $bigWinRealEv : self::SUPER_WIN_BONUS;
|
||
$superWinCoin = $bigWinEv * $ante;
|
||
// 中 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,
|
||
$ante,
|
||
$paidAmount,
|
||
$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,
|
||
'ante' => $ante,
|
||
'paid_amount' => $paidAmount,
|
||
'is_win' => $isWin,
|
||
'win_coin' => $winCoin,
|
||
'super_win_coin' => $superWinCoin,
|
||
'reward_win_coin' => $rewardWinCoin,
|
||
'use_coins' => $paidAmount,
|
||
'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;
|
||
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0)
|
||
$coinAfter = $coinBefore - $paidAmount + $winCoin;
|
||
$p->coin = $coinAfter;
|
||
// 不再使用抽奖券作为抽奖条件:付费不扣抽奖次数;免费抽奖仅消耗 free_ticket_count
|
||
if ($ticketType === self::LOTTERY_TYPE_FREE) {
|
||
$p->free_ticket_count = max(0, (int) $p->free_ticket_count - 1);
|
||
}
|
||
|
||
// 记录每次游玩:写入抽奖券记录(用于后台“抽奖券记录”追踪付费/免费游玩与消耗)
|
||
$isPaidPlay = $ticketType === self::LOTTERY_TYPE_PAID;
|
||
$paidCnt = $isPaidPlay ? 1 : 0;
|
||
$freeCnt = $isPaidPlay ? 0 : 1;
|
||
DicePlayerTicketRecord::create([
|
||
'player_id' => $playerId,
|
||
'admin_id' => $adminId,
|
||
'use_coins' => $paidAmount,
|
||
'ante' => $ante,
|
||
'total_ticket_count' => $paidCnt + $freeCnt,
|
||
'paid_ticket_count' => $paidCnt,
|
||
'free_ticket_count' => $freeCnt,
|
||
'remark' => ($isPaidPlay ? '付费游玩' : '免费游玩') . '|play_record_id=' . $record->id,
|
||
]);
|
||
|
||
// 若本局中奖档位为 T5,则额外赠送 1 次免费抽奖次数(总次数也 +1),并记录抽奖券获取记录
|
||
if ($isTierT5) {
|
||
$p->free_ticket_count = (int) $p->free_ticket_count + 1;
|
||
|
||
DicePlayerTicketRecord::create([
|
||
'player_id' => $playerId,
|
||
'admin_id' => $adminId,
|
||
'ante' => $ante,
|
||
'free_ticket_count' => 1,
|
||
'remark' => '中奖结果为T5',
|
||
]);
|
||
// 记录免费抽奖注数,用于强制下一局注数一致
|
||
Cache::set(self::FREE_ANTE_KEY_PREFIX . $playerId, $ante, self::FREE_ANTE_TTL);
|
||
} else {
|
||
// 若本次消耗了最后一次免费抽奖,则清理注数锁
|
||
if ($ticketType === self::LOTTERY_TYPE_FREE && (int) $p->free_ticket_count <= 0) {
|
||
Cache::delete(self::FREE_ANTE_KEY_PREFIX . $playerId);
|
||
}
|
||
}
|
||
|
||
$p->save();
|
||
|
||
// 彩金池累计盈利累加在 name=default 彩金池上:
|
||
// 付费:每局按「本局赢取平台币 win_coin - 抽奖费用 paid_amount(ante*100)」
|
||
// 免费券:paid_amount=0,只计入 win_coin
|
||
$perPlayProfit = ($ticketType === self::LOTTERY_TYPE_PAID) ? ($winCoin - (float) $paidAmount) : $winCoin;
|
||
$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,
|
||
// 钱包流水记录本局净变化:-付费金额 + 中奖金额(免费抽奖付费金额为 0)
|
||
'coin' => $winCoin - (float) $paidAmount,
|
||
'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;
|
||
return $arr;
|
||
}
|
||
|
||
/** 该组配置权重均为 0 时抛出,供调用方重试 */
|
||
private const EXCEPTION_WEIGHT_ALL_ZERO = 'REWARD_WEIGHT_ALL_ZERO';
|
||
|
||
/** 杀分档位需排除的豹子号:5 和 30 只能组成豹子,无法生成非豹子组合 */
|
||
private const KILL_MODE_EXCLUDE_GRIDS = [5, 30];
|
||
|
||
/**
|
||
* 杀分档位下排除 grid_number=5/30 的奖励(5/30 只能豹子,无法剔除)
|
||
* @return array 排除后的奖励列表,保持索引连续
|
||
*/
|
||
private static function filterOutSuperWinOnlyGrids(array $rewards): array
|
||
{
|
||
return array_values(array_filter($rewards, function ($r) {
|
||
$g = (int) ($r['grid_number'] ?? 0);
|
||
return !in_array($g, self::KILL_MODE_EXCLUDE_GRIDS, true);
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 按权重抽取一条配置:仅 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 int $ante 底注/注数(dice_ante_config.mult)
|
||
* @param array|null $customTierWeights 自定义档位权重 ['T1'=>x, 'T2'=>x, ...],非空时忽略 config 的档位权重
|
||
* @return array 可直接用于 DicePlayRecordTest::create 的字段 + tier(用于统计档位概率)
|
||
*/
|
||
public function simulateOnePlay($config, int $direction, int $lotteryType = 0, int $ante = 1, ?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;
|
||
}
|
||
// 免费券或 killScore 池:与实际流程一致,排除 5/30 且不触发豹子
|
||
$useKillMode = ($lotteryType === 1) || ($config !== null && (string) ($config->name ?? '') === 'killScore');
|
||
if ($useKillMode) {
|
||
$tierRewards = self::filterOutSuperWinOnlyGrids($tierRewards);
|
||
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);
|
||
// 摇色子中奖:按 real_ev 直接结算(与正式抽奖 run() 一致)
|
||
$rewardWinCoin = $realEv * $ante;
|
||
|
||
$superWinCoin = 0;
|
||
$isWin = 0;
|
||
$bigWinRealEv = 0.0;
|
||
if (in_array($rollNumber, self::SUPER_WIN_GRID_NUMBERS, true)) {
|
||
if ($useKillMode) {
|
||
// 杀分档位:绝不触发豹子,仅生成非豹子组合,不发放豹子奖金
|
||
$isWin = 0;
|
||
$superWinCoin = 0.0;
|
||
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
|
||
} else {
|
||
$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;
|
||
$bigWinEv = $bigWinRealEv > 0 ? $bigWinRealEv : self::SUPER_WIN_BONUS;
|
||
$superWinCoin = $bigWinEv * $ante;
|
||
$rewardWinCoin = 0;
|
||
// 中豹子时不走原奖励流程
|
||
$realEv = 0.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);
|
||
$paidAmount = $lotteryType === 0 ? ($ante * self::UNIT_COST) : 0;
|
||
|
||
return [
|
||
'player_id' => 0,
|
||
'admin_id' => 0,
|
||
'lottery_config_id' => $configId,
|
||
'lottery_type' => $lotteryType,
|
||
'is_win' => $isWin,
|
||
'win_coin' => $winCoin,
|
||
'ante' => $ante,
|
||
'paid_amount' => $paidAmount,
|
||
'super_win_coin' => $superWinCoin,
|
||
'reward_win_coin' => $rewardWinCoin,
|
||
'use_coins' => $paidAmount,
|
||
'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,
|
||
];
|
||
}
|
||
}
|