372 lines
15 KiB
PHP
372 lines
15 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\api\logic;
|
||
|
||
use app\api\cache\UserCache;
|
||
use app\api\service\LotteryService;
|
||
use app\dice\model\lottery_config\DiceLotteryConfig;
|
||
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_config\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_number(5=全1 10=全2 15=全3 20=全4 25=全5 30=全6);其中 5 和 30 固定 100% 出豹子 */
|
||
private const SUPER_WIN_GRID_NUMBERS = [5, 10, 15, 20, 25, 30];
|
||
/** grid_number 为 5 或 30 时豹子概率固定 100%(DiceRewardConfig tier=BIGWIN 约定) */
|
||
private const SUPER_WIN_ALWAYS_GRID_NUMBERS = [5, 30];
|
||
|
||
/**
|
||
* 执行一局游戏
|
||
* @param int $playerId 玩家ID
|
||
* @param int $direction 方向 0=无/顺时针 1=中奖/逆时针(前端 direction)
|
||
* @return array 成功返回 DicePlayRecord 数据;余额不足时抛 ApiException,message 为约定文案
|
||
*/
|
||
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() ? DiceLotteryConfig::find($lotteryService->getConfigType0Id()) : null)
|
||
: ($lotteryService->getConfigType1Id() ? DiceLotteryConfig::find($lotteryService->getConfigType1Id()) : null);
|
||
if (!$config) {
|
||
throw new ApiException('奖池配置不存在');
|
||
}
|
||
|
||
// 按玩家权重抽取档位;若该档位无奖励或该方向下均无可用路径则重新摇取档位
|
||
$maxTierRetry = 10;
|
||
$chosen = null;
|
||
$startCandidates = [];
|
||
$tier = null;
|
||
for ($tierAttempt = 0; $tierAttempt < $maxTierRetry; $tierAttempt++) {
|
||
$tier = LotteryService::drawTierByPlayerWeights($player);
|
||
$tierRewards = DiceRewardConfig::getCachedByTier($tier);
|
||
if (empty($tierRewards)) {
|
||
Log::warning("档位 {$tier} 无任何奖励配置,重新摇取档位");
|
||
continue;
|
||
}
|
||
$maxRewardRetry = count($tierRewards);
|
||
for ($attempt = 0; $attempt < $maxRewardRetry; $attempt++) {
|
||
$chosen = $tierRewards[array_rand($tierRewards)];
|
||
$chosenId = (int) ($chosen['id'] ?? 0);
|
||
if ($direction === 0) {
|
||
$startCandidates = DiceRewardConfig::getCachedBySEndIndex($chosenId);
|
||
} else {
|
||
$startCandidates = DiceRewardConfig::getCachedByNEndIndex($chosenId);
|
||
}
|
||
if (!empty($startCandidates)) {
|
||
break 2;
|
||
}
|
||
Log::warning("方向 {$direction} 下无 s_end_index/n_end_index={$chosenId} 的配置,重新摇取");
|
||
}
|
||
Log::warning("方向 {$direction} 下档位 {$tier} 所有奖励均无可用路径配置,重新摇取档位");
|
||
}
|
||
if (empty($startCandidates)) {
|
||
Log::error("方向 {$direction} 下多次摇取档位后仍无可用路径配置");
|
||
throw new ApiException('该方向下暂无可用路径配置');
|
||
}
|
||
$chosenId = (int) ($chosen['id'] ?? 0);
|
||
$startRecord = $startCandidates[array_rand($startCandidates)];
|
||
|
||
$startIndex = (int) ($startRecord['id'] ?? 0);
|
||
$targetIndex = $direction === 0
|
||
? (int) ($startRecord['s_end_index'] ?? 0)
|
||
: (int) ($startRecord['n_end_index'] ?? 0);
|
||
$rollNumber = (int) ($startRecord['grid_number'] ?? 0);
|
||
$realEv = (float) ($chosen['real_ev'] ?? 0);
|
||
$rewardWinCoin = 100 + $realEv; // 摇色子中奖平台币 = 100 + DiceRewardConfig.real_ev
|
||
|
||
// 当抽到的 grid_number 为 5/10/15/20/25/30 时,可出豹子;其中 grid_number=5 与 30 固定 100% 豹子(BIGWIN 约定)
|
||
$superWinCoin = 0;
|
||
$isWin = 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) {
|
||
$weight = $bigWinConfig !== null
|
||
? max(0.0, min(100.0, (float) ($bigWinConfig['weight'] ?? 0)))
|
||
: 100.0;
|
||
$roll = mt_rand(1, 10000) / 10000;
|
||
$doSuperWin = $roll <= $weight / 100;
|
||
}
|
||
if ($doSuperWin) {
|
||
$rollArray = $this->getSuperWinRollArray($rollNumber);
|
||
$isWin = 1;
|
||
$superWinCoin = $bigWinConfig !== null
|
||
? 100 + (float) ($bigWinConfig['real_ev'] ?? 0)
|
||
: self::SUPER_WIN_BONUS;
|
||
} 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; // 赢取平台币 = 中大奖 + 摇色子中奖
|
||
|
||
$record = null;
|
||
$configId = (int) $config->id;
|
||
$rewardId = $chosenId;
|
||
$configName = (string) ($config->name ?? '');
|
||
$isTierT5 = (string) ($chosen['tier'] ?? '') === 'T5';
|
||
$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,
|
||
$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();
|
||
|
||
// 累加彩金池盈利额度(累加值为 -real_ev)。若 dice_lottery_config 表有 ev 字段则执行
|
||
try {
|
||
DiceLotteryConfig::where('id', $configId)->update([
|
||
'ev' => Db::raw('IFNULL(ev,0) - ' . (float) $realEv),
|
||
]);
|
||
} catch (\Throwable $_) {
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 根据摇取点数(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[array_rand($candidates)];
|
||
$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 = array_rand($arr);
|
||
$j = array_rand($arr);
|
||
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);
|
||
}
|
||
}
|