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

750 lines
34 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\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 WALLET_TYPE_BUY_DRAW = 2;
/** 抽奖类型:付费 */
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;
/** 单注费用(抽奖券基础费用) */
private const UNIT_COST = 1.0;
/** 免费抽奖注数缓存 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_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
* @param int $ante 注数(必须在 DiceAnteConfig.mult 中存在)
* @return array 成功返回 DicePlayRecord 数据;余额不足时抛 ApiExceptionmessage 为约定文案
*/
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带 ante 与 count兼容旧字段 free_ticket_count
$freeTicket = $player->free_ticket ?? null;
$freeTicketAnte = null;
$freeTicketCount = 0;
if (is_array($freeTicket)) {
$a = $freeTicket['ante'] ?? null;
$c = $freeTicket['count'] ?? null;
if ($a !== null && $a !== '' && is_numeric($a)) {
$freeTicketAnte = (int) $a;
}
if ($c !== null && $c !== '' && is_numeric($c)) {
$freeTicketCount = (int) $c;
}
}
$legacyFreeCount = (int) ($player->free_ticket_count ?? 0);
$isFree = ($freeTicketAnte !== null && $freeTicketCount > 0) || $legacyFreeCount > 0;
$ticketType = $isFree ? self::LOTTERY_TYPE_FREE : self::LOTTERY_TYPE_PAID;
// 若为 free_ticket 免费抽奖:注数必须与券的 ante 一致
if ($ticketType === self::LOTTERY_TYPE_FREE && $freeTicketAnte !== null && $freeTicketCount > 0) {
if ($ante !== $freeTicketAnte) {
throw new ApiException('您有一张底注为' . $freeTicketAnte . '的免费抽奖券');
}
}
// 若为免费抽奖(旧逻辑):注数必须与上一次触发免费抽奖时的注数一致
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 * UNIT_COST不足则提示余额不足
$paidAmount = $ticketType === self::LOTTERY_TYPE_PAID ? round($ante * self::UNIT_COST, 2) : 0.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);
// T5/再来一次:以奖励行 tier 为准,并以摇奖档位 $tier 兜底(与 reward_tier 展示一致,避免 dice_reward 行缺 tier 时不发券)
$isTierT5 = (string) ($chosen['tier'] ?? '') === 'T5';
if ($isTierT5 === false && (string) ($tier ?? '') === 'T5') {
$isTierT5 = true;
}
// 摇色子中奖:按 dice_reward_config.real_ev 直接结算(已乘 ante
$rewardWinCoin = round($realEv * $ante, 2);
// 豹子判定5/30 必豹子10/15/20/25 按 DiceRewardConfig 中 BIGWIN 该点数的 weight 判定0-1000010000=100%
// 杀分档位不触发豹子5/30 已在上方抽取时排除10/15/20/25 仅生成非豹子组合
$superWinCoin = 0.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 = round($bigWinEv * $ante, 2);
// 中 BIGWIN 豹子:不走原奖励流程,不记录原奖励,不触发 T5 再来一次,仅发放豹子奖金
$rewardWinCoin = 0.0;
$realEv = 0.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 = round($superWinCoin + $rewardWinCoin, 2); // 赢取平台币 = 中大奖 + 摇色子中奖(豹子时 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,
$configName,
$ticketType,
$ante,
$paidAmount,
$winCoin,
$superWinCoin,
$rewardWinCoin,
$isWin,
$realEv,
$bigWinRealEv,
$direction,
$startIndex,
$targetIndex,
$rollArray,
$isTierT5,
$tier,
&$record
) {
$rewardTier = ($isWin === 1 && $superWinCoin > 0) ? 'BIGWIN' : (string) ($tier ?? '');
$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,
'direction' => $direction,
'reward_tier' => $rewardTier,
'start_index' => $startIndex,
'target_index' => $targetIndex,
'roll_array' => is_array($rollArray) ? json_encode($rollArray) : $rollArray,
'roll_number' => is_array($rollArray) ? array_sum($rollArray) : 0,
'status' => self::RECORD_STATUS_SUCCESS,
]);
$p = DicePlayer::find($playerId);
if (!$p) {
throw new \RuntimeException('玩家不存在');
}
$coinBefore = (float) $p->coin;
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0
$coinAfter = round($coinBefore - $paidAmount + $winCoin, 2);
$p->coin = $coinAfter;
// 免费抽奖消耗:优先消耗 free_ticket.count耗尽则清空 free_ticket否则兼容旧 free_ticket_count
if ($ticketType === self::LOTTERY_TYPE_FREE) {
$ft = $p->free_ticket ?? null;
$ftAnte = null;
$ftCount = 0;
if (is_array($ft)) {
$a = $ft['ante'] ?? null;
$c = $ft['count'] ?? null;
if ($a !== null && $a !== '' && is_numeric($a)) {
$ftAnte = (int) $a;
}
if ($c !== null && $c !== '' && is_numeric($c)) {
$ftCount = (int) $c;
}
}
if ($ftAnte !== null && $ftCount > 0) {
$next = $ftCount - 1;
if ($next <= 0) {
$p->free_ticket = null;
} else {
$p->free_ticket = ['ante' => $ftAnte, 'count' => $next];
}
} else {
$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 次免费抽奖次数:
// - 新结构:写入 free_ticketante=本局注数count+1
// - 兼容旧结构free_ticket_count +1
if ($isTierT5) {
$ft = $p->free_ticket ?? null;
$ftAnte = null;
$ftCount = 0;
if (is_array($ft)) {
$a = $ft['ante'] ?? null;
$c = $ft['count'] ?? null;
if ($a !== null && $a !== '' && is_numeric($a)) {
$ftAnte = (int) $a;
}
if ($c !== null && $c !== '' && is_numeric($c)) {
$ftCount = (int) $c;
}
}
if ($ftAnte === null) {
$ftAnte = $ante;
}
if ($ftAnte === $ante) {
$p->free_ticket = ['ante' => $ante, 'count' => $ftCount + 1];
} else {
// 若已有不同注数的免费券,则仍保留旧字段累加,避免覆盖玩家已有券
$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 {
// 若本次消耗了最后一次免费抽奖,则清理注数锁
$ft = $p->free_ticket ?? null;
$ftCount = 0;
if (is_array($ft)) {
$c = $ft['count'] ?? null;
if ($c !== null && $c !== '' && is_numeric($c)) {
$ftCount = (int) $c;
}
}
if ($ticketType === self::LOTTERY_TYPE_FREE && $ftCount <= 0 && (int) $p->free_ticket_count <= 0) {
Cache::delete(self::FREE_ANTE_KEY_PREFIX . $playerId);
}
}
$p->save();
// 彩金池累计盈利累加在 name=default 彩金池上:
// 付费:每局按「本局赢取平台币 win_coin - 抽奖费用 paid_amountante*UNIT_COST
// 免费券paid_amount=0只计入 win_coin
$perPlayProfit = ($ticketType === self::LOTTERY_TYPE_PAID) ? ($winCoin - $paidAmount) : $winCoin;
$addProfit = round($perPlayProfit, 2);
try {
DiceLotteryPoolConfig::where('id', $type0ConfigId)->update([
'profit_amount' => Db::raw('IFNULL(profit_amount,0) + ' . sprintf('%.2f', $addProfit)),
]);
} catch (\Throwable $e) {
Log::warning('彩金池盈利累加失败', [
'config_id' => $type0ConfigId,
'add_profit' => $addProfit,
'message' => $e->getMessage(),
]);
}
// 钱包流水拆分:先记录购券扣费,再记录抽奖结果(中奖/惩罚)
if ($paidAmount > 0) {
$walletAfterBuy = round($coinBefore - $paidAmount, 2);
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'admin_id' => $adminId,
'coin' => round(-$paidAmount, 2),
'type' => self::WALLET_TYPE_BUY_DRAW,
'wallet_before' => $coinBefore,
'wallet_after' => $walletAfterBuy,
'remark' => '抽奖购券扣费|play_record_id=' . $record->id,
]);
}
$walletBeforeDraw = $coinBefore - $paidAmount;
$drawRemark = ($winCoin >= 0 ? '抽奖中奖' : '抽奖惩罚') . '|play_record_id=' . $record->id;
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'admin_id' => $adminId,
'coin' => $winCoin,
'type' => self::WALLET_TYPE_DRAW,
'wallet_before' => round($walletBeforeDraw, 2),
'wallet_after' => $coinAfter,
'remark' => $drawRemark,
]);
});
} 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,
'direction' => $direction,
'reward_tier' => '',
'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['reward_tier'] = ($isWin === 1 && $superWinCoin > 0) ? 'BIGWIN' : (string) ($tier ?? '');
// 记录完数据后返回当前玩家余额与抽奖次数
$arr['coin'] = $updated ? round((float) $updated->coin, 2) : 0.0;
// 本局从玩家货币中扣除的金额:付费抽奖为 ante*UNIT_COST免费抽奖为 0与 paid_amount 一致)
$arr['use_coin'] = round($paidAmount, 2);
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-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 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 = round($realEv * $ante, 2);
$superWinCoin = 0.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 = round($bigWinEv * $ante, 2);
$rewardWinCoin = 0.0;
// 中豹子时不走原奖励流程
$realEv = 0.0;
} else {
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
}
}
} else {
$rollArray = $this->generateRollArrayFromSum($rollNumber);
}
$winCoin = round($superWinCoin + $rewardWinCoin, 2);
$configId = $config !== null ? (int) $config->id : 0;
$configName = $config !== null ? (string) ($config->name ?? '') : '自定义';
$costRealEv = $realEv + ($isWin === 1 ? $bigWinRealEv : 0.0);
$paidAmount = $lotteryType === 0 ? round($ante * self::UNIT_COST, 2) : 0.0;
$rewardTier = ($isWin === 1 && $superWinCoin > 0) ? 'BIGWIN' : (string) ($tier ?? '');
// 与写入记录的 reward_tier 完全一致:仅当展示档位为 T5非豹子大奖时触发「再来一次」链式免费局
$grantsFreeTicket = ($rewardTier === 'T5');
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,
'direction' => $direction,
'reward_tier' => $rewardTier,
'start_index' => $startIndex,
'target_index' => $targetIndex,
'roll_array' => json_encode($rollArray),
'roll_number' => array_sum($rollArray),
'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,
'grants_free_ticket' => $grantsFreeTicket,
];
}
}