diff --git a/app/Jobs/AiBaccaratBetJob.php b/app/Jobs/AiBaccaratBetJob.php index 5dbfade..14261e8 100644 --- a/app/Jobs/AiBaccaratBetJob.php +++ b/app/Jobs/AiBaccaratBetJob.php @@ -76,17 +76,7 @@ class AiBaccaratBetJob implements ShouldQueue 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. 获取近期路单(最多取 20 局) + // 4. 获取近期路单和 AI 历史下注 $recentResults = BaccaratRound::query() ->where('status', 'settled') ->orderByDesc('id') @@ -94,14 +84,56 @@ class AiBaccaratBetJob implements ShouldQueue ->pluck('result') ->toArray(); - // 5. 优先调用 AI 接口预测下注方向 - $predictionService = app(BaccaratPredictionService::class); - $aiPrediction = $predictionService->predict($recentResults); - $decisionSource = 'ai'; - $betType = $aiPrediction; + $historicalBets = BaccaratBet::query() + ->join('baccarat_rounds', 'baccarat_bets.round_id', '=', 'baccarat_rounds.id') + ->where('baccarat_bets.user_id', $user->id) + ->where('baccarat_rounds.status', 'settled') + ->orderByDesc('baccarat_rounds.id') + ->limit(10) + ->select('baccarat_bets.*') + ->get() + ->map(function ($bet) { + return [ + 'round_id' => $bet->round_id, + 'bet_type' => $bet->bet_type, + 'amount' => $bet->amount, + 'profit' => $bet->profit ?? 0, + ]; + }) + ->toArray(); - // AI 不可用时回退本地路单决策(保底逻辑) - if ($betType === null) { + // 5. 调用 AI 接口出具统筹策略 + $predictionService = app(BaccaratPredictionService::class); + $context = [ + 'recent_results' => $recentResults, + 'available_gold' => $availableGold, + 'historical_bets' => $historicalBets, + ]; + + $aiPrediction = $predictionService->predict($context); + $decisionSource = 'ai'; + + $betType = 'pass'; + $amount = 0; + $reason = ''; + + if ($aiPrediction) { + $betType = $aiPrediction['action']; + $percent = $aiPrediction['percentage']; + $reason = $aiPrediction['reason']; + + // 限定单局最高下注不超过可用金额的 5% 以防止 AI "乱梭哈" 破产 + $percent = min(5, max(0, $percent)); + + if ($betType !== 'pass') { + $amount = (int) round($availableGold * ($percent / 100.0)); + $amount = max($minBet, min($amount, $maxBet)); + if ($amount > $user->jjb) { + $amount = $user->jjb; + } + } + } else { + // AI 不可用时回退本地路单决策(保底逻辑) $decisionSource = 'local'; $bigCount = count(array_filter($recentResults, fn (string $r) => $r === 'big')); $smallCount = count(array_filter($recentResults, fn (string $r) => $r === 'small')); @@ -110,27 +142,39 @@ class AiBaccaratBetJob implements ShouldQueue 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'; + $betType = rand(0, 10) === 0 ? 'pass' : (rand(0, 1) ? 'big' : 'small'); + } + + if ($betType !== 'pass') { + $percent = rand(2, 5) / 100.0; + $amount = (int) round($availableGold * $percent); + $amount = max($minBet, min($amount, $maxBet)); } } // 记录 AI 小班长本次决策日志 - $labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子']; + $labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子', 'pass' => '观望']; Log::channel('daily')->info('AI小班长百家乐决策', [ 'round_id' => $round->id, - 'decision_source' => $decisionSource === 'ai' ? 'AI接口预测' : '本地路单兜底', - 'ai_prediction' => $aiPrediction ? $labelMap[$aiPrediction] : 'null(不可用)', - 'final_bet' => $labelMap[$betType] ?? $betType, + 'decision_source' => $decisionSource === 'ai' ? 'AI接口策略' : '本地路单兜底', + 'action' => $labelMap[$betType] ?? $betType, 'bet_amount' => $amount, + 'reason' => $reason, 'roadmap_20' => implode('→', array_map(fn (string $r) => $labelMap[$r] ?? $r, array_reverse($recentResults))), ]); - // 5. 执行下注 (同 BaccaratController::bet 逻辑) + if ($betType === 'pass') { + // 观望时仅广播,不执行真正的金币扣除下注逻辑 + $this->broadcastPassMessage($user, $round->room_id ?? 1, $reason); + + return; + } + + // 6. 执行下注 (同 BaccaratController::bet 逻辑) DB::transaction(function () use ($user, $round, $betType, $amount, $currency) { // 幂等:同一局只能下一注 $existing = BaccaratBet::query() @@ -178,6 +222,39 @@ class AiBaccaratBetJob implements ShouldQueue $this->broadcastBetMessage($user, $round->room_id ?? 1, $betType, $amount, $decisionSource); } + /** + * 广播 AI小班长的观望文案到聊天室 + * + * @param User $user AI小班长用户 + * @param int $roomId 聊天室 ID + * @param string $reason 观望理由 + */ + private function broadcastPassMessage(User $user, int $roomId, string $reason): void + { + if (empty($reason)) { + $reason = '风大雨大,保本最大,这把我决定观望一下!'; + } + + $chatState = app(ChatStateService::class); + $content = "🌟 🎲 【百家乐】 {$user->username} 本局选择挂机观望:✨
[🤖 策略分析] {$reason}"; + + $msg = [ + 'id' => $chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => $content, + 'is_secret' => false, + 'font_color' => '#d97706', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + + $chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + } + /** * 广播 AI小班长本次下注情况到聊天室 * diff --git a/app/Services/BaccaratPredictionService.php b/app/Services/BaccaratPredictionService.php index 2c37f88..f4b5377 100644 --- a/app/Services/BaccaratPredictionService.php +++ b/app/Services/BaccaratPredictionService.php @@ -43,16 +43,16 @@ class BaccaratPredictionService private ?int $aiUserId = null; /** - * 调用 AI 接口预测百家乐下注方向 + * 调用 AI 接口出具下注策略并预测百家乐方向 * * 优先使用 PREFERRED_MODEL 对应的厂商,调用失败时自动按 * sort_order 依次尝试其余已启用厂商(与 AiChatService 故障转移逻辑一致)。 * 所有厂商均失败时返回 null,由调用方回退本地路单决策。 * - * @param array $recentResults 近期已结算路单('big'|'small'|'triple',从最新到最旧) - * @return string|null 预测结果:'big'|'small'|'triple',或 null(AI 全部不可用) + * @param array $context 包含路单、资金以及历史下注记录的上下文数组 + * @return array|null 策略:['action' => '...', 'percentage' => 1-100, 'reason' => '...'] 或 null (不可用) */ - public function predict(array $recentResults): ?string + public function predict(array $context): ?array { // 获取所有已启用厂商,PREFERRED_MODEL 对应的排在最前面 $providers = AiProviderConfig::getEnabledProviders(); @@ -71,7 +71,7 @@ class BaccaratPredictionService ->values(); // 重置索引,消除 IDE 类型推断警告 } - $prompt = $this->buildPredictionPrompt($recentResults); + $prompt = $this->buildPredictionPrompt($context); // 依次尝试每个厂商,失败则切换下一个 foreach ($providers as $provider) { @@ -102,14 +102,18 @@ class BaccaratPredictionService } /** - * 构建发送给 AI 的预测提示词 + * 构建发送给 AI 的预测策略提示词 * - * 将路单数据转为人类可读格式,要求 AI 仅回复三种固定关键词之一。 + * 将路单数据转为人类可读格式,要求 AI 必须以特定 JSON 输出策略。 * - * @param array $recentResults 路单数组 + * @param array $context 上下文数据 */ - private function buildPredictionPrompt(array $recentResults): string + private function buildPredictionPrompt(array $context): string { + $recentResults = $context['recent_results'] ?? []; + $availableGold = $context['available_gold'] ?? 0; + $history = $context['historical_bets'] ?? []; + // 将英文 key 转为中文展示,方便 AI 理解 $labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子']; @@ -122,20 +126,43 @@ class BaccaratPredictionService $roadmapText = implode(' → ', array_reverse($roadmap)); $total = count($recentResults); + $historyText = ''; + if (empty($history)) { + $historyText = '暂无下注历史记录。'; + } else { + foreach ($history as $bet) { + $betAction = $labelMap[$bet['bet_type']] ?? $bet['bet_type']; + $profitText = $bet['profit'] > 0 ? "赢 +{$bet['profit']}" : "负 {$bet['profit']}"; + $historyText .= "- 第 {$bet['round_id']} 局:押 {$betAction} {$bet['amount']} 金币,结果:{$profitText}\n"; + } + } + return <<getDecryptedApiKey(); @@ -164,12 +191,13 @@ PROMPT; ->timeout(self::REQUEST_TIMEOUT) ->post($endpoint, [ 'model' => $config->model, - 'temperature' => 0.3, // 预测任务偏确定性,使用较低温度 - 'max_tokens' => 50, // 非推理模型只需输出单词,50 足够;推理模型请调高此值 + 'temperature' => 0.6, // 预测任务需要生成推理和灵活评估,适当提高温度 + 'max_tokens' => 300, // 输出 JSON 包含 reason,加大 token 限制 + 'response_format' => ['type' => 'json_object'], // 强制平台尝试规范 JSON 输出(对某些模型自动生效) 'messages' => [ [ 'role' => 'system', - 'content' => '你是百家乐路单分析专家,只输出"大"、"小"或"豹子"三个词之一,不输出任何其他内容。', + 'content' => '你是百家乐资金策略专家,严格遵守只返回合格格式的 JSON 字符串。', ], [ 'role' => 'user', @@ -213,24 +241,41 @@ PROMPT; } /** - * 将 AI 回复的中文词解析为系统内部下注键 + * 将 AI 回复的纯文本解析为内部的结构化策略数组 * - * @param string $reply AI 返回的原始文本 - * @return string 'big'|'small'|'triple' + * @param string $reply AI 返回的原始文本 (可能带 Markdown 或推理过程) + * @return array ['action', 'percentage', 'reason'] * - * @throws \Exception 无法识别时抛出 + * @throws \Exception 无法识别或格式错误时抛出 */ - private function parseReply(string $reply): string + private function parseReply(string $reply): array { - // 从回复中提取核心词(容忍多余空白/标点) - $cleaned = trim(preg_replace('/\s+/', '', $reply)); + // 尝试剔除 Markdown JSON 块标签 + $jsonStr = preg_replace('/```json\s*(.*?)\s*```/s', '$1', $reply); + $jsonStr = preg_replace('/```\s*(.*?)\s*```/s', '$1', $jsonStr); + $jsonStr = trim($jsonStr); - return match ($cleaned) { - '大' => 'big', - '小' => 'small', - '豹子' => 'triple', - default => throw new \Exception("AI 返回无法识别的预测结果:{$reply}"), - }; + // 如果 AI 有不可预测的开头结尾文字,可以尝试用正则仅抓取首尾的大括号 + if (preg_match('/\{.*\}/s', $jsonStr, $matches)) { + $jsonStr = $matches[0]; + } + + $decoded = json_decode($jsonStr, true); + + if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) { + throw new \Exception("AI 返回的 JSON 格式非法无法解析:{$reply}"); + } + + $action = $decoded['action'] ?? null; + if (! in_array($action, ['big', 'small', 'triple', 'pass'], true)) { + throw new \Exception("AI 返回了不合法的 action 指令:{$action}"); + } + + return [ + 'action' => $action, + 'percentage' => max(0, min(10, (int) ($decoded['percentage'] ?? 0))), + 'reason' => $decoded['reason'] ?? '局势迷离,默默操作', + ]; } /**