feat: 实现 AI 钓鱼与百家乐游戏的参与逻辑,并支持后台面板配置开关

This commit is contained in:
2026-03-26 11:49:36 +08:00
parent 532dc20a2d
commit a68e82107e
9 changed files with 422 additions and 78 deletions

View File

@@ -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;
}

View File

@@ -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', '全局设置保存成功!');
}

View File

@@ -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
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: '';
$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,
];
}
}

View File

@@ -0,0 +1,150 @@
<?php
/**
* 文件功能AI小班长自动参与百家乐下注
*
* 在每局百家乐开启时延迟调度执行:
* 1. 检查是否存在连输休息惩罚1小时
* 2. 检查可用金币,确保留存底金
* 3. 获取近期路单进行简单决策
* 4. 提交下注
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Enums\CurrencySource;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class AiBaccaratBetJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly BaccaratRound $round,
) {}
public function handle(UserCurrencyService $currency): void
{
// 1. 检查总开关与游戏开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1' || ! GameConfig::isEnabled('baccarat')) {
return;
}
$round = $this->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');
});
}
}

45
app/Jobs/AiFishingJob.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* 文件功能AI小班长钓鱼动作异步任务
*
* 模拟 AI 抛竿等待一定时间后收竿的过程。
* 收竿时自动调用 FishingService 结算结果并播报。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Models\User;
use App\Services\FishingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class AiFishingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @param User $aiUser AI小班长
* @param int $roomId 此事件广播的房间ID
*/
public function __construct(
public readonly User $aiUser,
public readonly int $roomId,
) {}
/**
* 任务执行
*/
public function handle(FishingService $fishingService): void
{
// 通过业务服务计算钓鱼结果并广播,标记 isAi 为 true
$fishingService->processCatch($this->aiUser, $this->roomId, true);
}
}

View File

@@ -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');
}
}
/**
* 向公屏发送开奖结果系统消息(含各用户输赢情况)。
*

View File

@@ -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);
}

View File

@@ -0,0 +1,122 @@
<?php
/**
* 文件功能:钓鱼核心业务逻辑
*
* 提取自 FishingController供玩家请求和 AI 挂机脚本复用。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\FishingEvent;
use App\Models\User;
class FishingService
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
) {}
/**
* 处理收竿逻辑:计算结果、发放积分并全服广播。
*
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
*/
public function processCatch(User $user, int $roomId, bool $isAi = false): array
{
// 1. 随机决定钓鱼结果
$result = $this->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
? ' <span onclick="window.openShopModal&&window.openShopModal()" '
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:10px;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: '';
}
$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,
];
}
}

View File

@@ -112,6 +112,22 @@
<span class="absolute right-3 top-2.5 text-gray-400 text-sm"></span>
</div>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">AI 参与钓鱼游戏</label>
<select name="chatbot_fishing_enabled"
class="w-40 border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="1" {{ $chatbotFishingEnabled ? 'selected' : '' }}> 开启</option>
<option value="0" {{ !$chatbotFishingEnabled ? 'selected' : '' }}> 关闭</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">AI 参与百家乐</label>
<select name="chatbot_baccarat_enabled"
class="w-40 border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="1" {{ $chatbotBaccaratEnabled ? 'selected' : '' }}> 开启</option>
<option value="0" {{ !$chatbotBaccaratEnabled ? 'selected' : '' }}> 关闭</option>
</select>
</div>
<button type="submit"
class="px-6 py-2 bg-slate-800 text-white rounded-md font-bold hover:bg-slate-900 text-sm transition h-[38px]">
保存运行参数