feat(ai): 将小班长升级为完全独立的实体用户并支持随机金币发放及持续在线刷级,设定为女兵人设并使用自定义头像

This commit is contained in:
2026-03-26 11:15:11 +08:00
parent c13bb5f35c
commit 4d60893dbe
16 changed files with 523 additions and 101 deletions
+199
View File
@@ -0,0 +1,199 @@
<?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\SaveMessageJob;
use App\Models\Autoact;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
class AiHeartbeatCommand extends Command
{
/**
* Artisan 指令名称
*/
protected $signature = 'chatroom:ai-heartbeat';
/**
* 指令描述
*/
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
}
public function handle(): int
{
// 1. 检查总开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
return Command::SUCCESS;
}
// 2. 获取 AI 实体
$user = User::where('username', 'AI小班长')->first();
if (! $user) {
return Command::SUCCESS;
}
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程,此处每分钟跑一次,发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
if ($expGain > 0) {
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain;
}
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
}
$user->save();
$user->refresh();
// 4. 重算等级(基础心跳升级)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$leveledUp = $this->calculateNewLevel($user, $superLevel);
// 5. 随机事件触发
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
// 执行随机事件的金钱经验惩奖
if ($autoEvent->exp_change !== 0) {
$this->currencyService->change(
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
}
if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change(
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
}
$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',
'大声宣告'
);
}
return Command::SUCCESS;
}
/**
* 计算并更新用户等级
*/
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;
}
/**
* 往所有活跃房间发送系统广播消息
*/
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);
}
}
}
+4
View File
@@ -44,6 +44,9 @@ enum CurrencySource: string
/** AI赠送福利(用户向AI祈求获得的随机奖励) */
case AI_GIFT = 'ai_gift';
/** 赠人玫瑰(用户或AI对外发放金币红包) */
case GIFT_SENT = 'gift_sent';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
@@ -145,6 +148,7 @@ enum CurrencySource: string
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
self::DIVORCE_CHARM => '离婚魅力惩罚',
self::RING_BUY => '购买戒指',
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChatBotToggled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(
public array $user,
public bool $isOnline
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('chat.system'),
];
}
}
@@ -46,8 +46,40 @@ class AiProviderController extends Controller
{
$providers = AiProviderConfig::orderBy('sort_order')->get();
$chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1';
$chatbotMaxGold = Sysparam::getValue('chatbot_max_gold', '5000');
$chatbotMaxDailyRewards = Sysparam::getValue('chatbot_max_daily_rewards', '1');
return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled'));
return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled', 'chatbotMaxGold', 'chatbotMaxDailyRewards'));
}
/**
* 保存全局设置
*/
public function updateSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'chatbot_max_gold' => 'required|integer|min:1',
'chatbot_max_daily_rewards' => 'required|integer|min:1',
]);
Sysparam::updateOrCreate(
['alias' => 'chatbot_max_gold'],
[
'body' => (string) $data['chatbot_max_gold'],
'guidetxt' => '单次最高发放金币金额',
]
);
Sysparam::clearCache('chatbot_max_gold');
Sysparam::updateOrCreate(
['alias' => 'chatbot_max_daily_rewards'],
[
'body' => (string) $data['chatbot_max_daily_rewards'],
'guidetxt' => '每个用户单日最多获得金币次数',
]
);
Sysparam::clearCache('chatbot_max_daily_rewards');
return back()->with('success', '全局设置保存成功!');
}
/**
@@ -192,11 +224,90 @@ class AiProviderController extends Controller
Sysparam::clearCache('chatbot_enabled');
$status = $newValue === '1' ? '开启' : '关闭';
$isEnabled = $newValue === '1';
// 确保 AI 实体账号存在
$user = \App\Models\User::firstOrCreate(
['username' => 'AI小班长'],
[
'password' => \Illuminate\Support\Facades\Hash::make(\Illuminate\Support\Str::random(16)),
'user_level' => 10,
'sex' => 0, // 女性
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
'jjb' => 1000000,
'sign' => '本群首席智慧小管家',
]
);
// 防止后期头像变动,强制更新到最新女生头像
if (! str_contains($user->usersf ?? '', 'ai_bot_cn_girl.png')) {
$user->update([
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
'sex' => 0,
]);
}
$userData = [
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
// 广播机器人进出事件(供前端名单增删)
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
// 像真实的玩家一样,对全网活跃房间进行高调进出场播报
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
if (empty($activeRoomIds)) {
$activeRoomIds = [1]; // 兜底
}
// 把 AI 实体挂名到一个主房间,即可被 app/Console/Commands/AutoSaveExp.php 扫描发经验
$mainRoomId = $activeRoomIds[0];
if ($isEnabled) {
$this->chatState->userJoin($mainRoomId, $user->username, $userData);
} else {
// 清理可能存在的所有房间的残留挂名
foreach ($activeRoomIds as $rId) {
$this->chatState->userLeave($rId, $user->username);
}
}
foreach ($activeRoomIds as $roomId) {
$content = $isEnabled
? '<span style="color: #9333ea; font-weight: bold;">🤖 【AI小班长】 迈着整齐的步伐进入了房间,随时为您服务!</span>'
: '<span style="color: #9ca3af; font-weight: bold;">🤖 【AI小班长】 去休息啦,大家聊得开心!</span>';
$botMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#9333ea',
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $botMsg);
broadcast(new \App\Events\MessageSent($roomId, $botMsg));
\App\Jobs\SaveMessageJob::dispatch($botMsg);
}
return response()->json([
'status' => 'success',
'message' => "聊天机器人已{$status}",
'enabled' => $newValue === '1',
'enabled' => $isEnabled,
]);
}
+55 -29
View File
@@ -62,6 +62,11 @@ class ChatBotController extends Controller
], 403);
}
$aiUser = \App\Models\User::where('username', 'AI小班长')->first();
if ($aiUser) {
$aiUser->increment('exp_num', 1);
}
$user = Auth::user();
$message = $request->input('message');
$roomId = $request->input('room_id');
@@ -92,40 +97,61 @@ class ChatBotController extends Controller
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
$reply = trim($reply);
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
// 原子操作,防止并发多次领取
if (Redis::setnx($redisKey, 1)) {
Redis::expire($redisKey, 86400); // 缓存 24 小时
$dailyCount = (int) Redis::get($redisKey);
// 给用户发放随机 100~5000 金币
$goldAmount = rand(100, 5000);
$this->currencyService->change(
$user,
'gold',
$goldAmount,
CurrencySource::AI_GIFT,
'AI小班长发善心赠送的金币福利',
$roomId
);
if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
// 发送全场大广播
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🤖 听闻小萌新哭穷,AI小班长看【{$user->username}】骨骼惊奇,大方地赏赐了 {$goldAmount} 枚金币福利!",
'is_secret' => false,
'font_color' => '#d97706', // 橙色醒目
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
if ($aiUser && $aiUser->jjb >= $goldAmount) {
Redis::incr($redisKey);
Redis::expire($redisKey, 86400); // 缓存 24 小时
// 真实扣除 AI 金币
$this->currencyService->change(
$aiUser,
'gold',
-$goldAmount,
CurrencySource::GIFT_SENT,
"赏赐给 {$user->username} 的金币福利",
$roomId
);
// 给用户发放金币
$this->currencyService->change(
$user,
'gold',
$goldAmount,
CurrencySource::AI_GIFT,
'AI小班长发善心赠送的金币福利',
$roomId
);
// 发送全场大广播
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => 'AI小班长',
'to_user' => $user->username,
'content' => "🤖 听闻小萌新哭穷,本班长看你骨骼惊奇,大方地赏赐了 {$goldAmount} 枚金币福利!",
'is_secret' => false,
'font_color' => '#d97706', // 橙色醒目
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
} else {
// 如果余额不足
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
}
} else {
// 如果已经领过了,修改回复提醒
$reply = $reply."\n\n(系统提示:你今天已经领过金币福利啦,每天只能领一次哦)";
$reply .= "\n\n(系统提示:你今天已经领过金币福利啦,把机会留给其他人吧)";
}
}
+3 -1
View File
@@ -89,7 +89,8 @@ class AiChatService
$guideRulesText = $this->getDynamicGuideRules();
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,所以大家也可以亲切地称呼你为"小熊班长"
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,大家也可以亲切地称呼你为"小熊班长"
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
你的工作是陪大家聊天,并在他们有疑问时热情、专业提供帮助,解答关于聊天室玩法的疑问。
【背景与基础】
@@ -109,6 +110,7 @@ $guideRulesText
2. 语气军旅、活泼友好且接地气,像老战友和耐心细致的客服班长。
3. 回复保持简洁(一般不超过 200 字),引导新兵熟悉各项功能。回答关于数值的问题时,请利用上面的手册提供的准确数据。
4. 鼓励适当使用表情符号(如 🫡🐻✨💰 等)来增加话题趣味性。
5. 【极其重要】你的回复将被系统自动加上前缀(例如:“AI小班长对流星老铁说:”)。因此,你的回复正文开头**绝对不要**再带上对方的名字作为称呼(例如:不要写“流星同志,这事包在班长身上”,直接写“这事包在班长身上”即可),否则会显得非常啰嗦重复!
PROMPT;
}