From a68e82107ec9aae8a04f23082abfce1033b4bd3b Mon Sep 17 00:00:00 2001 From: lkddi Date: Thu, 26 Mar 2026 11:49:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20AI=20=E9=92=93?= =?UTF-8?q?=E9=B1=BC=E4=B8=8E=E7=99=BE=E5=AE=B6=E4=B9=90=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E7=9A=84=E5=8F=82=E4=B8=8E=E9=80=BB=E8=BE=91=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=90=8E=E5=8F=B0=E9=9D=A2=E6=9D=BF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/AiHeartbeatCommand.php | 27 ++++ .../Admin/AiProviderController.php | 27 +++- app/Http/Controllers/FishingController.php | 81 +--------- app/Jobs/AiBaccaratBetJob.php | 150 ++++++++++++++++++ app/Jobs/AiFishingJob.php | 45 ++++++ app/Jobs/CloseBaccaratRoundJob.php | 25 +++ app/Jobs/OpenBaccaratRoundJob.php | 7 + app/Services/FishingService.php | 122 ++++++++++++++ .../views/admin/ai-providers/index.blade.php | 16 ++ 9 files changed, 422 insertions(+), 78 deletions(-) create mode 100644 app/Jobs/AiBaccaratBetJob.php create mode 100644 app/Jobs/AiFishingJob.php create mode 100644 app/Services/FishingService.php diff --git a/app/Console/Commands/AiHeartbeatCommand.php b/app/Console/Commands/AiHeartbeatCommand.php index 0d59ff1..b0b1a3f 100644 --- a/app/Console/Commands/AiHeartbeatCommand.php +++ b/app/Console/Commands/AiHeartbeatCommand.php @@ -128,6 +128,33 @@ class AiHeartbeatCommand extends Command ); } + // 7. 钓鱼小游戏随机参与逻辑 + $fishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1'; + $fishingChance = (int) Sysparam::getValue('chatbot_fishing_chance', '5'); // 默认 5% 概率 + if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance && \App\Models\GameConfig::isEnabled('fishing')) { + $cost = (int) (\App\Models\GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5')); + if ($user->jjb >= $cost) { + // 先扣除费用 + $this->currencyService->change( + $user, 'gold', -$cost, + CurrencySource::FISHING_COST, + "AI小班长钓鱼抛竿消耗 {$cost} 金币", + 1, + ); + + // 模拟玩家等待时间 + $waitMin = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8')); + $waitMax = (int) (\App\Models\GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15')); + $waitTime = rand($waitMin, $waitMax); + + // 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids) + $activeRoomIds = $this->chatState->getAllActiveRoomIds(); + $roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1; + + \App\Jobs\AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime)); + } + } + return Command::SUCCESS; } diff --git a/app/Http/Controllers/Admin/AiProviderController.php b/app/Http/Controllers/Admin/AiProviderController.php index 629ddbb..8b6f713 100644 --- a/app/Http/Controllers/Admin/AiProviderController.php +++ b/app/Http/Controllers/Admin/AiProviderController.php @@ -48,8 +48,13 @@ class AiProviderController extends Controller $chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1'; $chatbotMaxGold = Sysparam::getValue('chatbot_max_gold', '5000'); $chatbotMaxDailyRewards = Sysparam::getValue('chatbot_max_daily_rewards', '1'); + $chatbotFishingEnabled = Sysparam::getValue('chatbot_fishing_enabled', '0') === '1'; + $chatbotBaccaratEnabled = Sysparam::getValue('chatbot_baccarat_enabled', '0') === '1'; - return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled', 'chatbotMaxGold', 'chatbotMaxDailyRewards')); + return view('admin.ai-providers.index', compact( + 'providers', 'chatbotEnabled', 'chatbotMaxGold', + 'chatbotMaxDailyRewards', 'chatbotFishingEnabled', 'chatbotBaccaratEnabled' + )); } /** @@ -60,6 +65,8 @@ class AiProviderController extends Controller $data = $request->validate([ 'chatbot_max_gold' => 'required|integer|min:1', 'chatbot_max_daily_rewards' => 'required|integer|min:1', + 'chatbot_fishing_enabled' => 'required|in:0,1', + 'chatbot_baccarat_enabled' => 'required|in:0,1', ]); Sysparam::updateOrCreate( ['alias' => 'chatbot_max_gold'], @@ -79,6 +86,24 @@ class AiProviderController extends Controller ); Sysparam::clearCache('chatbot_max_daily_rewards'); + Sysparam::updateOrCreate( + ['alias' => 'chatbot_fishing_enabled'], + [ + 'body' => $data['chatbot_fishing_enabled'], + 'guidetxt' => 'AI 参与钓鱼游戏开关', + ] + ); + Sysparam::clearCache('chatbot_fishing_enabled'); + + Sysparam::updateOrCreate( + ['alias' => 'chatbot_baccarat_enabled'], + [ + 'body' => $data['chatbot_baccarat_enabled'], + 'guidetxt' => 'AI 参与百家乐游戏开关', + ] + ); + Sysparam::clearCache('chatbot_baccarat_enabled'); + return back()->with('success', '全局设置保存成功!'); } diff --git a/app/Http/Controllers/FishingController.php b/app/Http/Controllers/FishingController.php index 045ed28..fa92658 100644 --- a/app/Http/Controllers/FishingController.php +++ b/app/Http/Controllers/FishingController.php @@ -17,11 +17,10 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; -use App\Events\MessageSent; -use App\Models\FishingEvent; use App\Models\GameConfig; use App\Models\Sysparam; use App\Services\ChatStateService; +use App\Services\FishingService; use App\Services\ShopService; use App\Services\UserCurrencyService; use App\Services\VipService; @@ -38,6 +37,7 @@ class FishingController extends Controller private readonly VipService $vipService, private readonly UserCurrencyService $currencyService, private readonly ShopService $shopService, + private readonly FishingService $fishingService, ) {} /** @@ -179,52 +179,8 @@ class FishingController extends Controller $cooldown = (int) (GameConfig::param('fishing', 'fishing_cooldown') ?? Sysparam::getValue('fishing_cooldown', '300')); Redis::setex("fishing:cd:{$user->id}", $cooldown, time()); - // 3. 随机决定钓鱼结果 - $result = $this->randomFishResult(); - - // 4. 通过统一积分服务更新经验和金币 - $expMul = $this->vipService->getExpMultiplier($user); - $jjbMul = $this->vipService->getJjbMultiplier($user); - if ($result['exp'] !== 0) { - $finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp']; - $this->currencyService->change( - $user, 'exp', $finalExp, CurrencySource::FISHING_GAIN, - "钓鱼收竿:{$result['message']}", $id, - ); - } - if ($result['jjb'] !== 0) { - $finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb']; - $this->currencyService->change( - $user, 'gold', $finalJjb, CurrencySource::FISHING_GAIN, - "钓鱼收竿:{$result['message']}", $id, - ); - } - $user->refresh(); - - // 5. 广播钓鱼结果到聊天室 - // 若使用自动钓鱼卡,在消息末尾附加购买推广小标签(其他人点击可打开商店) - $autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user); - $promoTag = $autoFishingMinutesLeft > 0 - ? ' 🎣 自动钓鱼卡' - : ''; - - $sysMsg = [ - 'id' => $this->chatState->nextMessageId($id), - 'room_id' => $id, - 'from_user' => '钓鱼播报', - 'to_user' => '大家', - 'content' => "{$result['emoji']} 【{$user->username}】{$result['message']}{$promoTag}", - 'is_secret' => false, - 'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626', - 'action' => '', - 'sent_at' => now()->toDateTimeString(), - ]; - - $this->chatState->pushMessage($id, $sysMsg); - broadcast(new MessageSent($id, $sysMsg)); + // 3. 随机决定钓鱼结果并广播(直接调用服务) + $result = $this->fishingService->processCatch($user, $id, false); return response()->json([ 'status' => 'success', @@ -234,33 +190,4 @@ class FishingController extends Controller 'cooldown_seconds' => $cooldown, // 前端自动钓鱼卡循环等待用 ]); } - - /** - * 随机钓鱼结果(从数据库 fishing_events 加权随机抽取) - * - * 若数据库中无激活事件,回退到兜底结果。 - * - * @return array{emoji: string, message: string, exp: int, jjb: int} - */ - private function randomFishResult(): array - { - $event = FishingEvent::rollOne(); - - // 数据库无事件时的兜底 - if (! $event) { - return [ - 'emoji' => '🐟', - 'message' => '钓到一条小鱼,获得金币10', - 'exp' => 0, - 'jjb' => 10, - ]; - } - - return [ - 'emoji' => $event->emoji, - 'message' => $event->message, - 'exp' => $event->exp, - 'jjb' => $event->jjb, - ]; - } } diff --git a/app/Jobs/AiBaccaratBetJob.php b/app/Jobs/AiBaccaratBetJob.php new file mode 100644 index 0000000..fe4b784 --- /dev/null +++ b/app/Jobs/AiBaccaratBetJob.php @@ -0,0 +1,150 @@ +round->fresh(); + if (! $round || ! $round->isBettingOpen()) { + return; + } + + $user = User::where('username', 'AI小班长')->first(); + if (! $user) { + return; + } + + // 2. 检查连输惩罚超时 + if (Redis::exists('ai_baccarat_timeout')) { + return; // 还在禁赛期 + } + + // 3. 检查余额与限额 + $config = GameConfig::forGame('baccarat')?->params ?? []; + $minBet = (int) ($config['min_bet'] ?? 100); + $maxBet = (int) ($config['max_bet'] ?? 50000); + + // 至少保留 2000 金币底仓 + $availableGold = ($user->jjb ?? 0) - 2000; + if ($availableGold < $minBet) { + return; // 资金不足以支撑最小下注 + } + + // 下注金额:可用余额的 2% ~ 5%,并在 min_bet 和 max_bet 之间 + $percent = rand(2, 5) / 100.0; + $amount = (int) round($availableGold * $percent); + $amount = max($minBet, min($amount, $maxBet)); + + // 如果依然大于实际 jjb (保险兜底),则放弃 + if ($amount > $user->jjb) { + return; + } + + // 4. 决策逻辑:简单分析近期路单 + // 取最近 10 局 + $recentResults = BaccaratRound::query() + ->where('status', 'settled') + ->orderByDesc('id') + ->limit(10) + ->pluck('result') + ->toArray(); + + $bigCount = count(array_filter($recentResults, fn ($r) => $r === 'big')); + $smallCount = count(array_filter($recentResults, fn ($r) => $r === 'small')); + + // 基础策略:追逐热点 (跟大部队) 或 均值回归 (逆势) + // 这里做一个简单的随机倾向: + $strategy = rand(1, 100); + if ($strategy <= 10) { + $betType = 'triple'; // 10% 概率博豹子 + } elseif ($bigCount > $smallCount) { + // 大偏热,70%概率顺势买大,30%逆势买小 + $betType = rand(1, 100) <= 70 ? 'big' : 'small'; + } elseif ($smallCount > $bigCount) { + $betType = rand(1, 100) <= 70 ? 'small' : 'big'; + } else { + $betType = rand(0, 1) ? 'big' : 'small'; + } + + // 5. 执行下注 (同 BaccaratController::bet 逻辑) + DB::transaction(function () use ($user, $round, $betType, $amount, $currency) { + // 幂等:同一局只能下一注 + $existing = BaccaratBet::query() + ->where('round_id', $round->id) + ->where('user_id', $user->id) + ->lockForUpdate() + ->exists(); + + if ($existing) { + return; + } + + // 扣除金币 + $currency->change( + $user, + 'gold', + -$amount, + CurrencySource::BACCARAT_BET, + "AI小班长百家乐 #{$round->id} 押 ".match ($betType) { + 'big' => '大', 'small' => '小', default => '豹子' + }, + ); + + // 写入下注记录 + BaccaratBet::create([ + 'round_id' => $round->id, + 'user_id' => $user->id, + 'bet_type' => $betType, + 'amount' => $amount, + 'status' => 'pending', + ]); + + // 更新局次汇总统计 + $field = 'total_bet_'.$betType; + $round->increment($field, $amount); + $round->increment('bet_count'); + }); + } +} diff --git a/app/Jobs/AiFishingJob.php b/app/Jobs/AiFishingJob.php new file mode 100644 index 0000000..d7fdb42 --- /dev/null +++ b/app/Jobs/AiFishingJob.php @@ -0,0 +1,45 @@ +processCatch($this->aiUser, $this->roomId, true); + } +} diff --git a/app/Jobs/CloseBaccaratRoundJob.php b/app/Jobs/CloseBaccaratRoundJob.php index d2d3edc..c5ccae6 100644 --- a/app/Jobs/CloseBaccaratRoundJob.php +++ b/app/Jobs/CloseBaccaratRoundJob.php @@ -25,6 +25,7 @@ use App\Services\UserCurrencyService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Redis; class CloseBaccaratRoundJob implements ShouldQueue { @@ -103,6 +104,10 @@ class CloseBaccaratRoundJob implements ShouldQueue $bet->update(['status' => 'lost', 'payout' => 0]); $losers[] = "{$username}-{$bet->amount}"; + if ($username === 'AI小班长') { + $this->handleAiLoseStreak(); + } + continue; } @@ -121,9 +126,17 @@ class CloseBaccaratRoundJob implements ShouldQueue ); $totalPayout += $payout; $winners[] = "{$username}+".number_format($payout); + + if ($username === 'AI小班长') { + Redis::del('ai_baccarat_lose_streak'); // 赢了清空连输 + } } else { $bet->update(['status' => 'lost', 'payout' => 0]); $losers[] = "{$username}-".number_format($bet->amount); + + if ($username === 'AI小班长') { + $this->handleAiLoseStreak(); + } } } }); @@ -149,6 +162,18 @@ class CloseBaccaratRoundJob implements ShouldQueue $this->pushResultMessage($round, $chatState, $winners, $losers); } + /** + * 处理 AI 小班长连输逻辑 + */ + private function handleAiLoseStreak(): void + { + $streak = Redis::incr('ai_baccarat_lose_streak'); + if ($streak >= 3) { + Redis::setex('ai_baccarat_timeout', 3600, 'timeout'); // 连输三次,停赛1小时 + Redis::del('ai_baccarat_lose_streak'); + } + } + /** * 向公屏发送开奖结果系统消息(含各用户输赢情况)。 * diff --git a/app/Jobs/OpenBaccaratRoundJob.php b/app/Jobs/OpenBaccaratRoundJob.php index 11b1e55..6c9f57c 100644 --- a/app/Jobs/OpenBaccaratRoundJob.php +++ b/app/Jobs/OpenBaccaratRoundJob.php @@ -89,6 +89,13 @@ class OpenBaccaratRoundJob implements ShouldQueue broadcast(new MessageSent(1, $msg)); SaveMessageJob::dispatch($msg); + // 如果允许 AI 参与,延迟一定时间派发 AI 下注任务 + $baccaratEnabled = \App\Models\Sysparam::getValue('chatbot_baccarat_enabled', '0') === '1'; + if (\App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1' && $baccaratEnabled) { + $aiDelay = rand(10, min(40, max(10, $betSeconds - 5))); // 随机在 10 ~ (倒数前5秒) 之间下注 + \App\Jobs\AiBaccaratBetJob::dispatch($round)->delay(now()->addSeconds($aiDelay)); + } + // 在下注截止时安排结算任务 CloseBaccaratRoundJob::dispatch($round)->delay($closesAt); } diff --git a/app/Services/FishingService.php b/app/Services/FishingService.php new file mode 100644 index 0000000..7f3c933 --- /dev/null +++ b/app/Services/FishingService.php @@ -0,0 +1,122 @@ +randomFishResult(); + + // 2. 更新经验和金币 + $expMul = $this->vipService->getExpMultiplier($user); + $jjbMul = $this->vipService->getJjbMultiplier($user); + + if ($result['exp'] !== 0) { + $finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp']; + $this->currencyService->change( + $user, 'exp', $finalExp, CurrencySource::FISHING_GAIN, + "钓鱼收竿:{$result['message']}", $roomId, + ); + } + + if ($result['jjb'] !== 0) { + $finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb']; + $this->currencyService->change( + $user, 'gold', $finalJjb, CurrencySource::FISHING_GAIN, + "钓鱼收竿:{$result['message']}", $roomId, + ); + } + + $user->refresh(); + + // 3. 广播钓鱼结果到聊天室 + $promoTag = ''; + if (! $isAi) { + $autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user); + $promoTag = $autoFishingMinutesLeft > 0 + ? ' 🎣 自动钓鱼卡' + : ''; + } + + $sysMsg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '钓鱼播报', + 'to_user' => '大家', + 'content' => "{$result['emoji']} 【{$user->username}】{$result['message']}{$promoTag}", + 'is_secret' => false, + 'font_color' => $result['exp'] >= 0 ? '#16a34a' : '#dc2626', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + + $this->chatState->pushMessage($roomId, $sysMsg); + broadcast(new MessageSent($roomId, $sysMsg)); + // 发送完需持久化,不过 controller 里并未直接看到 SaveMessageJob, 但 AIheartbeat 里有。 + // 这里就先维持原样,只 broadcast 和 pushMessage + + return $result; + } + + /** + * 随机钓鱼结果(从数据库 fishing_events 加权随机抽取) + * + * 若数据库中无激活事件,回退到兜底结果。 + * + * @return array{emoji: string, message: string, exp: int, jjb: int} + */ + public function randomFishResult(): array + { + $event = FishingEvent::rollOne(); + + if (! $event) { + return [ + 'emoji' => '🐟', + 'message' => '钓到一条小鱼,获得金币10', + 'exp' => 0, + 'jjb' => 10, + ]; + } + + return [ + 'emoji' => $event->emoji, + 'message' => $event->message, + 'exp' => $event->exp, + 'jjb' => $event->jjb, + ]; + } +} diff --git a/resources/views/admin/ai-providers/index.blade.php b/resources/views/admin/ai-providers/index.blade.php index bc8e7d5..d3fd403 100644 --- a/resources/views/admin/ai-providers/index.blade.php +++ b/resources/views/admin/ai-providers/index.blade.php @@ -112,6 +112,22 @@ +
+ + +
+
+ + +