346 lines
12 KiB
PHP
346 lines
12 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:AI小班长专属极轻量心跳模拟器
|
||
*
|
||
* 专门用于让无法通过浏览器发送真实心跳的 AI实体用户
|
||
* 也能够完美触原有的法发经验/金币逻辑以及触发随机事件(Autoact)。
|
||
* 每分钟由 Laravel Scheduler 调用。
|
||
*
|
||
* @author ChatRoom Laravel
|
||
*
|
||
* @version 1.0.0
|
||
*/
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Enums\CurrencySource;
|
||
use App\Events\MessageSent;
|
||
use App\Jobs\AiFishingJob;
|
||
use App\Jobs\SaveMessageJob;
|
||
use App\Models\Autoact;
|
||
use App\Models\DailySignIn;
|
||
use App\Models\GameConfig;
|
||
use App\Models\Sysparam;
|
||
use App\Models\User;
|
||
use App\Services\AiFinanceService;
|
||
use App\Services\ChatStateService;
|
||
use App\Services\SignInService;
|
||
use App\Services\UserCurrencyService;
|
||
use App\Services\VipService;
|
||
use Illuminate\Console\Command;
|
||
|
||
/**
|
||
* 定时模拟 AI小班长心跳,并同步维护其常规存款理财行为。
|
||
*/
|
||
class AiHeartbeatCommand extends Command
|
||
{
|
||
/**
|
||
* Artisan 指令名称
|
||
*/
|
||
protected $signature = 'chatroom:ai-heartbeat';
|
||
|
||
/**
|
||
* 指令描述
|
||
*/
|
||
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
|
||
|
||
/**
|
||
* 注入聊天室状态、VIP、积分与 AI 资金调度服务。
|
||
*/
|
||
public function __construct(
|
||
private readonly ChatStateService $chatState,
|
||
private readonly VipService $vipService,
|
||
private readonly UserCurrencyService $currencyService,
|
||
private readonly AiFinanceService $aiFinance,
|
||
private readonly SignInService $signInService,
|
||
) {
|
||
parent::__construct();
|
||
}
|
||
|
||
/**
|
||
* 执行 AI小班长单次心跳,并处理奖励、随机事件与资金调度。
|
||
*/
|
||
public function handle(): int
|
||
{
|
||
$startedAt = microtime(true);
|
||
|
||
// 1. 检查总开关
|
||
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
|
||
return Command::SUCCESS;
|
||
}
|
||
|
||
$config = $this->heartbeatConfig();
|
||
|
||
// 2. 获取 AI 实体
|
||
$user = User::where('username', 'AI小班长')->first();
|
||
if (! $user) {
|
||
return Command::SUCCESS;
|
||
}
|
||
|
||
// 心跳开始前,若手上金币已高于 100 万,则先把超出的部分转入银行。
|
||
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||
$this->aiFinance->bankExcessGold($user);
|
||
}
|
||
|
||
// 2.5 自动每日签到(今日已签时 claim() 幂等返回,不重复发奖)
|
||
if ($this->performDailySignIn($user)) {
|
||
// 签到可能发放经验、金币或魅力,后续心跳计算必须基于最新余额。
|
||
$user->refresh();
|
||
}
|
||
|
||
// 3. 常规心跳经验与金币发放
|
||
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
|
||
$expGain = $this->parseRewardValue($config['exp_per_heartbeat']);
|
||
if ($expGain > 0) {
|
||
$expMultiplier = $this->vipService->getExpMultiplier($user);
|
||
$actualExpGain = (int) round($expGain * $expMultiplier);
|
||
$user->exp_num += $actualExpGain;
|
||
}
|
||
|
||
$jjbGain = $this->parseRewardValue($config['jjb_per_heartbeat']);
|
||
if ($jjbGain > 0) {
|
||
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
|
||
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
|
||
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
|
||
}
|
||
|
||
$user->save();
|
||
|
||
// 4. 重算等级(基础心跳升级)
|
||
$superLevel = (int) $config['superlevel'];
|
||
$leveledUp = $this->calculateNewLevel($user, $superLevel);
|
||
|
||
// 5. 随机事件触发
|
||
$eventChance = (int) $config['auto_event_chance'];
|
||
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
|
||
$autoEvent = Autoact::randomEvent();
|
||
if ($autoEvent) {
|
||
$hasCurrencyChange = false;
|
||
|
||
// 执行随机事件的金钱经验惩奖
|
||
if ($autoEvent->exp_change !== 0) {
|
||
$this->currencyService->change(
|
||
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||
);
|
||
$hasCurrencyChange = true;
|
||
}
|
||
if ($autoEvent->jjb_change !== 0) {
|
||
$this->currencyService->change(
|
||
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
|
||
);
|
||
$hasCurrencyChange = true;
|
||
}
|
||
|
||
if ($hasCurrencyChange) {
|
||
$user->refresh();
|
||
}
|
||
|
||
// 重新计算等级
|
||
if ($this->calculateNewLevel($user, $superLevel)) {
|
||
$leveledUp = true;
|
||
}
|
||
|
||
// 广播随机事件
|
||
$this->broadcastSystemMessage(
|
||
'星海小博士',
|
||
$autoEvent->renderText($user->username),
|
||
match ($autoEvent->event_type) {
|
||
'good' => '#16a34a',
|
||
'bad' => '#dc2626',
|
||
default => '#7c3aed',
|
||
}
|
||
);
|
||
}
|
||
}
|
||
|
||
// 6. 如果由于心跳或事件导致了升级,广播升级消息
|
||
if ($leveledUp) {
|
||
$this->broadcastSystemMessage(
|
||
'系统传音',
|
||
"🌟 天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}!",
|
||
'#d97706',
|
||
'大声宣告'
|
||
);
|
||
}
|
||
|
||
// 7. 钓鱼小游戏随机参与逻辑
|
||
$fishingEnabled = $config['chatbot_fishing_enabled'] === '1';
|
||
$fishingChance = (int) $config['chatbot_fishing_chance']; // 默认 100% 概率,保持原有配置默认值。
|
||
if ($fishingEnabled && $fishingChance > 0 && rand(1, 100) <= $fishingChance) {
|
||
$fishingConfig = GameConfig::forGame('fishing');
|
||
|
||
if ($fishingConfig?->enabled) {
|
||
$cost = (int) ($fishingConfig->params['fishing_cost'] ?? $config['fishing_cost']);
|
||
// 常规小游戏只使用当前手上金币,不再自动从银行补到 100 万。
|
||
if ($this->aiFinance->prepareSpend($user, $cost)) {
|
||
// 先扣除抛竿费用,再派发延迟收竿任务,避免当前心跳等待钓鱼结果。
|
||
$this->currencyService->change(
|
||
$user, 'gold', -$cost,
|
||
CurrencySource::FISHING_COST,
|
||
"AI小班长钓鱼抛竿消耗 {$cost} 金币",
|
||
1,
|
||
);
|
||
|
||
// 模拟玩家等待时间
|
||
$waitMin = (int) ($fishingConfig->params['fishing_wait_min'] ?? $config['fishing_wait_min']);
|
||
$waitMax = (int) ($fishingConfig->params['fishing_wait_max'] ?? $config['fishing_wait_max']);
|
||
$waitTime = rand($waitMin, $waitMax);
|
||
|
||
// 延迟派发收竿事件(AI目前统一将事件播报到房间 1,或者拿 active room ids)
|
||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||
$roomId = ! empty($activeRoomIds) ? $activeRoomIds[0] : 1;
|
||
|
||
AiFishingJob::dispatch($user, $roomId)->delay(now()->addSeconds($waitTime));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 心跳结束后,若新增金币让手上金额超过 100 万,则把超出的部分重新转回银行。
|
||
if ((int) ($user->jjb ?? 0) > AiFinanceService::AVAILABLE_GOLD_RESERVE) {
|
||
$this->aiFinance->bankExcessGold($user);
|
||
}
|
||
|
||
$elapsedMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||
$this->info("AI心跳完成,耗时 {$elapsedMs}ms。");
|
||
|
||
return Command::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* 读取本轮心跳需要的系统配置,避免命令流程中重复触发配置读取。
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
private function heartbeatConfig(): array
|
||
{
|
||
return [
|
||
'exp_per_heartbeat' => Sysparam::getValue('exp_per_heartbeat', '1'),
|
||
'jjb_per_heartbeat' => Sysparam::getValue('jjb_per_heartbeat', '0'),
|
||
'superlevel' => Sysparam::getValue('superlevel', '100'),
|
||
'auto_event_chance' => Sysparam::getValue('auto_event_chance', '10'),
|
||
'chatbot_fishing_enabled' => Sysparam::getValue('chatbot_fishing_enabled', '0'),
|
||
'chatbot_fishing_chance' => Sysparam::getValue('chatbot_fishing_chance', '100'),
|
||
'fishing_cost' => Sysparam::getValue('fishing_cost', '5'),
|
||
'fishing_wait_min' => Sysparam::getValue('fishing_wait_min', '8'),
|
||
'fishing_wait_max' => Sysparam::getValue('fishing_wait_max', '15'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 计算并更新用户等级
|
||
*/
|
||
private function calculateNewLevel(User $user, int $superLevel): bool
|
||
{
|
||
$oldLevel = $user->user_level;
|
||
|
||
if ($oldLevel >= $superLevel) {
|
||
return false; // 管理员不自动升降级
|
||
}
|
||
|
||
$newLevel = Sysparam::calculateLevel($user->exp_num);
|
||
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
|
||
$user->user_level = $newLevel;
|
||
$user->save();
|
||
|
||
return $newLevel > $oldLevel;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 解析配置的奖励范围,如 "1" 或 "1-5"
|
||
*/
|
||
private function parseRewardValue(string $raw): int
|
||
{
|
||
$raw = trim($raw);
|
||
if (str_contains($raw, '-')) {
|
||
[$min, $max] = explode('-', $raw, 2);
|
||
|
||
return rand((int) $min, (int) $max);
|
||
}
|
||
|
||
return (int) $raw;
|
||
}
|
||
|
||
/**
|
||
* 尝试为 AI小班长 执行今日签到,成功时广播签到通知。
|
||
*/
|
||
private function performDailySignIn(User $user): bool
|
||
{
|
||
// 先检查今日是否已签,避免每分钟都调用事务
|
||
$alreadySigned = DailySignIn::query()
|
||
->where('user_id', $user->id)
|
||
->whereDate('sign_in_date', today())
|
||
->exists();
|
||
|
||
if ($alreadySigned) {
|
||
return false;
|
||
}
|
||
|
||
// 获取活跃房间作为签到归属(默认房间 1)
|
||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||
$roomId = ! empty($activeRoomIds) ? (int) $activeRoomIds[0] : 1;
|
||
|
||
$dailySignIn = $this->signInService->claim($user, $roomId);
|
||
|
||
// 仅当本次心跳实际完成签到时才广播(幂等保护)
|
||
if (! $dailySignIn->wasRecentlyCreated) {
|
||
return false;
|
||
}
|
||
|
||
$rewardParts = [];
|
||
if ($dailySignIn->gold_reward > 0) {
|
||
$rewardParts[] = $dailySignIn->gold_reward.' 金币';
|
||
}
|
||
if ($dailySignIn->exp_reward > 0) {
|
||
$rewardParts[] = $dailySignIn->exp_reward.' 经验';
|
||
}
|
||
if ($dailySignIn->charm_reward > 0) {
|
||
$rewardParts[] = $dailySignIn->charm_reward.' 魅力';
|
||
}
|
||
$rewardText = $rewardParts === [] ? '签到记录' : implode(' + ', $rewardParts);
|
||
|
||
$identityText = $dailySignIn->identity_badge_name
|
||
? ',获得身份 '.e($dailySignIn->identity_badge_name)
|
||
: '';
|
||
|
||
$content = '【'.e($user->username).'】完成今日签到,连续签到 '
|
||
.$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。';
|
||
|
||
$this->broadcastSystemMessage('系统传音', $content, '#0f766e');
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 往所有活跃房间发送系统广播消息
|
||
*/
|
||
private function broadcastSystemMessage(string $fromUser, string $content, string $color, string $action = ''): void
|
||
{
|
||
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
|
||
if (empty($activeRoomIds)) {
|
||
$activeRoomIds = [1];
|
||
}
|
||
|
||
foreach ($activeRoomIds as $roomId) {
|
||
$sysMsg = [
|
||
'id' => $this->chatState->nextMessageId($roomId),
|
||
'room_id' => $roomId,
|
||
'from_user' => $fromUser,
|
||
'to_user' => '大家',
|
||
'content' => $content,
|
||
'is_secret' => false,
|
||
'font_color' => $color,
|
||
'action' => $action,
|
||
'sent_at' => now()->toDateTimeString(),
|
||
];
|
||
|
||
$this->chatState->pushMessage($roomId, $sysMsg);
|
||
broadcast(new MessageSent($roomId, $sysMsg));
|
||
SaveMessageJob::dispatch($sysMsg);
|
||
}
|
||
}
|
||
}
|