54 Commits

Author SHA1 Message Date
pllx 45ce8b2b2d 调整聊天输入栏工具字号 2026-04-30 09:07:10 +08:00
pllx 50b050c4bc 修复聊天室字号偏好和游戏通知显示 2026-04-29 18:27:32 +08:00
pllx 6748fbc44e 修复自动钓鱼状态与播报屏蔽范围 2026-04-29 16:54:18 +08:00
pllx 449894e3e5 修复赛马编号通知卡片识别 2026-04-29 16:07:26 +08:00
pllx 5173275a92 恢复游戏通知原始正文样式 2026-04-29 15:58:41 +08:00
pllx ee56792beb 统一猜谜答对通知配色 2026-04-29 15:50:58 +08:00
pllx 02ed8ea319 精简AI百家乐下注通知文案 2026-04-29 15:48:33 +08:00
pllx 2bebc78e82 补充游戏开奖通知编号文案 2026-04-29 15:45:07 +08:00
pllx 4fe4155ec0 修复钓鱼通知与游戏配置保存问题 2026-04-29 15:23:32 +08:00
pllx c640a31302 完善游戏通知文案与屏蔽逻辑 2026-04-29 15:06:01 +08:00
pllx 6ae452c4b9 补充猜谜活动旧Seeder兼容入口 2026-04-29 14:44:57 +08:00
pllx 1607f57e3c 支持所有游戏按房间范围配置和运行 2026-04-29 14:37:28 +08:00
pllx 3672140987 统一聊天室游戏通知胶囊样式 2026-04-29 14:35:52 +08:00
pllx 092b51cd95 拆分猜谜活动后台配置视图 2026-04-29 13:39:41 +08:00
pllx fe3e74b5f8 重构猜谜活动并统一聊天室答题通知 2026-04-29 13:35:20 +08:00
pllx 192259f0a4 调整聊天室名单排序搜索同排布局 2026-04-29 12:01:03 +08:00
pllx a50055deaf 修复赛马线上卡在跑马中状态 2026-04-29 11:42:49 +08:00
pllx 578f587941 拆分猜成语独立屏蔽通知类型 2026-04-29 11:35:14 +08:00
pllx fb4a7171f4 修复猜成语出题消息线上不显示问题 2026-04-29 11:25:57 +08:00
pllx dc9c09c722 修复 Reverb 配置缓存时的函数重定义 2026-04-29 11:18:08 +08:00
pllx 317dfd04d7 修复聊天室在线名单初始化与 Reverb 来源校验 2026-04-29 11:15:24 +08:00
pllx 1192fe5bdb 优化升级 2026-04-29 10:59:35 +08:00
pllx e0679b164e 修复升级bug 2026-04-29 10:52:44 +08:00
pllx 82d762d070 完善部署脚本的 Reverb 重启流程 2026-04-29 10:39:07 +08:00
pllx 5962d6d2b3 完善猜成语过期与答题记录逻辑 2026-04-29 10:32:12 +08:00
pllx 2f9b2eed64 修复拍一拍消息重复显示的问题,按发送者/被拍者路由到包厢,其他用户路由到公屏 2026-04-29 09:40:40 +08:00
pllx 434f2b8e0f fix(idiom): 后台出题按钮无效,管理后台布局缺少 @stack('scripts') 2026-04-29 00:14:41 +08:00
pllx 9bc085cb7d refactor(idiom): 将游戏配置和题库移到 IdiomSeeder,迁移只建表 2026-04-29 00:10:01 +08:00
pllx f13cfe4bc1 feat(idiom): 答对提示的用户名可点击打开用户名片 2026-04-28 23:58:04 +08:00
pllx cd1621f497 feat(idiom): disable answer button after question is solved 2026-04-28 23:53:05 +08:00
pllx 3973b7770c fix(idiom): split display - winner sees in private, others in public 2026-04-28 23:51:16 +08:00
pllx 0847877ce2 feat(idiom): global toast + chat message on answer result 2026-04-28 23:49:27 +08:00
pllx b886d98d8c feat(idiom): add scheduled auto-start task (everyMinute) 2026-04-28 23:48:32 +08:00
pllx 4ff62e29bd feat: 猜成语游戏 - 完整题库、管理后台、答题弹窗
- 创建 idioms 表(102条谜语式成语题库)和 idiom_game_rounds 表
- 后台成语管理页面:增删改题目 + 游戏参数(金币/经验/间隔)内联设置 + 出题按钮
- IdiomQuizController:出题/答题/当前回合查询,Redis 防并发抢答
- IdiomGameStarted / IdiomGameAnswered 广播事件
- 前端答题弹窗模块:聊天消息带【答题】按钮,点击弹出输入框
- GameConfig 注册 idiom 游戏,由 admin.game-configs 统一管理开关
2026-04-28 23:42:48 +08:00
pllx 461c6a6f56 fix: 弹窗点击遮罩层(外部区域)即可关闭 2026-04-28 23:18:26 +08:00
pllx 1850a5f4e9 fix: 选择斜杠命令后自动清除输入框中的 /
- 统一在 selectCommand 公共入口清理输入
- 新增 /签到 命令,自动完成今日签到
2026-04-28 23:12:30 +08:00
pllx 0850004d39 feat: 斜杠命令新增 /签到,自动完成今日签到 2026-04-28 23:10:00 +08:00
pllx df96b56ab0 feat: 斜杠命令新增 /查看资料,直接打开用户名片 2026-04-28 23:01:40 +08:00
pllx 495efdf9e0 feat: 新增 /拍一拍 功能 + 斜杠命令菜单
- 输入框输入 / 弹出命令菜单,当前支持 /拍一拍
- 选择对象后输入 /拍一拍 发送拍一拍通知
- 所有在线用户屏幕抖动 + 正常聊天样式显示消息
- 命令注册表可扩展,后续新增命令只需 push 到数组
2026-04-28 22:59:16 +08:00
pllx 0dd85879af 增加51统计 2026-04-28 14:49:33 +08:00
pllx 64434516d7 视频赚钱按钮改为提示已关闭 2026-04-28 14:45:37 +08:00
pllx e155a0e3d0 恢复视频赚钱路由(只保留控制器关闭逻辑) 2026-04-28 14:43:25 +08:00
pllx d6e8a64ce3 停用看视频赚钱功能(注释路由+控制器返回关闭) 2026-04-28 14:42:41 +08:00
pllx abb5512222 feat: 欢迎语增加右下角弹窗通知;禁止对「大家」发送欢迎 2026-04-28 14:40:27 +08:00
pllx 55fd770fdd 取消看视频赚钱 2026-04-28 14:32:39 +08:00
pllx 4fb78eaca9 feat: 功能菜单增加留言、反馈快捷入口 2026-04-28 14:28:52 +08:00
pllx 05ec4a72b7 feat: 公屏公告同时弹右下角通知(所有人可见) 2026-04-28 14:14:48 +08:00
pllx f3d883b5ed fix: 刷新后屏蔽项虽恢复但已有消息未隐藏 2026-04-28 14:08:15 +08:00
pllx 96e0e21f8b fix: 屏蔽列表刷新后不恢复(缺少从服务端读取的分支逻辑) 2026-04-28 14:04:07 +08:00
pllx c8adbff78e 安全优化 2026-04-28 13:57:15 +08:00
pllx aa6046d89b fix: 弹窗消息改用真正的换行符而非字面量 \n 2026-04-28 13:23:26 +08:00
pllx cdec289740 fix: 对话框 textContent 改为 innerText 支持换行;后端允许装扮批量购买 2026-04-28 13:16:24 +08:00
pllx d63aeef45b feat: 装扮购买增加数量输入弹窗(参照补签卡样式) 2026-04-28 13:12:56 +08:00
pllx 2be7e6caef 优化:去掉装饰品数量弹窗,改为确认对话框中提示叠加天数说明 2026-04-28 13:10:15 +08:00
96 changed files with 7242 additions and 423 deletions
+4
View File
@@ -158,6 +158,9 @@ enum CurrencySource: string
/** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/** 猜谜活动奖励 */
case GAME_REWARD = 'game_reward';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -210,6 +213,7 @@ enum CurrencySource: string
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜谜活动奖励',
};
}
}
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐押注人数变化。
*/
class BaccaratPoolUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_count_big' => $this->round->bet_count_big,
'bet_count_small' => $this->round->bet_count_small,
'bet_count_triple' => $this->round->bet_count_triple,
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐开局事件。
*/
class BaccaratRoundOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐结算结果。
*/
class BaccaratRoundSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points,
'result' => $this->round->result,
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播赛马开局事件。
*/
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -58,6 +61,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
{
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'horses' => $this->race->horses,
'total_pool' => $this->race->total_pool,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
+6 -1
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间持续广播赛马进度。
*/
class HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function __construct(
public readonly int $raceId,
public readonly int $roomId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->roomId)];
}
/**
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
{
return [
'race_id' => $this->raceId,
'room_id' => $this->roomId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
+2 -1
View File
@@ -46,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -94,6 +94,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => (int) $this->race->total_pool,
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:猜谜活动答题结果广播事件
*
* 用户答对题目时广播,通知房间内所有用户结果。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播猜谜活动答题结果。
*/
class RiddleGameAnswered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 方法功能:构造答题成功广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly int $roundId,
public readonly string $quizType,
public readonly string $answer,
public readonly string $winnerUsername,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 方法功能:声明广播频道。
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 方法功能:声明广播数据。
*/
public function broadcastWith(): array
{
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
return [
'round_id' => $this->roundId,
'quiz_type' => $this->quizType,
'quiz_type_label' => $quizTypeLabel,
'quiz_round_id' => $this->roundId,
'quiz_answer' => $this->answer,
'quiz_reward_gold' => $this->rewardGold,
'quiz_reward_exp' => $this->rewardExp,
'quiz_round_ended_id' => $this->roundId,
'answer' => $this->answer,
'winner_username' => $this->winnerUsername,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:猜谜活动开始广播事件
*
* 管理员手动出题或系统自动出题时触发,广播提示到聊天室。
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播新一轮猜谜活动题目。
*/
class RiddleGameStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 方法功能:构造新回合广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly string $quizType,
public readonly string $hint,
public readonly int $roundId,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 方法功能:声明广播频道。
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 方法功能:声明广播数据。
*/
public function broadcastWith(): array
{
$quizTypeLabel = \App\Models\Riddle::labelForType($this->quizType);
return [
'round_id' => $this->roundId,
'quiz_type' => $this->quizType,
'quiz_type_label' => $quizTypeLabel,
'quiz_round_id' => $this->roundId,
'quiz_hint' => $this->hint,
'quiz_reward_gold' => $this->rewardGold,
'quiz_reward_exp' => $this->rewardExp,
'hint' => $this->hint,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
'message' => "📣 【猜谜活动·{$quizTypeLabel}】第 #{$this->roundId} 题开始!题面:{$this->hint}",
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:用户"拍一拍"广播事件
*
* 用户输入 /拍一拍 用户名 后触发,通过 WebSocket 广播给房间内所有用户,
* 前端显示 "XXX拍了拍XXX" 消息并触发屏幕抖动动画。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserPat implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* 构造函数
*
* @param int $roomId 房间 ID
* @param string $fromUser 拍人的用户
* @param string $targetUser 被拍的用户
* @param string $displayText 前端展示文本,如 "流星 拍了拍 张三"
*/
public function __construct(
public readonly int $roomId,
public readonly string $fromUser,
public readonly string $targetUser,
public readonly string $displayText,
public readonly ?string $fromUserHeadface = null,
) {}
/**
* 广播频道:向房间内所有在线用户推送
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'from_user' => $this->fromUser,
'target_user' => $this->targetUser,
'display_text' => $this->displayText,
'from_user_headface' => $this->fromUserHeadface,
];
}
}
@@ -14,14 +14,20 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateGameConfigParamsRequest;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\Room;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
*/
class GameConfigController extends Controller
{
/**
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
public function index(): View
{
$games = GameConfig::orderBy('id')->get();
$availableRooms = Room::query()->orderBy('id')->get();
return view('admin.game-configs.index', compact('games'));
return view('admin.game-configs.index', compact('games', 'availableRooms'));
}
/**
@@ -56,15 +63,19 @@ class GameConfigController extends Controller
*
* 接收前端提交的 params JSON 对象并合并至现有配置。
*/
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse
public function updateParams(UpdateGameConfigParamsRequest $request, GameConfig $gameConfig, GameRoomScopeService $roomScopeService): RedirectResponse
{
$request->validate([
'params' => 'required|array',
]);
// 合并参数,保留已有键,只更新传入的键
$current = $gameConfig->params ?? [];
$updated = array_merge($current, $request->input('params'));
// 这里不能只读取 validated('params')。
// 当前请求类只对公共房间字段做了显式规则约束,像 fishing_cooldown 这类普通游戏参数
// 在 validated 数据中会被裁掉,导致后台提示成功但实际没有写入数据库。
$validatedParams = (array) $request->input('params', []);
$updated = array_merge($current, $validatedParams);
$scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams);
$updated['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$updated['room_ids'] = $scopeConfig['room_ids'];
if ($gameConfig->game_key === 'mystery_box') {
$legacyMap = [
@@ -107,17 +118,19 @@ class GameConfigController extends Controller
}
// 检查是否有正在开放的箱子(避免同时多个)
if (\App\Models\MysteryBox::currentOpenBox()) {
$targetRoomId = app(GameRoomScopeService::class)->getPrimaryRoomIdForGame('mystery_box');
if (\App\Models\MysteryBox::currentOpenBox($targetRoomId)) {
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
}
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $targetRoomId, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([
'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #{$targetRoomId} 房间,暗号将实时发送到公屏!",
]);
}
@@ -126,19 +139,31 @@ class GameConfigController extends Controller
*
* 仅在当前无进行中期次时生效,防止重复开期。
*/
public function openLotteryIssue(): JsonResponse
public function openLotteryIssue(GameRoomScopeService $roomScopeService): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
}
if (LotteryIssue::currentIssue()) {
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
$openedRoomIds = [];
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
if (LotteryIssue::currentIssue($roomId)) {
continue;
}
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
$openedRoomIds[] = $roomId;
}
\App\Jobs\OpenLotteryIssueJob::dispatch();
if ($openedRoomIds === []) {
return response()->json(['ok' => false, 'message' => '目标房间当前已有进行中的期次,无需重复开期。']);
}
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']);
return response()->json([
'ok' => true,
'message' => '✅ 已排队开期任务,目标房间:#'.implode('、#', $openedRoomIds),
]);
}
/**
@@ -0,0 +1,168 @@
<?php
/**
* 文件功能:猜谜活动题库后台管理控制器
*
* 负责后台题库的列表筛选、题目增删改和启用状态切换。
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Riddle;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 类功能:统一处理猜谜活动题库的后台管理动作。
*/
class RiddleController extends Controller
{
/**
* 方法功能:显示题库列表,并支持按题型和关键词筛选。
*/
public function index(Request $request): View
{
$typeOptions = Riddle::typeOptions();
$selectedType = trim((string) $request->query('type', ''));
$keyword = trim((string) $request->query('keyword', ''));
$idiomQuery = Riddle::query();
if ($selectedType !== '' && isset($typeOptions[$selectedType])) {
// 题型筛选只接受系统支持值,避免非法参数污染查询。
$idiomQuery->ofType($selectedType);
}
if ($keyword !== '') {
// 关键词同时匹配答案与提示,方便后台快速定位题目。
$idiomQuery->where(function ($query) use ($keyword): void {
$query->where('answer', 'like', '%'.$keyword.'%')
->orWhere('hint', 'like', '%'.$keyword.'%');
});
}
$idioms = $idiomQuery
->orderBy('type')
->orderBy('sort')
->orderBy('id')
->get();
$typeStats = Riddle::query()
->selectRaw('type, COUNT(*) as total')
->groupBy('type')
->pluck('total', 'type')
->all();
return view('admin.riddles.index', [
'idioms' => $idioms,
'typeOptions' => $typeOptions,
'selectedType' => $selectedType,
'keyword' => $keyword,
'typeStats' => $typeStats,
]);
}
/**
* 方法功能:创建新的猜谜活动题目。
*/
public function store(Request $request): RedirectResponse
{
$data = $this->validateRiddlePayload($request);
// 新增时默认启用,便于后台批量补题后立即可用。
$data['is_active'] = $request->boolean('is_active', true);
Riddle::create($data);
$typeLabel = Riddle::labelForType($data['type']);
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "{$typeLabel}题目已添加!");
}
/**
* 方法功能:更新已有题目内容与题型。
*/
public function update(Request $request, Riddle $idiom): RedirectResponse
{
$data = $this->validateRiddlePayload($request);
// 编辑时显式按复选框结果落库,避免旧状态残留。
$data['is_active'] = $request->boolean('is_active');
$idiom->update($data);
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "题目「{$idiom->answer}」已更新!");
}
/**
* 方法功能:通过 AJAX 切换题目的启用状态。
*/
public function toggle(Riddle $idiom): JsonResponse
{
// 开关按钮只变更启用状态,不改动其他题库字段。
$idiom->update(['is_active' => ! $idiom->is_active]);
return response()->json([
'ok' => true,
'is_active' => $idiom->is_active,
'message' => $idiom->is_active ? "{$idiom->answer}」已启用" : "{$idiom->answer}」已禁用",
]);
}
/**
* 方法功能:删除指定题目。
*/
public function destroy(Request $request, Riddle $idiom): RedirectResponse
{
$answer = $idiom->answer;
$idiom->delete();
return redirect()
->route('admin.riddles.index', $this->buildIndexFilters($request))
->with('success', "题目「{$answer}」已删除!");
}
/**
* 方法功能:校验后台题库保存载荷。
*
* @return array{type:string,answer:string,hint:string,sort:int}
*/
private function validateRiddlePayload(Request $request): array
{
return $request->validate([
'type' => ['required', 'string', Rule::in(Riddle::supportedTypes())],
'answer' => ['required', 'string', 'max:120'],
'hint' => ['required', 'string', 'max:255'],
'sort' => ['required', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);
}
/**
* 方法功能:保留列表筛选参数,方便后台操作后返回原筛选结果。
*
* @return array<string, string>
*/
private function buildIndexFilters(Request $request): array
{
$filters = [];
$type = trim((string) $request->input('redirect_type', $request->query('type', '')));
$keyword = trim((string) $request->input('redirect_keyword', $request->query('keyword', '')));
if ($type !== '') {
$filters['type'] = $type;
}
if ($keyword !== '') {
$filters['keyword'] = $keyword;
}
return $filters;
}
}
@@ -524,6 +524,13 @@ class AdminCommandController extends Controller
'font_color' => '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
'toast_notification' => [
'title' => '📢 公屏公告',
'message' => strip_tags($content),
'icon' => '📢',
'color' => '#b91c1c',
'duration' => 10000,
],
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
+20 -2
View File
@@ -20,16 +20,21 @@ use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供百家乐当前局查询、下注与历史接口。
*/
class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -38,7 +43,13 @@ class BaccaratController extends Controller
public function currentRound(Request $request): JsonResponse
{
$user = $request->user();
$round = BaccaratRound::currentRound();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['round' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$round = BaccaratRound::currentRound($roomId);
if (! $round) {
return response()->json([
@@ -98,6 +109,11 @@ class BaccaratController extends Controller
'bet_type' => 'required|in:big,small,triple',
'amount' => 'required|integer|min:1',
]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启百家乐。'], 403);
}
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -109,7 +125,7 @@ class BaccaratController extends Controller
$round = BaccaratRound::find($data['round_id']);
if (! $round || ! $round->isBettingOpen()) {
if (! $round || (int) $round->room_id !== $roomId || ! $round->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
@@ -212,7 +228,9 @@ class BaccaratController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$rounds = BaccaratRound::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
+81
View File
@@ -449,6 +449,17 @@ class ChatController extends Controller
$messageData = array_merge($messageData, $imagePayload);
}
// 欢迎动作:增加右下角弹窗通知(内容含发送者信息)
if (($data['action'] ?? '') === '欢迎') {
$messageData['toast_notification'] = [
'title' => '👋 欢迎',
'message' => strip_tags($pureContent),
'icon' => '👋',
'color' => '#e11d48',
'duration' => 8000,
];
}
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
$messageData = array_merge($messageData, $decorations);
@@ -1404,6 +1415,76 @@ class ChatController extends Controller
return null;
}
/**
* 拍一拍:用户通过 /拍一拍 命令向所选对象发送拍一拍通知。
*/
public function pat(Request $request, int $id): JsonResponse
{
$user = Auth::user();
if ($response = $this->ensureUserCanActInRoom($id, $user, '请先进入当前房间后再使用拍一拍。')) {
return $response;
}
// 0. 检查用户是否被禁言
$muteKey = "mute:{$id}:{$user->username}";
if (Redis::exists($muteKey)) {
$ttl = Redis::ttl($muteKey);
$minutes = ceil($ttl / 60);
return response()->json([
'status' => 'error',
'message' => "您正在禁言中,还需等待约 {$minutes} 分钟。",
], 403);
}
$targetUser = $request->input('target_user', '');
if (empty($targetUser) || $targetUser === '大家') {
return response()->json([
'status' => 'error',
'message' => '请选择一个聊天对象(不能为大家)进行拍一拍。',
], 422);
}
// 检查目标是否在线
$isOnline = Redis::hexists("room:{$id}:users", $targetUser);
if (! $isOnline) {
return response()->json([
'status' => 'error',
'message' => "{$targetUser}】目前已离开聊天室或不在线。",
], 200);
}
// 不能拍自己
if ($targetUser === $user->username) {
return response()->json([
'status' => 'error',
'message' => '不能拍自己哦~',
], 422);
}
// 获取发送者头像
$headface = $user->usersf ?: '1.gif';
$headSrc = str_starts_with($headface, 'storage/') ? '/'.$headface : '/images/headface/'.$headface;
// 构造展示文本
$displayText = "{$user->username} 拍了拍 {$targetUser}";
// 广播到房间
broadcast(new \App\Events\UserPat(
roomId: $id,
fromUser: $user->username,
targetUser: $targetUser,
displayText: $displayText,
fromUserHeadface: $headSrc,
));
return response()->json([
'status' => 'success',
'message' => $displayText,
]);
}
/**
* 校验目标用户是否仍在当前房间在线,避免跨房间赠送和消息注入。
*/
@@ -185,9 +185,10 @@ class DailySignInController extends Controller
.',当前连续签到 '.$streakDays.' 天,获得 '.$rewardText.$identityText.'。';
}
// 聊天消息内的快捷按钮使用相对字号,避免覆盖用户选择的消息字号。
$quickButton = '<button type="button" onclick="window.quickDailySignIn && window.quickDailySignIn()" '
.'style="display:inline-block;margin-left:6px;padding:1px 8px;border:none;border-radius:999px;'
.'background:#ccfbf1;color:#0f766e;font-size:10px;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'background:#ccfbf1;color:#0f766e;font-size:0.78em;font-weight:bold;cursor:pointer;vertical-align:middle;">'
.'✅ 快速签到</button>';
return '【'.e($user->username).'】完成今日签到,连续签到 '
+7 -1
View File
@@ -43,6 +43,11 @@ class EarnController extends Controller
*/
public function claimVideoReward(Request $request): JsonResponse
{
return response()->json([
'success' => false,
'message' => '看视频赚钱功能已关闭。',
]);
/** @var User $user */
$user = Auth::user();
@@ -99,9 +104,10 @@ class EarnController extends Controller
// 6. 广播全服系统消息
if ($roomId > 0) {
// 公屏消息内的入口标签使用相对字号,跟随用户在聊天室选择的字号。
$promoTag = ' <span onclick="window.dispatchEvent(new CustomEvent(\'open-earn-panel\'))" '
.'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;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击赚金币">💰 看视频赚金币</span>';
$sysMsg = [
+93 -17
View File
@@ -19,8 +19,10 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\FishingService;
use App\Services\GameRoomScopeService;
use App\Services\ShopService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
@@ -30,14 +32,21 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
/**
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
*/
class FishingController extends Controller
{
/**
* 注入钓鱼流程需要的状态、会员、金币、商店和房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
private readonly FishingService $fishingService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -63,6 +72,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 检查冷却时间(Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
@@ -75,6 +88,14 @@ class FishingController extends Controller
], 429);
}
$tokenKey = "fishing:token:{$user->id}";
if (Redis::exists($tokenKey)) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
}
// 2. 检查金币是否足够
$cost = (int) (GameConfig::param('fishing', 'fishing_cost') ?? Sysparam::getValue('fishing_cost', '5'));
if (($user->jjb ?? 0) < $cost) {
@@ -84,34 +105,54 @@ class FishingController extends Controller
], 422);
}
// 3. 扣除金币
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
// 4. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
// 3. 生成一次性 token,存入 Redis(TTL = 等待时间 + 收竿窗口 + 缓冲)
$waitMin = (int) (GameConfig::param('fishing', 'fishing_wait_min') ?? Sysparam::getValue('fishing_wait_min', '8'));
$waitMax = (int) (GameConfig::param('fishing', 'fishing_wait_max') ?? Sysparam::getValue('fishing_wait_max', '15'));
$waitTime = rand($waitMin, $waitMax);
$token = Str::random(32);
$tokenKey = "fishing:token:{$user->id}";
// token 有效期 = 等待时间 + 8秒点击窗口 + 5秒缓冲
// 同时把 cast 时间戳和 wait_time 一起存入,供 reel 做服务端时间校验
Redis::setex($tokenKey, $waitTime + 13, json_encode([
$tokenTtl = $waitTime + 13;
$tokenPayload = json_encode([
'token' => $token,
'cast_at' => time(),
'wait_time' => $waitTime,
]));
]);
// 5. 生成随机浮漂坐标(百分比,避开边缘)
// 原子占用本次抛竿 token,避免多标签页自动钓鱼互相覆盖令牌。
$reserved = Redis::command('set', [$tokenKey, $tokenPayload, 'EX', $tokenTtl, 'NX']);
if (! $reserved) {
$activeSessionResponse = $this->restoreActiveFishingSessionResponse($user, $tokenKey);
if ($activeSessionResponse) {
return $activeSessionResponse;
}
return response()->json([
'status' => 'error',
'message' => '钓鱼状态同步中,请稍后重试。',
'retry_after' => 3,
], 409);
}
try {
// token 占用成功后才扣金币,确保重复抛竿不会多扣费用。
$this->currencyService->change(
$user, 'gold', -$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh();
} catch (\Throwable $exception) {
// 金币扣除失败时释放 token,避免用户被短时间卡在未收竿状态。
Redis::del($tokenKey);
throw $exception;
}
// 4. 生成随机浮漂坐标(百分比,避开边缘)
$bobberX = rand(15, 85); // 左右 15%~85%
$bobberY = rand(20, 65); // 上下 20%~65%
// 6. 检查是否持有有效自动钓鱼卡
// 5. 检查是否持有有效自动钓鱼卡
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
@@ -128,6 +169,37 @@ class FishingController extends Controller
]);
}
/**
* 恢复已有钓鱼会话,避免刷新页面后丢失前端内存里的收竿令牌。
*/
private function restoreActiveFishingSessionResponse(User $user, string $tokenKey): ?JsonResponse
{
$stored = json_decode((string) Redis::get($tokenKey), true);
if (! is_array($stored) || empty($stored['token'])) {
Redis::del($tokenKey);
return null;
}
$elapsed = time() - (int) ($stored['cast_at'] ?? 0);
$waitTime = max(0, (int) ($stored['wait_time'] ?? 0) - $elapsed);
$autoFishingMinutes = $this->shopService->getActiveAutoFishingMinutesLeft($user);
return response()->json([
'status' => 'success',
'message' => '已恢复正在进行的钓鱼,请等待本次收竿。',
'wait_time' => $waitTime,
'bobber_x' => rand(15, 85),
'bobber_y' => rand(20, 65),
'token' => (string) $stored['token'],
'auto_fishing' => $autoFishingMinutes > 0,
'auto_fishing_minutes_left' => $autoFishingMinutes,
'cost' => 0,
'jjb' => $user->jjb,
'restored' => true,
]);
}
/**
* 收竿 验证浮漂 token,随机计算钓鱼结果,更新经验/金币,广播到聊天室。
*
@@ -142,6 +214,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey);
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:提供神秘占卜状态、抽签和历史接口。
*/
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
return response()->json(['enabled' => false]);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['enabled' => false]);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘占卜。'], 403);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
*/
public function history(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['history' => []]);
}
$logs = FortuneLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -24,17 +24,22 @@ use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent;
use App\Models\GameConfig;
use App\Models\GomokuGame;
use App\Services\GameRoomScopeService;
use App\Services\GomokuAiService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理五子棋创建、加入与对局过程接口。
*/
class GomokuController extends Controller
{
public function __construct(
private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -58,6 +63,10 @@ class GomokuController extends Controller
$user = $request->user();
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
}
// PvP:检查是否已在等待/对局中(一次只能参与一场)
$activeGame = GomokuGame::query()
->where(function ($q) use ($user) {
+139 -6
View File
@@ -23,15 +23,23 @@ use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:赛马竞猜前台控制器
*
* 负责聊天室赛马玩法的当前场次查询、下注提交、历史记录读取,
* 并在发现线上遗留的超时 running 场次时执行最小范围的状态自愈。
*/
class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -44,7 +52,12 @@ class HorseRaceController extends Controller
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
$race = HorseRace::currentRace();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['race' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$race = $this->resolveCurrentRaceState(HorseRace::currentRace($roomId));
if (! $race) {
return response()->json([
@@ -139,6 +152,11 @@ class HorseRaceController extends Controller
'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1',
]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启赛马竞猜。'], 403);
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -150,7 +168,7 @@ class HorseRaceController extends Controller
$race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) {
if (! $race || (int) $race->room_id !== $roomId || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
@@ -207,8 +225,8 @@ class HorseRaceController extends Controller
$formattedAmount = number_format($data['amount']);
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -217,8 +235,8 @@ class HorseRaceController extends Controller
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
event(new MessageSent(1, $msg));
$chatState->pushMessage((int) $race->room_id, $msg);
event(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
@@ -235,7 +253,9 @@ class HorseRaceController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$races = HorseRace::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
@@ -264,6 +284,119 @@ class HorseRaceController extends Controller
return response()->json(['history' => $history]);
}
/**
* 自愈当前场次状态,避免线上遗漏结算时长期卡在 running。
*/
private function resolveCurrentRaceState(?HorseRace $race): ?HorseRace
{
if (! $race || $race->status !== 'running') {
return $race;
}
if (! $this->shouldRecoverStaleRunningRace($race)) {
return $race;
}
$race = $this->prepareRunningRaceForSettlement($race);
if (! $race || $race->status !== 'running' || ! $race->winner_horse_id) {
return $race;
}
// 线上若漏消费 CloseHorseRaceJob,这里同步补做一次结算,避免界面一直显示“跑马中”。
app()->call([new \App\Jobs\CloseHorseRaceJob($race), 'handle']);
return HorseRace::currentRace((int) $race->room_id);
}
/**
* 判断 running 场次是否已经超过合理比赛时长,需要请求侧补偿收尾。
*/
private function shouldRecoverStaleRunningRace(HorseRace $race): bool
{
if (! $race->race_starts_at) {
return false;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$raceDuration = max(1, (int) ($config['race_duration'] ?? 30));
$recoveryGraceSeconds = 5;
return $race->race_starts_at->lte(now()->subSeconds($raceDuration + $recoveryGraceSeconds));
}
/**
* 为超时 running 场次补齐缺失赛果字段,确保后续结算任务可以安全执行。
*/
private function prepareRunningRaceForSettlement(HorseRace $race): ?HorseRace
{
if ($race->winner_horse_id && $race->race_ends_at) {
return $race->fresh();
}
$horses = $this->normalizeRaceHorses($race->horses);
$winnerHorseId = $race->winner_horse_id ?: $this->resolveStaleRunningWinnerId($race, $horses);
if (! $winnerHorseId) {
return $race;
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$seedPool = (int) ($config['seed_pool'] ?? 0);
// 线上补偿场景下以当前下注快照补齐统计,确保本次请求内的结算口径与正常流程一致。
$totalBets = HorseBet::query()->where('race_id', $race->id)->count();
$totalPool = $seedPool + (int) HorseBet::query()->where('race_id', $race->id)->sum('amount');
HorseRace::query()
->where('id', $race->id)
->where('status', 'running')
->update([
'winner_horse_id' => $winnerHorseId,
'race_ends_at' => $race->race_ends_at ?? now(),
'total_bets' => $totalBets,
'total_pool' => $totalPool,
]);
return $race->fresh();
}
/**
* 为异常滞留的 running 场次推导一个稳定冠军,避免多次请求得到不同结算结果。
*
* @param array<int, array{id:int,name:string,emoji:string}> $horses
*/
private function resolveStaleRunningWinnerId(HorseRace $race, array $horses): ?int
{
if ($horses === []) {
return null;
}
$horsePools = HorseBet::query()
->where('race_id', $race->id)
->groupBy('horse_id')
->selectRaw('horse_id, SUM(amount) as pool')
->pluck('pool', 'horse_id')
->map(fn ($pool) => (int) $pool)
->toArray();
$candidateIds = array_map(
fn (array $horse): int => (int) $horse['id'],
$horses,
);
usort($candidateIds, function (int $leftId, int $rightId) use ($horsePools): int {
$leftPool = (int) ($horsePools[$leftId] ?? 0);
$rightPool = (int) ($horsePools[$rightId] ?? 0);
if ($leftPool === $rightPool) {
return $leftId <=> $rightId;
}
return $rightPool <=> $leftPool;
});
return $candidateIds[0] ?? null;
}
/**
* 兼容旧赛马数据结构,统一清洗为前端可消费的马匹数组。
*
+20 -2
View File
@@ -19,27 +19,38 @@ namespace App\Http\Controllers;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:提供双色球当前期、购票和历史记录接口。
*/
class LotteryController extends Controller
{
public function __construct(
private readonly LotteryService $lottery,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/
public function current(): JsonResponse
public function current(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['enabled' => false]);
}
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue();
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['enabled' => false, 'message' => '当前房间未开启双色球彩票。'], 403);
}
$issue = LotteryIssue::currentIssue($roomId) ?? LotteryIssue::latestIssue($roomId);
if (! $issue) {
return response()->json(['enabled' => true, 'issue' => null]);
@@ -90,6 +101,11 @@ class LotteryController extends Controller
*/
public function buy(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启双色球彩票。'], 403);
}
$request->validate([
'numbers' => 'required|array|min:1',
'numbers.*.reds' => 'required|array|size:3',
@@ -132,7 +148,9 @@ class LotteryController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
$issues = LotteryIssue::query()
->where('room_id', $roomId)
->where('status', 'settled')
->latest()
->limit(20)
+25 -11
View File
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
*/
class MysteryBoxController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/
public function status(): JsonResponse
public function status(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox();
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox($roomId);
if (! $box) {
return response()->json(['active' => false]);
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
}
$user = $request->user();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
return DB::transaction(function () use ($user, $passcode): JsonResponse {
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘箱子。'], 403);
}
return DB::transaction(function () use ($user, $passcode, $roomId): JsonResponse {
// 查找匹配暗号的可领取箱子(加锁防并发)
$box = MysteryBox::query()
->where('room_id', $roomId)
->where('passcode', $passcode)
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【神秘箱子】开箱播报:恭喜{$username} 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$content = "{$emoji}{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【神秘箱子】《黑化陷阱》haha{$username} 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$content = "☠️ {$username}踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
@@ -0,0 +1,267 @@
<?php
/**
* 文件功能:猜谜活动控制器
*
* 负责兼容现有 idiom-quiz 路由,同时支持猜成语与脑筋急转弯
* 两类题型的开题、答题与当前回合查询。
*/
namespace App\Http\Controllers;
use App\Events\RiddleGameAnswered;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Services\RiddleGameService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:处理猜谜活动开题、答题和当前回合读取。
*/
class RiddleQuizController extends Controller
{
/**
* 方法功能:注入猜谜活动所需的服务。
*/
public function __construct(
private readonly RiddleGameService $riddleGameService,
private readonly UserCurrencyService $currencyService,
) {}
/**
* 方法功能:管理员手动为指定房间与题型发起一轮猜谜活动。
*/
public function start(Request $request): JsonResponse
{
$user = Auth::user();
// 仅站长或具备后台职务的管理用户可手动开题。
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
}
$roomId = (int) $request->input('room_id', 0);
// 兼容后台新字段 quiz_type 与旧字段 type,两边都允许触发手动出题。
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
// 猜谜活动总开关关闭时,直接返回明确提示,避免误报成“题库为空”。
if (! GameConfig::isEnabled(Riddle::TYPE_IDIOM)) {
return response()->json([
'status' => 'error',
'message' => '猜谜活动未开启,请先到游戏管理中开启后再出题。',
], 400);
}
// 后台手动出题允许覆盖当前同题型回合,避免管理员还要先人工结束上一题。
$this->riddleGameService->endActiveRoundsForRoom($roomId, $quizType);
$round = $this->riddleGameService->startRound($roomId, $quizType);
if (! $round) {
if (! $this->riddleGameService->pickRandomQuestion($quizType)) {
return response()->json(['status' => 'error', 'message' => '当前题型题库中没有可用题目,请先在后台添加。'], 400);
}
return response()->json(['status' => 'error', 'message' => '当前题型暂时无法出题,请检查游戏配置与参与房间设置。'], 400);
}
return response()->json([
'status' => 'success',
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'quiz_hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:提交当前猜谜活动回合的答案。
*/
public function answer(Request $request): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$roundId = (int) $request->input('round_id');
$roomId = (int) $request->input('room_id');
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
$userAnswer = trim((string) $request->input('answer', ''));
if ($roundId <= 0 || $roomId <= 0 || $userAnswer === '') {
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
}
$round = RiddleGameRound::with('idiom')->find($roundId);
if (! $round || $round->room_id !== $roomId || $round->quiz_type !== $quizType) {
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
}
// 判题前先做超时结算,避免用户继续抢答无效回合。
if ($this->riddleGameService->expireRound($round)) {
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
}
if ($round->status !== 'active') {
if ($round->status === 'answered') {
return response()->json([
'status' => 'error',
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
], 400);
}
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
}
// 答案对比忽略空格与大小写,减少正常输入误判。
$normalizedAnswer = str_replace(' ', '', $userAnswer);
$normalizedCorrect = str_replace(' ', '', (string) $round->idiom?->answer);
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
return response()->json([
'status' => 'error',
'message' => '答案不正确,再想想!',
]);
}
$lockKey = "riddle:answer_lock:{$roundId}";
if (! Redis::setnx($lockKey, 1)) {
return response()->json([
'status' => 'error',
'message' => "这道{$this->riddleGameService->getQuizTypeLabel($round->quiz_type)}已被「{$round->winner_username}」抢先答对了!",
], 400);
}
Redis::expire($lockKey, 10);
// 抢答成功后立即封盘,确保并发请求读到统一状态。
$round->update([
'status' => 'answered',
'winner_id' => $user->id,
'winner_username' => $user->username,
'ended_at' => now(),
]);
if ($round->reward_gold > 0) {
$this->currencyService->change(
$user,
'gold',
$round->reward_gold,
\App\Enums\CurrencySource::GAME_REWARD,
$this->riddleGameService->buildRewardDescription($round),
$roomId,
);
}
if ($round->reward_exp > 0) {
// 经验奖励仍沿用现有字段,避免引入额外奖励服务改动。
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
$user->save();
}
broadcast(new RiddleGameAnswered(
roomId: $roomId,
roundId: $round->id,
quizType: $round->quiz_type,
answer: (string) $round->idiom?->answer,
winnerUsername: $user->username,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
$quizTypeLabel = $this->riddleGameService->getQuizTypeLabel($round->quiz_type);
$resultMsg = [
'id' => app(\App\Services\ChatStateService::class)->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🎉 【猜谜活动·{$quizTypeLabel}{$user->username} 率先答对「{$round->idiom?->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => 'idiom_result',
'winner_username' => $user->username,
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel,
'quiz_answer' => (string) $round->idiom?->answer,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
'quiz_round_id' => $round->id,
'quiz_round_ended_id' => $round->id,
'idiom_answer' => (string) $round->idiom?->answer,
'idiom_result_reward_gold' => $round->reward_gold,
'idiom_result_reward_exp' => $round->reward_exp,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
app(\App\Services\ChatStateService::class)->pushMessage($roomId, $resultMsg);
Redis::del($lockKey);
return response()->json([
'status' => 'success',
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $quizTypeLabel,
'answer' => (string) $round->idiom?->answer,
'quiz_answer' => (string) $round->idiom?->answer,
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:查询当前房间指定题型的进行中回合。
*/
public function current(Request $request): JsonResponse
{
$roomId = (int) $request->input('room_id', 0);
$quizType = $this->riddleGameService->normalizeQuizType($request->input('quiz_type', $request->input('type', Riddle::TYPE_IDIOM)));
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
$round = $this->riddleGameService->findActiveRound($roomId, $quizType);
if (! $round) {
return response()->json(['status' => 'success', 'data' => null]);
}
if ($this->riddleGameService->expireRound($round)) {
return response()->json(['status' => 'success', 'data' => null]);
}
return response()->json([
'status' => 'success',
'data' => [
'quiz_type' => $round->quiz_type,
'quiz_type_label' => $this->riddleGameService->getQuizTypeLabel($round->quiz_type),
'round_id' => $round->id,
'quiz_round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'quiz_hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
],
]);
}
}
+33 -13
View File
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\SlotMachineLog;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供老虎机信息查询、转动和个人记录接口。
*/
class SlotMachineController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
return response()->json(['enabled' => false]);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['enabled' => false]);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$user = $request->user();
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启老虎机。'], 403);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$cost = (int) ($config['cost_per_spin'] ?? 100);
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -100,7 +115,7 @@ class SlotMachineController extends Controller
}
}
return DB::transaction(function () use ($user, $cost, $config): JsonResponse {
return DB::transaction(function () use ($user, $cost, $config, $roomId): JsonResponse {
// ① 扣费
$this->currency->change(
$user,
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
if ($resultType === 'jackpot') {
// 三个7:全服公屏广播
$this->broadcastJackpot($user->username, $payout, $cost);
$this->broadcastJackpot($user->username, $payout, $cost, $roomId);
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
// 普通中奖:仅向本人发送聊天室系统通知
$net = $payout - $cost;
$content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
} elseif ($resultType === 'curse') {
// 诅咒:通知本人
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
}
$user->refresh();
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
*/
public function history(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['history' => []]);
}
$logs = SlotMachineLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
/**
* 三个7全服公屏广播。
*/
private function broadcastJackpot(string $username, int $payout, int $cost): void
private function broadcastJackpot(string $username, int $payout, int $cost, int $roomId): void
{
$net = $payout - $cost;
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
* @param string $toUsername 接收用户名
* @param string $content 消息内容
*/
private function broadcastPersonal(string $toUsername, string $content): void
private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => $toUsername,
'content' => $content,
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
broadcast(new MessageSent(1, $msg));
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
+20 -2
View File
@@ -303,19 +303,37 @@ class UserController extends Controller
}
/**
* 保存聊天室屏蔽禁音偏好。
* 保存聊天室屏蔽禁音与字号偏好。
*/
public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse
{
$user = Auth::user();
$data = $request->validated();
$existingPreferences = is_array($user->chat_preferences) ? $user->chat_preferences : [];
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
->map(function (string $sender): string {
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
return $sender === '猜谜活动' ? '猜成语' : $sender;
})
->unique()
->values()
->all();
$preferences = [
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])),
'blocked_system_senders' => $blockedSystemSenders,
'sound_muted' => (bool) $data['sound_muted'],
];
// 字号偏好和屏蔽/禁音共用账号配置,旧请求未携带字号时保留原值。
$fontSize = array_key_exists('font_size', $data) && $data['font_size'] !== null
? (int) $data['font_size']
: ($existingPreferences['font_size'] ?? null);
if ($fontSize !== null) {
$preferences['font_size'] = (int) $fontSize;
}
$user->update([
'chat_preferences' => $preferences,
]);
@@ -2,7 +2,7 @@
/**
* 文件功能:聊天室偏好设置验证器
* 负责校验用户提交的屏蔽播报禁音配置。
* 负责校验用户提交的屏蔽播报禁音与聊天室字号配置。
*/
namespace App\Http\Requests;
@@ -12,7 +12,7 @@ use Illuminate\Validation\Rule;
/**
* 聊天室偏好设置验证器
* 仅允许提交白名单内的屏蔽项布尔型禁音状态。
* 仅允许提交白名单内的屏蔽项布尔型禁音状态与合法字号
*/
class UpdateChatPreferencesRequest extends FormRequest
{
@@ -35,9 +35,10 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders' => ['nullable', 'array'],
'blocked_system_senders.*' => [
'string',
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子', '五子棋', '老虎机', '双色球彩票']),
],
'sound_muted' => ['required', 'boolean'],
'font_size' => ['nullable', 'integer', 'min:10', 'max:30'],
];
}
@@ -53,6 +54,9 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders.*.in' => '存在不支持的屏蔽项目。',
'sound_muted.required' => '请传入禁音状态。',
'sound_muted.boolean' => '禁音状态格式无效。',
'font_size.integer' => '聊天室字号格式无效。',
'font_size.min' => '聊天室字号不能小于 10。',
'font_size.max' => '聊天室字号不能大于 30。',
];
}
}
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:保存游戏参数请求校验
*
* 统一校验后台“游戏管理”页提交的 params 结构,
* 并在所有游戏共用的房间范围字段上执行归一化。
*/
namespace App\Http\Requests;
use App\Services\GameRoomScopeService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:约束后台游戏参数保存请求的公共结构。
*/
class UpdateGameConfigParamsRequest extends FormRequest
{
/**
* 判断当前请求是否允许执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'params' => ['required', 'array'],
'params.room_scope_mode' => ['nullable', 'in:all,single,multiple'],
'params.room_ids' => ['nullable', 'array'],
'params.room_ids.*' => ['integer', 'exists:rooms,id'],
];
}
/**
* 自定义错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'params.required' => '缺少游戏参数数据。',
'params.array' => '游戏参数格式无效。',
'params.room_scope_mode.in' => '参与房间模式无效。',
'params.room_ids.array' => '参与房间列表格式无效。',
'params.room_ids.*.integer' => '参与房间编号格式无效。',
'params.room_ids.*.exists' => '所选房间不存在,请刷新页面后重试。',
];
}
/**
* 在校验前先把房间范围字段归一化,兼容单值与旧字段。
*/
protected function prepareForValidation(): void
{
$params = (array) $this->input('params', []);
$roomScopeService = app(GameRoomScopeService::class);
$scopeConfig = $roomScopeService->getScopeConfigForParams($params);
$params['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$params['room_ids'] = $scopeConfig['room_ids'];
$this->merge([
'params' => $params,
]);
}
/**
* 校验通过后补充“单选/多选至少选择一个房间”的约束。
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$params = (array) $this->input('params', []);
$roomMode = (string) ($params['room_scope_mode'] ?? GameRoomScopeService::MODE_SINGLE);
$roomIds = (array) ($params['room_ids'] ?? []);
if (in_array($roomMode, [GameRoomScopeService::MODE_SINGLE, GameRoomScopeService::MODE_MULTIPLE], true) && $roomIds === []) {
$validator->errors()->add('params.room_ids', '单选/多选房间模式下,请至少选择一个房间。');
}
if ($roomMode === GameRoomScopeService::MODE_SINGLE && count($roomIds) > 1) {
$validator->errors()->add('params.room_ids', '单选房间模式下只能选择一个房间。');
}
});
}
}
+3 -8
View File
@@ -290,12 +290,8 @@ class AiBaccaratBetJob implements ShouldQueue
*/
private function broadcastPassMessage(User $user, int $roomId, string $reason): void
{
if (empty($reason)) {
$reason = '风大雨大,保本最大,这把我决定观望一下!';
}
$chatState = app(ChatStateService::class);
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 本局选择挂机观望:✨ <br/><span style='color:#666;'>[🤖 策略分析] {$reason}</span>";
$content = "🎲 【百家乐】 {$user->username} 本局选择挂机观望";
$msg = [
'id' => $chatState->nextMessageId($roomId),
@@ -335,9 +331,8 @@ class AiBaccaratBetJob implements ShouldQueue
$chatState = app(ChatStateService::class);
$labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子'];
$label = $labelMap[$betType] ?? $betType;
$sourceText = $decisionSource === 'ai' ? '🤖 经过深度算法预测,本局我看好:' : '📊 观察了下最近的路单,这把我觉得是:';
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount)." 金币)<br/><span style='color:#666;'>{$sourceText} {$label}</span>";
// AI 下注播报统一压成单行,避免游戏通知卡片出现多行正文挤占高度。
$content = "🌟 🎲 【百家乐】 <b>{$user->username}</b> 已下注:<span style='color:#1d4ed8;font-weight:bold;'>{$label}</span> (".number_format($amount).' 金币)';
$msg = [
'id' => $chatState->nextMessageId($roomId),
+9 -6
View File
@@ -28,6 +28,9 @@ use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:完成一局百家乐的开奖、派奖与通知。
*/
class CloseBaccaratRoundJob implements ShouldQueue
{
use Queueable;
@@ -227,7 +230,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
return;
}
$roomId = 1;
$roomId = (int) $round->room_id;
$roundResultLabel = $round->resultLabel();
foreach ($participantSettlements as $settlement) {
@@ -309,11 +312,11 @@ class CloseBaccaratRoundJob implements ShouldQueue
$detail = $detailParts ? ' '.implode(' ', $detailParts) : '';
$content = "🎲 【百家乐】第 #{$round->id} 局开奖{$diceStr} 总点 {$round->total_points} {$resultText}{$payoutText}{$detail}";
$content = "🎲 第 #{$round->id} 局开奖{$diceStr} {$round->total_points} 点,{$resultText}{$payoutText}{$detail}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $round->room_id),
'room_id' => (int) $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -322,8 +325,8 @@ class CloseBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $round->room_id, $msg);
broadcast(new MessageSent((int) $round->room_id, $msg));
SaveMessageJob::dispatch($msg);
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
+9 -6
View File
@@ -26,6 +26,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
/**
* 类功能:完成一场赛马竞猜的派奖与结果广播。
*/
class CloseHorseRaceJob implements ShouldQueue
{
use Queueable;
@@ -181,7 +184,7 @@ class CloseHorseRaceJob implements ShouldQueue
return;
}
$roomId = 1;
$roomId = (int) $race->room_id;
$winnerName = $this->resolveWinnerHorseName($race);
foreach ($participantSettlements as $settlement) {
@@ -243,11 +246,11 @@ class CloseHorseRaceJob implements ShouldQueue
? '共派发 💰'.number_format($totalPayout).' 金币'
: '本场无人获奖';
$content = "🏆 【赛马】第 #{$race->id} 场结束冠军:{$winnerName}{$payoutText}";
$content = "🏆 第 #{$race->id} 场结束冠军:{$winnerName}{$payoutText}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -256,8 +259,8 @@ class CloseHorseRaceJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $race->room_id, $msg);
broadcast(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
+5 -2
View File
@@ -23,6 +23,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
/**
* 类功能:按房间投放神秘箱子并广播暗号。
*/
class DropMysteryBoxJob implements ShouldQueue
{
use Queueable;
@@ -80,6 +83,7 @@ class DropMysteryBoxJob implements ShouldQueue
// 创建箱子记录
$box = MysteryBox::create([
'room_id' => $targetRoom,
'box_type' => $this->boxType,
'passcode' => $passcode,
'reward_min' => $rewardMin,
@@ -94,8 +98,7 @@ class DropMysteryBoxJob implements ShouldQueue
$typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}【神秘箱子】{$typeName}{$source}投放了一个神秘箱子!"
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$content = "{$emoji} {$typeName}{$source}投放,暗号「{$passcode}」,限时 {$claimWindow} 秒。";
$msg = [
'id' => $chatState->nextMessageId($targetRoom),
+7 -4
View File
@@ -18,6 +18,9 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:关闭已超时的神秘箱子并广播过期提醒。
*/
class ExpireMysteryBoxJob implements ShouldQueue
{
use Queueable;
@@ -49,8 +52,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
// 公屏广播过期通知
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
@@ -60,8 +63,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一局新的百家乐押注回合。
*/
class OpenBaccaratRoundJob implements ShouldQueue
{
use Queueable;
/**
* 构造开局任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -44,7 +56,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
// 防止重复开局(如果上一局还在押注中则跳过)
if (BaccaratRound::currentRound()) {
if (BaccaratRound::currentRound($this->roomId)) {
return;
}
@@ -53,6 +65,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
// 创建新局次
$round = BaccaratRound::create([
'room_id' => $this->roomId,
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
@@ -77,10 +90,10 @@ class OpenBaccaratRoundJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与</button>';
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡 1:{$bigRate} · 💥豹子 1:{$tripleRate}☠️ {$killText}庄家收割".$quickOpenButton;
$content = "🎲 第 #{$round->id} 局开局:{$betSeconds}下注{$minBet}~{$maxBet} 金币,🔵/🟡 1:{$bigRate},💥 1:{$tripleRate}☠️ {$killText} 点收割".$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -89,8 +102,8 @@ class OpenBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg);
// 如果允许 AI 参与,延迟一定时间派发 AI 下注任务
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一场新的赛马竞猜回合。
*/
class OpenHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 构造开赛任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -41,7 +53,7 @@ class OpenHorseRaceJob implements ShouldQueue
}
// 防止重复开赛(上一场还在进行中)
if (HorseRace::currentRace()) {
if (HorseRace::currentRace($this->roomId)) {
return;
}
@@ -60,6 +72,7 @@ class OpenHorseRaceJob implements ShouldQueue
// 创建新场次
$race = HorseRace::create([
'room_id' => $this->roomId,
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
@@ -79,11 +92,11 @@ class OpenHorseRaceJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>';
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$content = "🐎 第 #{$race->id} 场开赛:{$horseList}{$betSeconds} 秒下注,".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -92,8 +105,8 @@ class OpenHorseRaceJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg);
// 押注截止后触发跑马 & 结算任务
+15 -2
View File
@@ -19,10 +19,22 @@ use App\Models\LotteryIssue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间创建一条新的双色球期次。
*/
class OpenLotteryIssueJob implements ShouldQueue
{
use Queueable;
/**
* 构造开期任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -38,7 +50,7 @@ class OpenLotteryIssueJob implements ShouldQueue
}
// 已有进行中的期次则跳过
if (LotteryIssue::currentIssue()) {
if (LotteryIssue::currentIssue($this->roomId)) {
return;
}
@@ -56,7 +68,8 @@ class OpenLotteryIssueJob implements ShouldQueue
$closeAt = $drawAt->copy()->subMinutes($stopMinutes);
LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(),
'room_id' => $this->roomId,
'issue_no' => LotteryIssue::nextIssueNo($this->roomId),
'status' => 'open',
'pool_amount' => 0,
'carry_amount' => 0,
+14 -8
View File
@@ -22,6 +22,12 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:赛马跑马动画广播与结算衔接任务
*
* 负责在押注截止后推进 running 流程、广播实时进度,
* 并在同一条任务链中补齐赛果与触发最终结算,避免线上状态滞留。
*/
class RunHorseRaceJob implements ShouldQueue
{
use Queueable;
@@ -72,18 +78,18 @@ class RunHorseRaceJob implements ShouldQueue
));
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id}押注截止!马匹已进入跑道,比赛开始!参赛阵容{$horseList}",
'content' => "🏇 第 #{$race->id}比赛开始{$horseList}",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $startMsg);
broadcast(new MessageSent(1, $startMsg));
$chatState->pushMessage((int) $race->room_id, $startMsg);
broadcast(new MessageSent((int) $race->room_id, $startMsg));
SaveMessageJob::dispatch($startMsg);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
@@ -126,7 +132,7 @@ class RunHorseRaceJob implements ShouldQueue
}
// 广播当前帧进度
broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
broadcast(new HorseRaceProgress($race->id, (int) $race->room_id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
if ($finished) {
break;
@@ -156,8 +162,8 @@ class RunHorseRaceJob implements ShouldQueue
'total_pool' => $totalPool,
]);
// 触发结算任务
CloseHorseRaceJob::dispatch($race->fresh());
// 在同一条队列任务里直接完成结算,避免线上出现“已跑完但 Close 任务未继续消费”的断链。
app()->call([new CloseHorseRaceJob($race->fresh()), 'handle']);
}
/**
+14 -5
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存百家乐局次数据并提供当前局查询能力。
*/
class BaccaratRound extends Model
{
protected $fillable = [
'room_id',
'dice1', 'dice2', 'dice3',
'total_points', 'result', 'status',
'bet_opens_at', 'bet_closes_at', 'settled_at',
@@ -36,6 +40,7 @@ class BaccaratRound extends Model
'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime',
'settled_at' => 'datetime',
'room_id' => 'integer',
'dice1' => 'integer',
'dice2' => 'integer',
'dice3' => 'integer',
@@ -104,12 +109,16 @@ class BaccaratRound extends Model
/**
* 查询当前正在进行的局次(状态为 betting 且未截止)。
*/
public static function currentRound(): ?static
public static function currentRound(?int $roomId = null): ?static
{
return static::query()
$query = static::query()
->where('status', 'betting')
->where('bet_closes_at', '>', now())
->latest()
->first();
->where('bet_closes_at', '>', now());
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
}
+16 -5
View File
@@ -16,9 +16,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:赛马竞猜局次模型
*
* 负责描述赛马场次的生命周期、参赛马匹、下注汇总与派奖计算,
* 并为控制器和队列任务提供当前场次、赔率与奖池算法支持。
*/
class HorseRace extends Model
{
protected $fillable = [
'room_id',
'status',
'bet_opens_at',
'bet_closes_at',
@@ -42,6 +49,7 @@ class HorseRace extends Model
'race_starts_at' => 'datetime',
'race_ends_at' => 'datetime',
'settled_at' => 'datetime',
'room_id' => 'integer',
'horses' => 'array',
'winner_horse_id' => 'integer',
'total_bets' => 'integer',
@@ -69,12 +77,15 @@ class HorseRace extends Model
/**
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
*/
public static function currentRace(): ?static
public static function currentRace(?int $roomId = null): ?static
{
return static::query()
->whereIn('status', ['betting', 'running'])
->latest()
->first();
$query = static::query()->whereIn('status', ['betting', 'running']);
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
+29 -9
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存双色球期次数据并提供按房间查询能力。
*/
class LotteryIssue extends Model
{
protected $fillable = [
'room_id',
'issue_no',
'status',
'red1', 'red2', 'red3', 'blue',
@@ -38,6 +42,7 @@ class LotteryIssue extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'is_super_issue' => 'boolean',
'pool_amount' => 'integer',
'carry_amount' => 'integer',
@@ -71,29 +76,44 @@ class LotteryIssue extends Model
/**
* 获取当前正在购票的期次(status=open)。
*/
public static function currentIssue(): ?static
public static function currentIssue(?int $roomId = null): ?static
{
return static::query()->where('status', 'open')->latest()->first();
$query = static::query()->where('status', 'open');
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
* 获取最新一期(不论状态)。
*/
public static function latestIssue(): ?static
public static function latestIssue(?int $roomId = null): ?static
{
return static::query()->latest()->first();
$query = static::query();
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
* 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。
*/
public static function nextIssueNo(): string
public static function nextIssueNo(?int $roomId = null): string
{
$year = now()->year;
$last = static::query()
->whereYear('created_at', $year)
->latest()
->first();
$query = static::query()->whereYear('created_at', $year);
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
$last = $query->latest()->first();
$seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1;
+14 -5
View File
@@ -17,9 +17,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 类功能:保存神秘箱子投放记录并提供当前箱子查询能力。
*/
class MysteryBox extends Model
{
protected $fillable = [
'room_id',
'box_type',
'passcode',
'reward_min',
@@ -35,6 +39,7 @@ class MysteryBox extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'reward_min' => 'integer',
'reward_max' => 'integer',
'expires_at' => 'datetime',
@@ -64,13 +69,17 @@ class MysteryBox extends Model
/**
* 当前可领取(open 状态 + 未过期)的箱子。
*/
public static function currentOpenBox(): ?static
public static function currentOpenBox(?int $roomId = null): ?static
{
return static::query()
$query = static::query()
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->latest()
->first();
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
// ─── 工具方法 ────────────────────────────────────────────────────
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* 文件功能:猜谜活动题库模型
*
* 对应 idioms 表,统一承载成语题与脑筋急转弯题目。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 类功能:统一管理猜谜活动的题目、答案、提示与题型。
*/
class Riddle extends Model
{
/**
* 属性功能:显式绑定历史题库表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idioms';
/**
* 常量功能:声明成语题题型标识。
*/
public const TYPE_IDIOM = 'idiom';
/**
* 常量功能:声明脑筋急转弯题型标识。
*/
public const TYPE_BRAIN_TEASER = 'brain_teaser';
/**
* 方法功能:声明允许批量赋值的题库字段。
*
* @var array<int, string>
*/
protected $fillable = [
'type',
'answer',
'hint',
'is_active',
'sort',
];
/**
* 方法功能:定义题库字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort' => 'integer',
];
}
/**
* 方法功能:返回系统支持的全部题型。
*
* @return array<int, string>
*/
public static function supportedTypes(): array
{
return [
self::TYPE_IDIOM,
self::TYPE_BRAIN_TEASER,
];
}
/**
* 方法功能:判断给定题型是否属于系统支持范围。
*/
public static function isSupportedType(string $type): bool
{
return in_array($type, self::supportedTypes(), true);
}
/**
* 方法功能:根据题型返回面向用户的中文名称。
*/
public static function labelForType(string $type): string
{
return match ($type) {
self::TYPE_BRAIN_TEASER => '脑筋急转弯',
default => '猜成语',
};
}
/**
* 方法功能:返回后台表单可直接使用的题型键值对。
*
* @return array<string, string>
*/
public static function typeOptions(): array
{
return collect(self::supportedTypes())
->mapWithKeys(fn (string $type): array => [$type => self::labelForType($type)])
->all();
}
/**
* 方法功能:返回题型对应的活动标题。
*/
public static function activityLabelForType(string $type): string
{
return '猜谜活动·'.self::labelForType($type);
}
/**
* 方法功能:按题型筛选题库记录。
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', self::isSupportedType($type) ? $type : self::TYPE_IDIOM);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:猜谜活动回合模型
*
* 每次出题对应一个回合,记录题型、题目、状态、奖励和获胜者。
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录猜谜活动每一轮的题型、奖励与结算状态。
*/
class RiddleGameRound extends Model
{
/**
* 属性功能:显式绑定历史回合表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idiom_game_rounds';
/**
* 方法功能:声明可批量赋值的回合字段。
*
* @var array<int, string>
*/
protected $fillable = [
'room_id',
'idiom_id',
'quiz_type',
'status',
'reward_gold',
'reward_exp',
'winner_id',
'winner_username',
'started_at',
'ended_at',
];
/**
* 方法功能:定义回合字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'room_id' => 'integer',
'idiom_id' => 'integer',
'reward_gold' => 'integer',
'reward_exp' => 'integer',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
/**
* 方法功能:关联本回合对应的猜谜题目。
*/
public function idiom(): BelongsTo
{
return $this->belongsTo(Riddle::class);
}
}
+9
View File
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class ShopItem extends Model
{
public const TYPE_SIGN_REPAIR = 'sign_repair';
public const DECORATION_TYPES = ['msg_bubble', 'msg_name_color', 'msg_text_color', 'avatar_frame'];
protected $table = 'shop_items';
@@ -51,6 +52,14 @@ class ShopItem extends Model
return $this->type === self::TYPE_SIGN_REPAIR;
}
/**
* 是否为个人装扮(气泡、颜色、头像框等)。
*/
public function isDecoration(): bool
{
return in_array($this->type, self::DECORATION_TYPES, true);
}
/**
* 是否为特效类商品(instant durationslug once_ week_ 开头)
*/
+3 -2
View File
@@ -400,7 +400,8 @@ class BaccaratLossCoverService
}
if ($compensableCount > 0) {
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>';
// 聊天消息内的按钮使用相对字号,跟随用户在底部工具栏选择的聊天字号。
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>';
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}{$button}";
} else {
$content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。";
@@ -446,7 +447,7 @@ class BaccaratLossCoverService
$formattedAmount = number_format($amount);
$button = $event->status === 'claimable'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>'
? ' <button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:0.82em;font-weight:bold;">领取补偿</button>'
: '';
// 领取成功的公屏格式复用百家乐参与播报风格,保证聊天室感知一致。
+24 -20
View File
@@ -11,6 +11,7 @@
* (会员加成:+经验X+金币Y
*
* @author ChatRoom Laravel
*
* @version 1.2.0
*/
@@ -24,20 +25,19 @@ use App\Models\User;
class FishingService
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
)
{
}
private readonly ShopService $shopService,
) {}
/**
* 处理收竿逻辑:计算结果、发放积分并全服广播。
*
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @param User $user 收竿的用户实体
* @param int $roomId 所在房间 ID
* @param bool $isAi 是否为 AI 调用(用于影响文案或标签)
* @return array{emoji:string,message:string,exp:int,jjb:int,base_exp:int,base_jjb:int,bonus_exp:int,bonus_jjb:int}
*/
public function processCatch(User $user, int $roomId, bool $isAi = false): array
{
@@ -54,11 +54,11 @@ class FishingService
if ($result['exp'] !== 0) {
// 当经验为 正数 则可使用会员翻倍,负数则不
$finalExp = $result['exp'] > 0 ? (int)round($result['exp'] * $expMul) : $result['exp'];
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
}
if ($result['jjb'] !== 0) {
$finalJjb = $result['jjb'] > 0 ? (int)round($result['jjb'] * $jjbMul) : $result['jjb'];
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
}
// 4. 计算会员额外加成部分
@@ -92,16 +92,19 @@ class FishingService
// 8. 广播钓鱼结果到聊天室
$promoTag = '';
if (!$isAi) {
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>'
.'style="display:inline-block;margin-left:6px;padding:1px 7px;background:#e9e4f5;'
.'color:#6d4fa8;border-radius:10px;font-size:0.78em;cursor:pointer;font-weight:bold;vertical-align:middle;'
.'border:1px solid #d0c4ec;" title="点击购买自动钓鱼卡">🎣 自动钓鱼卡</span>'
: '';
}
// 广播结果时额外带上统一动作标记和钓鱼者用户名,
// 方便前端把“钓鱼者本人”的公屏结果折叠到包厢窗口,避免重复显示。
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
@@ -110,7 +113,8 @@ class FishingService
'content' => "{$result['emoji']}{$user->username}{$finalMessage}{$promoTag}",
'is_secret' => false,
'font_color' => ($result['exp'] < 0 || $result['jjb'] < 0) ? '#dc2626' : '#16a34a',
'action' => '',
'action' => 'fishing_result',
'fishing_username' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
@@ -154,7 +158,7 @@ class FishingService
return $baseMessage;
}
return $baseMessage . '' . $user->vipName() . '追加:' . implode('', $bonusParts) . '';
return $baseMessage.''.$user->vipName().'追加:'.implode('', $bonusParts).'';
}
/**
@@ -168,7 +172,7 @@ class FishingService
{
$event = FishingEvent::rollOne();
if (!$event) {
if (! $event) {
return [
'emoji' => '🐟',
'message' => '钓到一条小鱼,获得金币10',
@@ -180,8 +184,8 @@ class FishingService
return [
'emoji' => $event->emoji,
'message' => $event->message,
'exp' => (int)$event->exp,
'jjb' => (int)$event->jjb,
'exp' => (int) $event->exp,
'jjb' => (int) $event->jjb,
];
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
/**
* 文件功能:游戏房间范围配置服务
*
* 统一解析所有游戏的 room_scope_mode room_ids 配置,
* 供后台保存、调度任务、前台准入校验和公共回合查询复用。
*/
namespace App\Services;
use App\Models\GameConfig;
use App\Models\User;
use Illuminate\Http\Request;
/**
* 类功能:统一管理所有游戏的房间范围读取与房间判定。
*/
class GameRoomScopeService
{
/**
* 房间模式常量:全部房间。
*/
public const MODE_ALL = 'all';
/**
* 房间模式常量:单选房间。
*/
public const MODE_SINGLE = 'single';
/**
* 房间模式常量:多选房间。
*/
public const MODE_MULTIPLE = 'multiple';
/**
* 支持的房间模式列表。
*
* @var array<int, string>
*/
public const SUPPORTED_MODES = [
self::MODE_ALL,
self::MODE_SINGLE,
self::MODE_MULTIPLE,
];
/**
* 构造房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 归一化房间模式。
*/
public function normalizeRoomScopeMode(?string $mode, string $default = self::MODE_SINGLE): string
{
$normalizedMode = (string) $mode;
if (! in_array($normalizedMode, self::SUPPORTED_MODES, true)) {
return $default;
}
return $normalizedMode;
}
/**
* 把原始房间数组归一化为去重后的整型数组。
*
* @return array<int, int>
*/
public function normalizeRoomIds(mixed $roomIds, array $default = [1]): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
$normalizedRoomIds = collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
if ($normalizedRoomIds === []) {
return $default;
}
return $normalizedRoomIds;
}
/**
* params 数组中解析房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForParams(array $params, array $defaultRoomIds = [1]): array
{
if (
! array_key_exists('room_scope_mode', $params)
&& ! array_key_exists('room_ids', $params)
&& ! array_key_exists('room_id', $params)
) {
return [
'room_scope_mode' => self::MODE_ALL,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
$roomScopeMode = $this->normalizeRoomScopeMode(
mode: (string) ($params['room_scope_mode'] ?? self::MODE_SINGLE),
default: self::MODE_SINGLE,
);
$roomIds = $this->normalizeRoomIds(
roomIds: $params['room_ids'] ?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : []),
default: $defaultRoomIds,
);
return [
'room_scope_mode' => $roomScopeMode,
'room_ids' => $roomIds,
];
}
/**
* 读取指定游戏当前配置中的房间范围。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$params = GameConfig::forGame($gameKey)?->params ?? [];
return $this->getScopeConfigForParams($params, $defaultRoomIds);
}
/**
* 获取指定游戏真正生效的房间 ID 列表。
*
* @return array<int, int>
*/
public function getScopedRoomIdsForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$scopeConfig = $this->getScopeConfigForGame($gameKey, $defaultRoomIds);
if ($scopeConfig['room_scope_mode'] === self::MODE_ALL) {
return $this->resolveAllAvailableRoomIds($defaultRoomIds);
}
return $scopeConfig['room_ids'];
}
/**
* 获取指定游戏的首选房间。
*/
public function getPrimaryRoomIdForGame(string $gameKey, int $fallback = 1): int
{
$roomIds = $this->getScopedRoomIdsForGame($gameKey, [$fallback]);
return $roomIds[0] ?? $fallback;
}
/**
* 判断某个房间是否在指定游戏允许范围内。
*/
public function isRoomAllowedForGame(string $gameKey, int $roomId, array $defaultRoomIds = [1]): bool
{
return in_array($roomId, $this->getScopedRoomIdsForGame($gameKey, $defaultRoomIds), true);
}
/**
* 从请求或在线状态解析当前操作房间。
*/
public function resolveRequestRoomId(Request $request, ?User $user = null, int $fallback = 1): int
{
$requestedRoomId = (int) $request->integer('room_id', 0);
if ($requestedRoomId > 0) {
return $requestedRoomId;
}
return $this->resolveUserRoomId($user ?? $request->user(), $fallback);
}
/**
* 从用户在线房间或用户资料中推断当前房间。
*/
public function resolveUserRoomId(?User $user, int $fallback = 1): int
{
if (! $user) {
return $fallback;
}
$activeRoomIds = $this->chatState->getUserRooms($user->username);
if ($activeRoomIds !== []) {
return (int) $activeRoomIds[0];
}
$profileRoomId = (int) ($user->room_id ?? 0);
return $profileRoomId > 0 ? $profileRoomId : $fallback;
}
/**
* 返回通用后台复用的默认房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function defaultScopeConfig(array $defaultRoomIds = [1]): array
{
return [
'room_scope_mode' => self::MODE_SINGLE,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
/**
* 在“全部房间”模式下解析当前可用房间。
*
* @return array<int, int>
*/
private function resolveAllAvailableRoomIds(array $defaultRoomIds = [1]): array
{
$roomIds = \App\Models\Room::query()
->orderBy('id')
->pluck('id')
->map(fn (mixed $roomId): int => (int) $roomId)
->all();
return $roomIds !== [] ? $roomIds : $defaultRoomIds;
}
}
+18 -13
View File
@@ -22,11 +22,15 @@ use App\Models\LotteryTicket;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* 类功能:负责双色球购票、开奖、滚存与房间广播。
*/
class LotteryService
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
// ─── 购票 ─────────────────────────────────────────────────────────
@@ -49,7 +53,8 @@ class LotteryService
throw new \RuntimeException('双色球彩票游戏未开启');
}
$issue = LotteryIssue::currentIssue();
$roomId = $this->roomScopeService->resolveUserRoomId($user);
$issue = LotteryIssue::currentIssue($roomId);
if (! $issue || ! $issue->isOpen()) {
throw new \RuntimeException('当前无正在进行的期次,或已停售');
}
@@ -135,7 +140,7 @@ class LotteryService
$firstTicket = $tickets[0];
$numsStr = $firstTicket->numbersLabel();
$moreStr = $buyCount > 1 ? "{$buyCount} 注号码" : '';
$this->pushSystemMessage("🎟️ 【双色球彩票】财神爷保佑!玩家【{$user->username}豪掷千金,购买了当前 #{$issue->issue_no}双色球 {$numsStr} {$moreStr},祝 Ta 中大奖!");
$this->pushSystemMessage("🎟️ 【{$user->username}购买 {$issue->issue_no}{$numsStr} {$moreStr}", (int) $issue->room_id);
return $tickets;
}
@@ -364,7 +369,8 @@ class LotteryService
}
$newIssue = LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(),
'room_id' => (int) $prevIssue->room_id,
'issue_no' => LotteryIssue::nextIssueNo((int) $prevIssue->room_id),
'status' => 'open',
'pool_amount' => $carryAmount + $injectAmount,
'carry_amount' => $carryAmount,
@@ -444,9 +450,9 @@ class LotteryService
$detailStr = $details ? ' '.implode(' | ', $details) : '';
$content = "🎟️ 【双色球 {$issue->issue_no} 开奖{$drawNums} {$line1}{$detailStr}";
$content = "🎟️ 第 #{$issue->issue_no} 期开奖{$drawNums} {$line1}{$detailStr}";
$this->pushSystemMessage($content);
$this->pushSystemMessage($content, (int) $issue->room_id);
// 触发微信机器人消息推送 (彩票开奖)
try {
@@ -463,20 +469,19 @@ class LotteryService
private function broadcastSuperIssue(LotteryIssue $issue): void
{
$pool = number_format($issue->pool_amount);
$content = "🎊🎟️ 【双色球超级期预警】{$issue->issue_no} 期已连续 {$issue->no_winner_streak} 期无一等奖"
."当前奖池 💰 {$pool} 金币,系统已追加注入!今日 {$issue->draw_at?->format('H:i')} 开奖,赶紧购票!";
$content = "🎊 #{$issue->issue_no}超级期:已连续 {$issue->no_winner_streak} 期无一等奖,奖池 💰 {$pool}{$issue->draw_at?->format('H:i')} 开奖。";
$this->pushSystemMessage($content);
$this->pushSystemMessage($content, (int) $issue->room_id);
}
/**
* 向公屏发送系统消息。
*/
private function pushSystemMessage(string $content): void
private function pushSystemMessage(string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -485,8 +490,8 @@ class LotteryService
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
}
}
+493
View File
@@ -0,0 +1,493 @@
<?php
/**
* 文件功能:猜谜活动回合服务
*
* 统一处理题型兼容、房间范围、自动出题、超时结算与公屏公告,
* 避免控制器与定时任务各自维护一套猜谜活动逻辑。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Events\RiddleGameStarted;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Models\Room;
/**
* 类功能:提供猜谜活动的配置读取、出题、过期结算与公告能力。
*/
class RiddleGameService
{
/**
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 方法功能:读取指定题型的完整配置,并兼容旧版平铺参数。
*
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
*/
public function getTypeConfig(?string $quizType = null): array
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$config = GameConfig::forGame(Riddle::TYPE_IDIOM) ?? GameConfig::forGame($normalizedQuizType);
$params = $config?->params ?? [];
$typeConfig = (array) (($params['type_configs'] ?? [])[$normalizedQuizType] ?? []);
$sharedRoomIds = $this->normalizeRoomIds(
$params['room_ids']
?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : [])
);
$roomMode = (string) ($params['room_scope_mode'] ?? ($typeConfig['room_mode'] ?? 'single'));
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
$roomMode = 'single';
}
$roomIds = $sharedRoomIds !== []
? $sharedRoomIds
: $this->normalizeRoomIds($typeConfig['room_ids'] ?? [1]);
return [
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($typeConfig['reward_gold'] ?? 50))),
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($typeConfig['reward_exp'] ?? 30))),
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($typeConfig['expire_minutes'] ?? 5))),
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($typeConfig['auto_start_interval'] ?? 0))),
'room_mode' => $roomMode,
'room_ids' => $roomIds,
];
}
/**
* 方法功能:读取题目有效时长配置,单位分钟。
*/
public function getExpireMinutes(?string $quizType = null): int
{
return $this->getTypeConfig($quizType)['expire_minutes'];
}
/**
* 方法功能:读取自动出题间隔配置,单位分钟。
*/
public function getAutoStartInterval(?string $quizType = null): int
{
return $this->getTypeConfig($quizType)['auto_start_interval'];
}
/**
* 方法功能:读取答题奖励配置。
*
* @return array{reward_gold:int,reward_exp:int}
*/
public function getRewardConfig(?string $quizType = null): array
{
$typeConfig = $this->getTypeConfig($quizType);
return [
'reward_gold' => $typeConfig['reward_gold'],
'reward_exp' => $typeConfig['reward_exp'],
];
}
/**
* 方法功能:将外部传入的题型归一化为系统支持值。
*/
public function normalizeQuizType(?string $quizType): string
{
$normalizedType = trim((string) $quizType);
return Riddle::isSupportedType($normalizedType)
? $normalizedType
: Riddle::TYPE_IDIOM;
}
/**
* 方法功能:返回题型对应的中文名称。
*/
public function getQuizTypeLabel(string $quizType): string
{
return Riddle::labelForType($this->normalizeQuizType($quizType));
}
/**
* 方法功能:读取自动出题的房间范围模式。
*/
public function getRoomScopeMode(?string $quizType = null): string
{
return $this->getTypeConfig($quizType)['room_mode'];
}
/**
* 方法功能:读取自动出题允许覆盖的房间列表。
*
* @return array<int, int>
*/
public function getScopedRoomIds(?string $quizType = null): array
{
$typeConfig = $this->getTypeConfig($quizType);
$mode = $typeConfig['room_mode'];
$configuredRoomIds = $typeConfig['room_ids'];
if ($mode === 'all') {
return Room::query()->orderBy('id')->pluck('id')->map(fn (mixed $id): int => (int) $id)->all();
}
if ($mode === 'single') {
return array_slice($configuredRoomIds !== [] ? $configuredRoomIds : [1], 0, 1);
}
return $configuredRoomIds !== [] ? $configuredRoomIds : [1];
}
/**
* 方法功能:判断指定回合是否已经超过有效时长。
*/
public function isRoundExpired(RiddleGameRound $round): bool
{
$expireMinutes = $this->getExpireMinutes($round->quiz_type);
if ($expireMinutes <= 0) {
return false;
}
if (! in_array($round->status, ['pending', 'active'], true)) {
return false;
}
if (! $round->started_at) {
return false;
}
return $round->started_at->copy()->addMinutes($expireMinutes)->lte(now());
}
/**
* 方法功能:结算并结束已过期的回合,必要时发送超时公告。
*/
public function expireRound(RiddleGameRound $round, bool $announce = true): bool
{
if (! $this->isRoundExpired($round)) {
return false;
}
$round->loadMissing('idiom');
// 已过期回合统一落为 ended,防止继续答题或阻塞新开题。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
if ($announce) {
$this->pushExpiredRoundMessage($round);
}
return true;
}
/**
* 方法功能:批量清理指定房间内已超时但仍处于进行中的回合。
*/
public function expireActiveRoundsForRoom(int $roomId, bool $announce = true, ?string $quizType = null): int
{
$expiredCount = 0;
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
RiddleGameRound::with('idiom')
->where('room_id', $roomId)
->when(
$normalizedQuizType !== null,
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (RiddleGameRound $round) use ($announce, &$expiredCount): void {
if ($this->expireRound($round, $announce)) {
$expiredCount++;
}
});
return $expiredCount;
}
/**
* 方法功能:手动结束指定房间指定题型的所有进行中回合。
*/
public function endActiveRoundsForRoom(int $roomId, ?string $quizType = null): int
{
$endedCount = 0;
$normalizedQuizType = $quizType !== null ? $this->normalizeQuizType($quizType) : null;
RiddleGameRound::query()
->where('room_id', $roomId)
->when(
$normalizedQuizType !== null,
fn ($query) => $query->where('quiz_type', $normalizedQuizType),
)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (RiddleGameRound $round) use (&$endedCount): void {
// 手动出题覆盖旧题时,直接结束旧回合,不再额外发超时公告。
$round->update([
'status' => 'ended',
'ended_at' => $round->ended_at ?? now(),
]);
$endedCount++;
});
return $endedCount;
}
/**
* 方法功能:为指定房间和题型创建一轮新题。
*/
public function startRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
if (! $this->isGameEnabled($normalizedQuizType)) {
return null;
}
// 先清理同房间同题型的过期回合,避免旧记录卡住新题。
$this->expireActiveRoundsForRoom($roomId, true, $normalizedQuizType);
if ($this->findActiveRound($roomId, $normalizedQuizType)) {
return null;
}
$idiom = $this->pickRandomQuestion($normalizedQuizType);
if (! $idiom) {
return null;
}
$rewardConfig = $this->getRewardConfig($normalizedQuizType);
// 新回合显式记录 quiz_type,保证房间与题型维度都能独立判定。
$round = RiddleGameRound::create([
'room_id' => $roomId,
'idiom_id' => $idiom->id,
'quiz_type' => $normalizedQuizType,
'status' => 'active',
'reward_gold' => $rewardConfig['reward_gold'],
'reward_exp' => $rewardConfig['reward_exp'],
'started_at' => now(),
]);
$round->setRelation('idiom', $idiom);
$this->broadcastStartedRound($round);
return $round;
}
/**
* 方法功能:按配置范围自动为各房间各题型尝试开题。
*/
public function autoStartEligibleRounds(): int
{
$startedCount = 0;
foreach (Riddle::supportedTypes() as $quizType) {
$interval = $this->getAutoStartInterval($quizType);
if ($interval <= 0) {
continue;
}
foreach ($this->getScopedRoomIds($quizType) as $roomId) {
// 房间与题型维度独立结算过期回合,互不干扰。
$this->expireActiveRoundsForRoom($roomId, true, $quizType);
if ($this->findActiveRound($roomId, $quizType)) {
continue;
}
if (! $this->hasReachedAutoStartInterval($roomId, $quizType, $interval)) {
continue;
}
if (! $this->pickRandomQuestion($quizType)) {
continue;
}
if ($this->startRound($roomId, $quizType)) {
$startedCount++;
}
}
}
return $startedCount;
}
/**
* 方法功能:查询指定房间指定题型的进行中回合。
*/
public function findActiveRound(int $roomId, ?string $quizType = null): ?RiddleGameRound
{
return RiddleGameRound::query()
->with('idiom')
->where('room_id', $roomId)
->where('quiz_type', $this->normalizeQuizType($quizType))
->whereIn('status', ['pending', 'active'])
->first();
}
/**
* 方法功能:随机抽取一条启用中的题目。
*/
public function pickRandomQuestion(?string $quizType = null): ?Riddle
{
return Riddle::query()
->where('type', $this->normalizeQuizType($quizType))
->where('is_active', true)
->inRandomOrder()
->first();
}
/**
* 方法功能:生成答题奖励日志文案。
*/
public function buildRewardDescription(RiddleGameRound $round): string
{
$quizTypeLabel = $this->getQuizTypeLabel($round->quiz_type);
return "猜谜活动{$quizTypeLabel}答对「{$round->idiom?->answer}」奖励";
}
/**
* 方法功能:向公屏推送回合超时公告。
*/
public function pushExpiredRoundMessage(RiddleGameRound $round): void
{
$answer = $round->idiom?->answer ?? '未知答案';
$quizTitle = Riddle::activityLabelForType($round->quiz_type);
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏳ 【{$quizTitle}】第 #{$round->id} 题已超时结束!正确答案:{$answer}",
'is_secret' => false,
'font_color' => '#d97706',
'action' => '',
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
'quiz_round_id' => $round->id,
'quiz_round_ended_id' => $round->id,
'quiz_answer' => $answer,
'idiom_game_round_ended_id' => $round->id,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
/**
* 方法功能:广播新回合开始事件并同步写入公屏消息。
*/
public function broadcastStartedRound(RiddleGameRound $round): void
{
$round->loadMissing('idiom');
broadcast(new RiddleGameStarted(
roomId: $round->room_id,
quizType: $round->quiz_type,
hint: $round->idiom?->hint ?? '',
roundId: $round->id,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $this->buildStartMessage($round->quiz_type, $round->id, $round->idiom?->hint ?? ''),
'is_secret' => false,
'font_color' => '#b91c1c',
'action' => '',
'quiz_type' => $this->normalizeQuizType($round->quiz_type),
'quiz_type_label' => $this->getQuizTypeLabel($round->quiz_type),
'quiz_round_id' => $round->id,
'quiz_hint' => $round->idiom?->hint ?? '',
'quiz_reward_gold' => $round->reward_gold,
'quiz_reward_exp' => $round->reward_exp,
'idiom_game_round_id' => $round->id,
'idiom_reward_gold' => $round->reward_gold,
'idiom_reward_exp' => $round->reward_exp,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($round->room_id, $message);
broadcast(new MessageSent($round->room_id, $message));
}
/**
* 方法功能:判断指定房间指定题型是否已到自动开题间隔。
*/
private function hasReachedAutoStartInterval(int $roomId, string $quizType, int $interval): bool
{
$lastRound = RiddleGameRound::query()
->where('room_id', $roomId)
->where('quiz_type', $this->normalizeQuizType($quizType))
->latest()
->first();
if (! $lastRound) {
return true;
}
$lastTime = $lastRound->ended_at ?? $lastRound->started_at ?? $lastRound->created_at;
return ! $lastTime || $lastTime->diffInMinutes(now()) >= $interval;
}
/**
* 方法功能:把 room_ids 配置归一化为整型数组。
*
* @return array<int, int>
*/
private function normalizeRoomIds(mixed $roomIds): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
return collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
}
/**
* 方法功能:返回题型对应的活动标题。
*/
private function buildStartMessage(string $quizType, int $roundId, string $hint): string
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$quizLabel = $this->getQuizTypeLabel($normalizedQuizType);
$icon = $normalizedQuizType === Riddle::TYPE_BRAIN_TEASER ? '🧠' : '🧩';
return "{$icon} 【猜谜活动·{$quizLabel}】第 #{$roundId} 题开始!题面:{$hint}";
}
/**
* 方法功能:判断猜谜活动总开关是否处于启用状态。
*/
private function isGameEnabled(?string $quizType = null): bool
{
$normalizedQuizType = $this->normalizeQuizType($quizType);
$config = GameConfig::forGame($normalizedQuizType) ?? GameConfig::forGame(Riddle::TYPE_IDIOM);
return (bool) $config?->enabled;
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class ShopService
*/
public function buyItem(User $user, ShopItem $item, int $quantity = 1): array
{
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) {
if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR && !$item->isDecoration()) {
return ['ok' => false, 'message' => '该商品暂不支持批量购买。'];
}
+32 -1
View File
@@ -1,5 +1,35 @@
<?php
$normalizeReverbAllowedOrigins = static function (?string $rawOrigins): array {
if ($rawOrigins === null || trim($rawOrigins) === '') {
return ['*'];
}
$normalizedOrigins = [];
foreach (explode(',', $rawOrigins) as $origin) {
$candidate = trim($origin);
if ($candidate === '') {
continue;
}
if ($candidate === '*') {
return ['*'];
}
$host = parse_url($candidate, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
$host = parse_url('http://'.$candidate, PHP_URL_HOST);
}
$normalizedOrigins[] = is_string($host) && $host !== '' ? $host : $candidate;
}
return array_values(array_unique($normalizedOrigins));
};
return [
/*
@@ -82,7 +112,8 @@ return [
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
// Reverb 内部按 Origin 的主机名比对,这里统一转成 host,避免把完整 URL 写进 .env 后被误拒绝。
'allowed_origins' => $normalizeReverbAllowedOrigins(env('REVERB_ALLOWED_ORIGIN')),
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
@@ -0,0 +1,41 @@
<?php
/**
* 文件功能:创建猜成语题库表
*
* 存储成语题目及答案,管理员可在后台增删改。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 idioms 表。
*/
public function up(): void
{
Schema::create('idioms', function (Blueprint $table) {
$table->id();
$table->string('answer', 50)->comment('成语答案');
$table->string('hint', 255)->comment('谜语线索提示');
$table->boolean('is_active')->default(true)->comment('是否启用');
$table->unsignedSmallInteger('sort')->default(0)->comment('排序');
$table->timestamps();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::dropIfExists('idioms');
}
};
@@ -0,0 +1,49 @@
<?php
/**
* 文件功能:创建猜成语游戏回合表
*
* 每次出题对应一个回合,记录答题状态、奖励和获胜者。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 idiom_game_rounds 表。
*/
public function up(): void
{
Schema::create('idiom_game_rounds', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('room_id')->comment('游戏所在房间 ID');
$table->unsignedBigInteger('idiom_id')->comment('当前题目 ID');
$table->string('status', 20)->default('pending')->comment('状态:pending/active/answered/ended');
$table->integer('reward_gold')->default(0)->comment('答对奖励金币');
$table->integer('reward_exp')->default(0)->comment('答对奖励经验');
$table->unsignedBigInteger('winner_id')->nullable()->comment('答对用户 ID');
$table->string('winner_username', 50)->nullable()->comment('答对用户名');
$table->timestamp('started_at')->nullable()->comment('开始答题时间');
$table->timestamp('ended_at')->nullable()->comment('结束答题时间');
$table->timestamps();
$table->foreign('idiom_id')->references('id')->on('idioms')->onDelete('cascade');
$table->index('status');
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::dropIfExists('idiom_game_rounds');
}
};
@@ -0,0 +1,73 @@
<?php
/**
* 文件功能:将猜成语数据结构升级为猜谜活动通用结构
*
* 为题库增加题型字段,为回合增加 quiz_type 与复合索引,
* 兼容既有“猜成语”数据并为脑筋急转弯题型预留能力。
*/
use App\Models\Riddle;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 方法功能:执行表结构升级并补齐历史数据默认值。
*/
public function up(): void
{
Schema::table('idioms', function (Blueprint $table): void {
if (! Schema::hasColumn('idioms', 'type')) {
$table->string('type', 30)->default(Riddle::TYPE_IDIOM)->after('id')->comment('题型:idiom/brain_teaser');
}
});
// 历史成语题默认归类到 idiom,保证旧数据无需人工修复。
DB::table('idioms')
->whereNull('type')
->orWhere('type', '')
->update(['type' => Riddle::TYPE_IDIOM]);
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
if (! Schema::hasColumn('idiom_game_rounds', 'quiz_type')) {
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM)->after('idiom_id')->comment('回合题型:idiom/brain_teaser');
}
});
// 历史回合默认按成语题处理,确保旧记录仍可正常展示与过期结算。
DB::table('idiom_game_rounds')
->whereNull('quiz_type')
->orWhere('quiz_type', '')
->update(['quiz_type' => Riddle::TYPE_IDIOM]);
Schema::table('idioms', function (Blueprint $table): void {
$table->index(['type', 'is_active'], 'idioms_type_is_active_index');
});
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
$table->index(['room_id', 'quiz_type', 'status'], 'idiom_rounds_room_type_status_index');
$table->index(['room_id', 'quiz_type', 'id'], 'idiom_rounds_room_type_id_index');
});
}
/**
* 方法功能:回滚猜谜活动通用结构升级。
*/
public function down(): void
{
Schema::table('idiom_game_rounds', function (Blueprint $table): void {
$table->dropIndex('idiom_rounds_room_type_status_index');
$table->dropIndex('idiom_rounds_room_type_id_index');
$table->dropColumn('quiz_type');
});
Schema::table('idioms', function (Blueprint $table): void {
$table->dropIndex('idioms_type_is_active_index');
$table->dropColumn('type');
});
}
};
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:为公共回合型游戏补充 room_id 字段
*
* 让百家乐、赛马、彩票和神秘箱子可以按房间独立开局、查询与广播。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移。
*/
public function up(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('horse_races', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('horse_races', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
}
};
+39
View File
@@ -32,6 +32,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统每隔一段时间自动开一局,玩家在倒计时内押注大/小/豹子,骰子结果决定胜负。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single', // 参与房间模式
'room_ids' => [1], // 参与房间列表
'interval_minutes' => 2, // 多少分钟开一局
'bet_window_seconds' => 60, // 每局押注窗口(秒)
'min_bet' => 100, // 最低押注金币
@@ -51,6 +53,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币转动老虎机,三列图案匹配可获得不同倍率奖励,三个7大奖全服广播。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'cost_per_spin' => 100, // 每次旋转消耗
'house_edge_percent' => 15, // 庄家边际(%
'daily_limit' => 100, // 每日最多转动次数(0=不限)
@@ -70,6 +74,8 @@ class GameConfigSeeder extends Seeder
'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'auto_drop_enabled' => false, // 是否自动定时投放
'auto_interval_hours' => 2, // 自动投放间隔(小时)
'claim_window_seconds' => 60, // 领取窗口(秒)
@@ -91,6 +97,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统定期举办赛马,用户在倒计时内下注,按注池赔率结算,跑马过程 WebSocket 实时播报。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'interval_minutes' => 30, // 多少分钟一场
'bet_window_seconds' => 90, // 押注窗口(秒)
'race_duration' => 30, // 跑马动画时长(秒)
@@ -110,6 +118,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一次免费占卜,系统生成玄学签文并赋予当日加成效果(幸运/倒霉)。额外占卜消耗金币。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'free_count_per_day' => 1, // 每日免费次数
'extra_cost' => 500, // 额外次数消耗金币
'buff_duration_hours' => 24, // 加成效果持续时间
@@ -128,6 +138,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币抛竿,等待浮漂下沉后点击收竿,随机获得奖励或惩罚。持有自动钓鱼卡可自动循环。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5, // 每次抛竿消耗金币
'fishing_wait_min' => 8, // 浮漂等待最短秒数
'fishing_wait_max' => 15, // 浮漂等待最长秒数
@@ -143,6 +155,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一期,选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
// ── 开奖时间 ──
'draw_hour' => 20, // 每天几点开奖(24小时制)
'draw_minute' => 0, // 几分开奖
@@ -169,6 +183,31 @@ class GameConfigSeeder extends Seeder
'super_issue_inject' => 20000, // 超级期系统注入金额上限
],
],
// ─── 五子棋 ───────────────────────────────────────────────
[
'game_key' => 'gomoku',
'name' => '五子棋',
'icon' => '♟️',
'description' => 'PvP 对战/人机对战,房间内随时发起邀请,超时或认输均自动结算。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'pvp_reward' => 200, // PvP 胜利奖励金币
'pvp_invite_timeout' => 30, // PvP 邀请超时(秒)
'pvp_move_timeout' => 45, // 每步落子超时(秒)
'pvp_ready_timeout' => 20, // 对局准备超时(秒)
'pve_fee_level_1' => 50, // AI简单 入场费
'pve_reward_level_1' => 100, // AI简单 胜利奖励
'pve_fee_level_2' => 100, // AI普通 入场费
'pve_reward_level_2' => 200, // AI普通 胜利奖励
'pve_fee_level_3' => 200, // AI困难 入场费
'pve_reward_level_3' => 400, // AI困难 胜利奖励
'pve_fee_level_4' => 500, // AI专家 入场费
'pve_reward_level_4' => 1000, // AI专家 胜利奖励
],
],
];
foreach ($games as $game) {
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* 文件功能:猜谜活动旧 Seeder 兼容入口
*
* 兼容仍然使用 `IdiomSeeder` 名称的旧命令与旧文档,
* 实际执行逻辑委托给新的 RiddleSeeder。
*/
namespace Database\Seeders;
/**
* 类功能:兼容旧的 IdiomSeeder 调用入口。
*/
class IdiomSeeder extends RiddleSeeder
{
/**
* 方法功能:复用新的猜谜活动题库填充逻辑。
*/
public function run(): void
{
parent::run();
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
/**
* 文件功能:猜谜活动题库填充器
*
* 初始化猜谜活动配置、成语题库与脑筋急转弯题库数据。
* 使用 updateOrCreate 确保重复执行不影响已有数据。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Database\Seeders;
use App\Models\GameConfig;
use App\Models\Riddle;
use Illuminate\Database\Seeder;
/**
* 类功能:初始化猜谜活动配置、成语题库与脑筋急转弯题库。
*/
class RiddleSeeder extends Seeder
{
/**
* 填充猜谜活动配置与题库。
*/
public function run(): void
{
// ── 游戏配置(已存在则更新为统一猜谜活动结构) ──
GameConfig::updateOrCreate(
['game_key' => 'idiom'],
[
'name' => '猜谜活动',
'icon' => '🧩',
'description' => '管理员手动出题或系统定时自动出题,支持成语题与脑筋急转弯题,第一个答对的用户获得金币和经验奖励。',
'enabled' => false,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [1],
'type_configs' => [
Riddle::TYPE_IDIOM => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_mode' => 'single',
'room_ids' => [1],
],
Riddle::TYPE_BRAIN_TEASER => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_mode' => 'single',
'room_ids' => [1],
],
],
],
],
);
// ── 题库数据 ──
$idioms = [
['answer' => '画蛇添足', 'hint' => '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语'],
['answer' => '守株待兔', 'hint' => '🧩 农夫不干活,天天蹲树桩旁等天上掉馅饼。猜一成语'],
['answer' => '掩耳盗铃', 'hint' => '🧩 小偷以为捂住自己耳朵,别人就听不见铃铛响了。猜一成语'],
['answer' => '亡羊补牢', 'hint' => '🧩 羊圈破了个洞,羊跑了几只才想起修。猜一成语'],
['answer' => '刻舟求剑', 'hint' => '🧩 船上做了个记号就能在江里找回剑?猜一成语'],
['answer' => '叶公好龙', 'hint' => '🧩 家里到处画龙雕龙,真龙来了却吓得屁滚尿流。猜一成语'],
['answer' => '狐假虎威', 'hint' => '🧩 狐狸走在老虎前面,小动物们到底怕谁?猜一成语'],
['answer' => '井底之蛙', 'hint' => '🧩 住在井里,却以为天空只有井口那么大。猜一成语'],
['answer' => '对牛弹琴', 'hint' => '🧩 弹了一首好曲子,听众却在低头吃草。猜一成语'],
['answer' => '杯弓蛇影', 'hint' => '🧩 酒杯里有个弯弯曲曲的东西在动,吓得大病一场。猜一成语'],
['answer' => '鹤立鸡群', 'hint' => '🧩 一只长腿白鸟混进了院子里的小黄鸡中。猜一成语'],
['answer' => '画龙点睛', 'hint' => '🧩 最后两笔点上后,墙上的龙竟然飞走了。猜一成语'],
['answer' => '鸡飞蛋打', 'hint' => '🧩 偷鸡不成,竹篮打水一场空。猜一成语'],
['answer' => '马到成功', 'hint' => '🧩 战旗一挥,马蹄刚踏出去就赢了。猜一成语'],
['answer' => '虎头蛇尾', 'hint' => '🧩 开头气势如虹,结尾却草草收场。猜一成语'],
['answer' => '龙飞凤舞', 'hint' => '🧩 王羲之喝醉了,笔下的字好像要飞起来。猜一成语'],
['answer' => '鸡犬不宁', 'hint' => '🧩 闹得天翻地覆,连院子里的小动物都不得安生。猜一成语'],
['answer' => '狼吞虎咽', 'hint' => '🧩 饿了三天的壮汉看到一碗面。猜一成语'],
['answer' => '鱼目混珠', 'hint' => '🧩 地摊上有人拿玻璃球当夜明珠卖。猜一成语'],
['answer' => '鼠目寸光', 'hint' => '🧩 只看得到眼前一寸的路,走远就迷路。猜一成语'],
['answer' => '九牛一毛', 'hint' => '🧩 亿万富翁丢了一分钱,连弯腰捡都懒得捡。猜一成语'],
['answer' => '如鱼得水', 'hint' => '🧩 刘备说:有了诸葛亮,就像什么回到了什么里?猜一成语'],
['answer' => '鸟语花香', 'hint' => '🧩 春天来了,你能听到什么、闻到什么?猜一成语'],
['answer' => '风花雪月', 'hint' => '🧩 才子佳人写的诗,看起来很美,其实没什么实际内容。猜一成语'],
['answer' => '山清水秀', 'hint' => '🧩 桂林漓江边上,你能看到什么颜色?猜一成语'],
['answer' => '水落石出', 'hint' => '🧩 水位下降后,河床下的东西藏不住了。猜一成语'],
['answer' => '火中取栗', 'hint' => '🧩 猫爪子被烫伤了,猴子却在旁边偷笑吃栗子。猜一成语'],
['answer' => '石破天惊', 'hint' => '🧩 一块石头裂开,伴随着一声巨响,所有人都惊呆了。猜一成语'],
['answer' => '翻天覆地', 'hint' => '🧩 孙悟空大闹天宫后,凌霄宝殿变成了什么样?猜一成语'],
['answer' => '开天辟地', 'hint' => '🧩 盘古拿着斧头,对着混沌用力一劈。猜一成语'],
['answer' => '惊天动地', 'hint' => '🧩 汶川大地震那天,连天上的云都在颤抖。猜一成语'],
['answer' => '花好月圆', 'hint' => '🧩 婚礼请柬上最常见的四个字祝福。猜一成语'],
['answer' => '冰清玉洁', 'hint' => '🧩 她的品格像冬天的什么和深山里的什么?猜一成语'],
['answer' => '海阔天空', 'hint' => '🧩 走出小县城,来到大都市,才发现世界有多大。猜一成语'],
['answer' => '雪中送炭', 'hint' => '🧩 大冬天你最需要什么?有人偏偏就送来了什么。猜一成语'],
['answer' => '锦上添花', 'hint' => '🧩 已经够漂亮了,还要再点缀一下。猜一成语'],
['answer' => '落井下石', 'hint' => '🧩 有人掉坑里了,你不救也就算了,还往下面扔砖头。猜一成语'],
['answer' => '纸上谈兵', 'hint' => '🧩 赵括说起兵法头头是道,上了战场却一败涂地。猜一成语'],
['answer' => '胸有成竹', 'hint' => '🧩 画家文同还没下笔,心里已经有了完整的竹子。猜一成语'],
['answer' => '一帆风顺', 'hint' => '🧩 出海前最想听到的一句祝福。猜一成语'],
['answer' => '水到渠成', 'hint' => '🧩 不用刻意挖渠,水自然会找到它的路。猜一成语'],
['answer' => '百发百中', 'hint' => '🧩 养由基站在百步之外射柳叶,箭无虚发。猜一成语'],
['answer' => '一鸣惊人', 'hint' => '🧩 齐威王三年不上朝不理政,一出手就震惊了各国。猜一成语'],
['answer' => '对答如流', 'hint' => '🧩 老师提问,他不用思考就说出答案,像江水一样不停。猜一成语'],
['answer' => '顺手牵羊', 'hint' => '🧩 路过别人家门口,看到一只羊没人看管……猜一成语'],
['answer' => '勇往直前', 'hint' => '🧩 前面是刀山火海,他眼睛都不眨一下继续走。猜一成语'],
['answer' => '百折不挠', 'hint' => '🧩 被摔倒一百次,第一百零一次依然站起来。猜一成语'],
['answer' => '持之以恒', 'hint' => '🧩 水滴不断地滴在石头上,千年后石头被滴穿了。猜一成语'],
['answer' => '知己知彼', 'hint' => '🧩 孙子兵法说:了解自己又了解对方,百战不殆。猜一成语'],
['answer' => '四面楚歌', 'hint' => '🧩 项羽被包围在垓下,四面八方都传来熟悉的歌声。猜一成语'],
['answer' => '草木皆兵', 'hint' => '🧩 淝水之战中,苻坚看到山上的草和树,都以为是敌军。猜一成语'],
['answer' => '一箭双雕', 'hint' => '🧩 长孙晟一箭射出去,两只大鸟应声落地。猜一成语'],
['answer' => '背水一战', 'hint' => '🧩 韩信把军队放在河边列阵,断了所有人的退路。猜一成语'],
['answer' => '声东击西', 'hint' => '🧩 明明要打左边,却装作全力进攻右边。猜一成语'],
['answer' => '调虎离山', 'hint' => '🧩 想占老虎的老窝,得先把老虎引出去。猜一成语'],
['answer' => '空城计', 'hint' => '🧩 诸葛亮大开城门,在城楼上弹琴,敌军反而不敢进城。猜一成语'],
['answer' => '缓兵之计', 'hint' => '🧩 打不过怎么办?先假装谈判争取时间。猜一成语'],
['answer' => '卧薪尝胆', 'hint' => '🧩 越王勾践每天睡在柴堆上,还要舔一口苦胆。猜一成语'],
['answer' => '三顾茅庐', 'hint' => '🧩 刘备为了请一个人出山,大冬天跑了三趟。猜一成语'],
['answer' => '望梅止渴', 'hint' => '🧩 曹操说前面有片梅林,士兵们嘴里都开始流口水了。猜一成语'],
['answer' => '七步成诗', 'hint' => '🧩 曹植被亲哥哥逼着在很短的时间里写诗保命。猜一成语'],
['answer' => '才高八斗', 'hint' => '🧩 谢灵运说:天下才华共一石,曹植独占八斗。猜一成语'],
['answer' => '入木三分', 'hint' => '🧩 王羲之在木板上写字,墨迹渗入木头三分深。猜一成语'],
['answer' => '一字千金', 'hint' => '🧩 吕不韦悬赏:谁能给《吕氏春秋》改动一个字,赏千金。猜一成语'],
['answer' => '一诺千金', 'hint' => '🧩 季布的一句话,比黄金千两还贵重。猜一成语'],
['answer' => '半途而废', 'hint' => '🧩 走到半山腰觉得累了,就转身下山了。猜一成语'],
['answer' => '实事求是', 'hint' => '🧩 不夸大不缩小,是什么就是什么。猜一成语'],
['answer' => '量力而行', 'hint' => '🧩 蚂蚁想搬走大象?先掂量掂量自己有几斤几两。猜一成语'],
['answer' => '自相矛盾', 'hint' => '🧩 楚国人夸自己的矛能刺穿任何盾,又夸自己的盾什么都刺不穿。猜一成语'],
['answer' => '买椟还珠', 'hint' => '🧩 花大价钱买了精美盒子,却把里面的珍珠还给了老板。猜一成语'],
['answer' => '杞人忧天', 'hint' => '🧩 有个怪人天天担心天会塌下来,地会陷下去。猜一成语'],
['answer' => '画饼充饥', 'hint' => '🧩 饿了看着墙上画的大饼,假装自己吃饱了。猜一成语'],
['answer' => '空中楼阁', 'hint' => '🧩 没有地基,没有支柱,一座房子浮在云端。猜一成语'],
['answer' => '异想天开', 'hint' => '🧩 有人想在天上种田,海里摘星星。猜一成语'],
['answer' => '千方百计', 'hint' => '🧩 为了达到目的,把所有能想到的办法都用上了。猜一成语'],
['answer' => '不计其数', 'hint' => '🧩 海边的沙子有多少?天上的星星有多少?猜一成语'],
['answer' => '成千上万', 'hint' => '🧩 体育场里坐满了人,放眼望去黑压压一片。猜一成语'],
['answer' => '独一无二', 'hint' => '🧩 世界上没有第二个一模一样的你。猜一成语'],
['answer' => '举世闻名', 'hint' => '🧩 地球上有几个人,就有几个人知道他。猜一成语'],
['answer' => '名不虚传', 'hint' => '🧩 听说很好吃,亲自尝了一口,果然名不虚传。不对,我说的就是~猜一成语'],
['answer' => '名副其实', 'hint' => '🧩 大家都叫他「神算子」,他算卦确实从没错过。猜一成语'],
['answer' => '引人入胜', 'hint' => '🧩 这本书太好看了,一翻开就停不下来,像被吸进去了一样。猜一成语'],
['answer' => '身临其境', 'hint' => '🧩 VR眼镜里的世界太真实了,好像自己真的在里面。猜一成语'],
['answer' => '迫不及待', 'hint' => '🧩 快递到了,鞋都没穿就跑下楼去拿。猜一成语'],
['answer' => '争先恐后', 'hint' => '🧩 超市大减价,大门一开所有人都在往前冲。猜一成语'],
['answer' => '如履薄冰', 'hint' => '🧩 每一步都小心翼翼,生怕脚下突然裂开。猜一成语'],
['answer' => '小心翼翼', 'hint' => '🧩 手里捧着一个装满水的气球,大气都不敢喘。猜一成语'],
['answer' => '心花怒放', 'hint' => '🧩 听到被录取的消息,心里像有千万朵花同时绽放。猜一成语'],
['answer' => '欢天喜地', 'hint' => '🧩 过年了,小孩拿到压岁钱在大街上又蹦又跳。猜一成语'],
['answer' => '眉开眼笑', 'hint' => '🧩 嘴角上扬,眼角弯弯,整张脸都在表达开心。猜一成语'],
['answer' => '手舞足蹈', 'hint' => '🧩 听到最喜欢的歌,身体不由自主地跟着节奏动起来。猜一成语'],
['answer' => '兴高采烈', 'hint' => '🧩 中彩票后,他整个人像打了鸡血一样。猜一成语'],
['answer' => '得意忘形', 'hint' => '🧩 考了第一名就开始翘尾巴,连走路姿势都不一样了。猜一成语'],
['answer' => '怒气冲天', 'hint' => '🧩 他气得头顶冒烟,火焰快要烧到天花板了。猜一成语'],
['answer' => '火冒三丈', 'hint' => '🧩 听到这个消息,他脑袋上的火苗蹿得比房子还高。猜一成语'],
['answer' => '心急如焚', 'hint' => '🧩 等结果的那几分钟,心脏像放在火上烤。猜一成语'],
['answer' => '愁眉苦脸', 'hint' => '🧩 眉头拧成麻花,嘴角向下弯,整张脸写满了不开心。猜一成语'],
['answer' => '泪如雨下', 'hint' => '🧩 他哭得比外面的倾盆大雨还要猛。猜一成语'],
['answer' => '目瞪口呆', 'hint' => '🧩 看到 UFO 从头顶飞过,他张大了嘴巴一句话也说不出来。猜一成语'],
['answer' => '惊弓之鸟', 'hint' => '🧩 被弓箭射过一次的鸟,听到弓弦声就吓得乱飞。猜一成语'],
['answer' => '千钧一发', 'hint' => '🧩 一万斤的重物吊在一根头发丝上,随时会断。猜一成语'],
['answer' => '愚公移山', 'hint' => '🧩 九十岁老头发誓要搬走门口的两座大山,子子孙孙无穷匮也。猜一成语'],
];
foreach ($idioms as $index => $idiom) {
Riddle::updateOrCreate(
[
'type' => Riddle::TYPE_IDIOM,
'answer' => $idiom['answer'],
],
[
'hint' => $idiom['hint'],
'is_active' => true,
'sort' => $index + 1,
],
);
}
// 新增脑筋急转弯题库,供猜谜活动切换题型时直接使用。
$brainTeasers = [
['answer' => '影子', 'hint' => '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?'],
['answer' => '回声', 'hint' => '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?'],
['answer' => '镜子', 'hint' => '🧠 你哭它也哭,你笑它也笑,但它永远不会先动,是什么?'],
['answer' => '口罩', 'hint' => '🧠 戴在脸上不是面具,遮住口鼻保健康,是什么?'],
['answer' => '手套', 'hint' => '🧠 五个小兄弟住两套房,冬天最爱穿,是什么?'],
['answer' => '袜子', 'hint' => '🧠 一对好朋友,天天躲鞋里,是什么?'],
['answer' => '鞋带', 'hint' => '🧠 两条细长蛇,天天趴鞋上,不打结鞋就跑,是什么?'],
['answer' => '雨伞', 'hint' => '🧠 下雨天开花,晴天就收起,是什么?'],
['answer' => '帽子', 'hint' => '🧠 不长头发却总爱站在头顶,是什么?'],
['answer' => '围巾', 'hint' => '🧠 冬天挂脖子,既不是项链也不是绳子,是什么?'],
['answer' => '口红', 'hint' => '🧠 不是彩笔,却常在嘴上画颜色,是什么?'],
['answer' => '牙刷', 'hint' => '🧠 头上长毛,天天进嘴里干活,是什么?'],
['answer' => '牙膏', 'hint' => '🧠 白白一条小胖虫,挤出来给牙刷帮忙,是什么?'],
['answer' => '肥皂', 'hint' => '🧠 越洗越瘦,越搓泡泡越多,是什么?'],
['answer' => '毛巾', 'hint' => '🧠 洗完脸后最爱找它抱一抱,是什么?'],
['answer' => '梳子', 'hint' => '🧠 一排小牙齿,不吃饭,只理头发,是什么?'],
['answer' => '吹风机', 'hint' => '🧠 会吹热风的小机器,洗完头总请它帮忙,是什么?'],
['answer' => '指甲刀', 'hint' => '🧠 身子很小嘴巴很硬,专门啃手指头,是什么?'],
['answer' => '钥匙', 'hint' => '🧠 个子不大本事大,能把锁头嘴巴打开,是什么?'],
['answer' => '锁', 'hint' => '🧠 一张铁嘴不吃饭,钥匙一来才张口,是什么?'],
['answer' => '门铃', 'hint' => '🧠 客人到门口,不敲门先叫唤,是什么?'],
['answer' => '电梯', 'hint' => '🧠 关上门就上下跑,不是汽车不上路,是什么?'],
['answer' => '楼梯', 'hint' => '🧠 一节一节往上走,不会动却能送人上楼,是什么?'],
['answer' => '窗户', 'hint' => '🧠 墙上开个洞,白天爱看风景,晚上爱看月亮,是什么?'],
['answer' => '窗帘', 'hint' => '🧠 白天拉开,晚上关上,帮房间遮眼睛,是什么?'],
['answer' => '镜框', 'hint' => '🧠 不会照人,却总抱着照片或镜子,是什么?'],
['answer' => '桌子', 'hint' => '🧠 四条腿不会走,肚皮平平能放东西,是什么?'],
['answer' => '椅子', 'hint' => '🧠 有脚不走路,专门让人坐,是什么?'],
['answer' => '沙发', 'hint' => '🧠 胖胖软软客厅王,大家累了都爱躺,是什么?'],
['answer' => '床', 'hint' => '🧠 白天安安静静,晚上最忙,是什么?'],
['answer' => '枕头', 'hint' => '🧠 软绵绵的小山包,睡觉时总垫在头下,是什么?'],
['answer' => '被子', 'hint' => '🧠 白天叠成豆腐块,晚上张开抱住你,是什么?'],
['answer' => '闹钟', 'hint' => '🧠 不会说早安,却总把你从梦里拽出来,是什么?'],
['answer' => '日历', 'hint' => '🧠 每过一天就瘦一张,是什么?'],
['answer' => '时钟', 'hint' => '🧠 三根兄弟赛跑,一圈一圈不停歇,是什么?'],
['answer' => '手机', 'hint' => '🧠 不长嘴巴却能说话,不长耳朵却能听见,是什么?'],
['answer' => '电话', 'hint' => '🧠 两地相隔很远,也能贴耳说悄悄话,是什么?'],
['answer' => '电视', 'hint' => '🧠 小小方盒子,里面天天演大戏,是什么?'],
['answer' => '遥控器', 'hint' => '🧠 不用走过去,按按它就能让电视听话,是什么?'],
['answer' => '电脑', 'hint' => '🧠 肚子里装知识,手指一敲就干活,是什么?'],
['answer' => '键盘', 'hint' => '🧠 一排排小方块,不是钢琴也能打字,是什么?'],
['answer' => '鼠标', 'hint' => '🧠 名字像老鼠,却最怕猫,天天趴桌上,是什么?'],
['answer' => '耳机', 'hint' => '🧠 一左一右挂耳边,音乐只给你一个人听,是什么?'],
['answer' => '音箱', 'hint' => '🧠 肚里藏着喇叭,最会放大声音,是什么?'],
['answer' => '充电器', 'hint' => '🧠 手机饿了它喂饭,是什么?'],
['answer' => '电池', 'hint' => '🧠 个子小,电量大,很多机器靠它活,是什么?'],
['answer' => '电灯', 'hint' => '🧠 太阳下班它上岗,是什么?'],
['answer' => '灯泡', 'hint' => '🧠 玻璃肚里藏火苗,黑夜一亮像白天,是什么?'],
['answer' => '蜡烛', 'hint' => '🧠 有泪不会哭,有火不会叫,越烧越短,是什么?'],
['answer' => '火柴', 'hint' => '🧠 头戴红帽子,脾气特别火,一擦就冒火星,是什么?'],
['answer' => '打火机', 'hint' => '🧠 小盒子脾气爆,拇指一按就出火,是什么?'],
['answer' => '冰箱', 'hint' => '🧠 肚子大又冷,专门帮食物避暑,是什么?'],
['answer' => '空调', 'hint' => '🧠 夏天送凉风,冬天送暖风,挂在墙上最勤快,是什么?'],
['answer' => '风扇', 'hint' => '🧠 没有翅膀也会转,专给人送风,是什么?'],
['answer' => '洗衣机', 'hint' => '🧠 不长手,却特别会洗衣服,是什么?'],
['answer' => '熨斗', 'hint' => '🧠 衣服皱巴巴,它一来就服服帖帖,是什么?'],
['answer' => '微波炉', 'hint' => '🧠 剩饭剩菜进去转几圈,就又热乎了,是什么?'],
['answer' => '电饭煲', 'hint' => '🧠 白米进去,香饭出来,是什么?'],
['answer' => '锅', 'hint' => '🧠 黑脸大肚子,天天在灶台上唱歌,是什么?'],
['answer' => '筷子', 'hint' => '🧠 两个瘦兄弟,合作夹饭菜,是什么?'],
['answer' => '勺子', 'hint' => '🧠 有个圆脑袋,喝汤最拿手,是什么?'],
['answer' => '碗', 'hint' => '🧠 圆圆小肚皮,最爱装米饭和汤,是什么?'],
['answer' => '盘子', 'hint' => '🧠 扁扁一张脸,端菜最稳,是什么?'],
['answer' => '杯子', 'hint' => '🧠 不会说话却总装水,是什么?'],
['answer' => '水壶', 'hint' => '🧠 肚子能装水,嘴巴细细长长,是什么?'],
['answer' => '吸管', 'hint' => '🧠 不用嘴碰杯,也能把饮料送进肚,是什么?'],
['answer' => '铅笔', 'hint' => '🧠 身子细细穿木衣,肚里黑黑会写字,是什么?'],
['answer' => '橡皮', 'hint' => '🧠 不会写字专会擦,哪里写错它就上,是什么?'],
['answer' => '尺子', 'hint' => '🧠 身子直直会量长短,还能帮人画直线,是什么?'],
['answer' => '书包', 'hint' => '🧠 不会走路却天天背着书上学,是什么?'],
['answer' => '课本', 'hint' => '🧠 不会说话却肚子里全是知识,是什么?'],
['answer' => '黑板', 'hint' => '🧠 一张大黑脸,粉笔天天在上面写字,是什么?'],
['answer' => '粉笔', 'hint' => '🧠 白白瘦瘦,最爱在黑板上留下痕迹,是什么?'],
['answer' => '粉笔擦', 'hint' => '🧠 不会写字却专门抹掉黑板上的字,是什么?'],
['answer' => '书签', 'hint' => '🧠 个子小小住书里,帮你记住看到哪一页,是什么?'],
['answer' => '放大镜', 'hint' => '🧠 小东西一到它眼前,立刻显得很大,是什么?'],
['answer' => '望远镜', 'hint' => '🧠 明明站在原地,却能看见很远的东西,是什么?'],
['answer' => '相机', 'hint' => '🧠 不会画画却能把风景留住,是什么?'],
['answer' => '照片', 'hint' => '🧠 不会动不会说,却能把昨天留下来,是什么?'],
['answer' => '地图', 'hint' => '🧠 不出门也能带你认识世界,是什么?'],
['answer' => '地球仪', 'hint' => '🧠 一个蓝色大圆球,抱着全世界,是什么?'],
['answer' => '篮球', 'hint' => '🧠 穿着橙色外衣,最爱往框里钻,是什么?'],
['answer' => '足球', 'hint' => '🧠 用脚最喜欢的圆朋友,是什么?'],
['answer' => '羽毛球', 'hint' => '🧠 头圆圆,尾巴白白,飞起来像小鸟,是什么?'],
['answer' => '乒乓球', 'hint' => '🧠 白白小圆豆,桌上跳来跳去,是什么?'],
['answer' => '跳绳', 'hint' => '🧠 一根长线会转圈,小朋友最爱跳过去,是什么?'],
['answer' => '秋千', 'hint' => '🧠 坐上去前后飞,却飞不离原地,是什么?'],
['answer' => '风筝', 'hint' => '🧠 长着尾巴在天上飞,线一松就跑,是什么?'],
['answer' => '气球', 'hint' => '🧠 胖胖肚子装着气,手一松就想上天,是什么?'],
['answer' => '雪人', 'hint' => '🧠 冬天站院里,太阳一晒就瘦,是什么?'],
['answer' => '彩虹', 'hint' => '🧠 雨过天晴天上挂着七色桥,是什么?'],
['answer' => '云', 'hint' => '🧠 白天像棉花,风一吹就变样,是什么?'],
['answer' => '雾', 'hint' => '🧠 没下雨却湿漉漉,早晨最爱挡路,是什么?'],
['answer' => '霜', 'hint' => '🧠 不是雪却白在草上,太阳一出就化,是什么?'],
['answer' => '露珠', 'hint' => '🧠 清晨叶子上挂着一颗颗小珍珠,是什么?'],
['answer' => '月亮', 'hint' => '🧠 白天看不清,晚上天上挂银盘,是什么?'],
['answer' => '星星', 'hint' => '🧠 白天藏起来,晚上眨眼睛,是什么?'],
['answer' => '太阳', 'hint' => '🧠 白天值班最勤快,大家都靠它发光发热,是什么?'],
['answer' => '雷', 'hint' => '🧠 看不见摸不着,却能在天上大声打鼓,是什么?'],
['answer' => '闪电', 'hint' => '🧠 先看到一道亮鞭子,再听见轰隆隆,是什么?'],
];
foreach ($brainTeasers as $index => $brainTeaser) {
Riddle::updateOrCreate(
[
'type' => Riddle::TYPE_BRAIN_TEASER,
'answer' => $brainTeaser['answer'],
],
[
'hint' => $brainTeaser['hint'],
'is_active' => true,
'sort' => 1000 + $index + 1,
],
);
}
}
}
+75 -12
View File
@@ -9,6 +9,22 @@ NC='\033[0m'
PROJECT_ROOT="/www/wwwroot/chat.ay.lc" # <--- 确认这里的路径是否正确
export COMPOSER_ALLOW_SUPERUSER=1
SUPERVISORCTL_BIN=""
PHP_BIN=""
for candidate in /usr/bin/supervisorctl /usr/local/bin/supervisorctl /www/server/panel/pyenv/bin/supervisorctl; do
if [ -x "$candidate" ]; then
SUPERVISORCTL_BIN="$candidate"
break
fi
done
for candidate in /www/server/php/84/bin/php /usr/bin/php /usr/local/bin/php; do
if [ -x "$candidate" ]; then
PHP_BIN="$candidate"
break
fi
done
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} 🚀 Laravel 稳健更新脚本 (带严格检查) ${NC}"
@@ -16,14 +32,22 @@ echo -e "${BLUE}========================================${NC}"
cd "$PROJECT_ROOT" || { echo -e "${RED}❌ 无法进入项目目录:$PROJECT_ROOT${NC}"; exit 1; }
if [ -z "$PHP_BIN" ]; then
echo -e "${RED}❌ 未找到可用 PHP 可执行文件,请确认服务器已安装 PHP 8.4。${NC}"
exit 1
fi
PHP_VERSION=$("$PHP_BIN" -r 'echo PHP_VERSION;' 2>/dev/null)
echo -e "${BLUE}使用 PHP$PHP_BIN (版本:${PHP_VERSION:-unknown})${NC}"
# 1. Git Pull(先重置 lock 文件,避免服务器环境差异导致冲突)
echo -e "${YELLOW}[1/7] 拉取代码...${NC}"
echo -e "${YELLOW}[1/8] 拉取代码...${NC}"
git checkout -- composer.lock package-lock.json 2>/dev/null || true
git fetch origin && git pull origin master
if [ $? -ne 0 ]; then echo -e "${RED}❌ Git 失败${NC}"; exit 1; fi
# 2. Composer Install (关键检查点)
echo -e "${YELLOW}[2/7] 安装依赖 (Composer)...${NC}"
echo -e "${YELLOW}[2/8] 安装依赖 (Composer)...${NC}"
composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction
COMPOSER_EXIT_CODE=$?
@@ -58,7 +82,7 @@ composer dump-autoload --no-dev --optimize --classmap-authoritative --no-interac
if [ $? -ne 0 ]; then echo -e "${RED}❌ Composer autoload 重建失败${NC}"; exit 1; fi
# 用当前 PHP 直接加载 Laravel autoload,提前暴露 vendor 缺文件 / 权限 / autoload 缓存问题
php -d opcache.enable_cli=0 -r "require __DIR__.'/vendor/autoload.php'; echo 'Composer autoload OK'.PHP_EOL;"
"$PHP_BIN" -d opcache.enable_cli=0 -r "require __DIR__.'/vendor/autoload.php'; echo 'Composer autoload OK'.PHP_EOL;"
if [ $? -ne 0 ]; then
echo -e "${RED}========================================${NC}"
echo -e "${RED} ❌ 致命错误:PHP 无法加载 vendor/autoload.php ${NC}"
@@ -68,27 +92,66 @@ if [ $? -ne 0 ]; then
fi
# 3. 前端构建
echo -e "${YELLOW}[3/7] 前端构建 (npm run build)...${NC}"
echo -e "${YELLOW}[3/8] 前端构建 (npm run build)...${NC}"
npm run build
if [ $? -ne 0 ]; then echo -e "${RED}❌ npm run build 失败${NC}"; exit 1; fi
echo -e "${GREEN}✅ 前端资源构建完成。${NC}"
# 4. 数据库迁移
echo -e "${YELLOW}[5/7] 数据库迁移...${NC}"
php artisan migrate --force
echo -e "${YELLOW}[4/8] 数据库迁移...${NC}"
"$PHP_BIN" artisan migrate --force
# 5. 优化
echo -e "${YELLOW}[5/8] 生产环境优化...${NC}"
# 注意:optimize 命令内部已经包含了 config:cache, route:cache, event:cache,此处无须多余处理
php artisan optimize:clear && php artisan optimize && php artisan view:cache
"$PHP_BIN" artisan optimize:clear && "$PHP_BIN" artisan optimize && "$PHP_BIN" artisan view:cache
# 6. 重启 Horizon / 队列进程
echo -e "${YELLOW}[6/8] 重启 Horizon...${NC}"
php artisan horizon:terminate
"$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
if [ -n "$SUPERVISORCTL_BIN" ]; then
"$SUPERVISORCTL_BIN" restart horizon >/dev/null 2>&1 || "$SUPERVISORCTL_BIN" restart horizon:* >/dev/null 2>&1 || true
fi
# 7. 重载 PHP-FPM / opcache,避免 Web 进程继续使用旧的 autoload 缓存
echo -e "${YELLOW}[7/8] 重载 PHP-FPM...${NC}"
# 7. 重启 Reverb,确保长驻 WebSocket 进程加载最新代码和配置
echo -e "${YELLOW}[7/8] 重启 Reverb...${NC}"
REVERB_RESTARTED=0
REVERB_CAN_AUTO_RESTART=0
SUPERVISOR_REVERB_TARGET=""
# 先探测是否存在可自动拉起 Reverb 的进程管理器,避免在纯手工启动场景下执行 reverb:restart 后把聊天室停掉。
if [ -n "$SUPERVISORCTL_BIN" ]; then
if "$SUPERVISORCTL_BIN" status reverb >/dev/null 2>&1; then
SUPERVISOR_REVERB_TARGET="reverb"
REVERB_CAN_AUTO_RESTART=1
elif "$SUPERVISORCTL_BIN" status reverb:* >/dev/null 2>&1; then
SUPERVISOR_REVERB_TARGET="reverb:*"
REVERB_CAN_AUTO_RESTART=1
fi
fi
# Laravel 官方文档说明:reverb:restart 适合在 Supervisor 等进程管理器托管下使用。
if [ "$REVERB_CAN_AUTO_RESTART" -eq 1 ]; then
"$PHP_BIN" artisan reverb:restart >/dev/null 2>&1
if [ $? -eq 0 ]; then
REVERB_RESTARTED=1
echo -e "${GREEN}✅ 已执行 php artisan reverb:restart。${NC}"
fi
# 若线上通过 Supervisor 托管 reverb:start,再补一次显式 restart,尽量确保进程被拉起。
if [ -n "$SUPERVISOR_REVERB_TARGET" ]; then
"$SUPERVISORCTL_BIN" restart "$SUPERVISOR_REVERB_TARGET" >/dev/null 2>&1 || true
REVERB_RESTARTED=1
echo -e "${GREEN}✅ 已尝试重启 Supervisor 托管的 Reverb 进程。${NC}"
fi
else
echo -e "${YELLOW}⚠️ 未检测到 Reverb 进程管理器,已跳过 reverb:restart,避免把手工启动的聊天室长连接服务停掉。${NC}"
echo -e "${YELLOW}⚠️ 若当前服务器是手工执行 php artisan reverb:start,请在部署完成后手动重启该进程。${NC}"
fi
# 8. 重载 PHP-FPM / opcache,避免 Web 进程继续使用旧的 autoload 缓存
echo -e "${YELLOW}[8/8] 重载 PHP-FPM...${NC}"
PHP_FPM_RELOADED=0
if command -v systemctl >/dev/null 2>&1; then
for svc in php-fpm php-fpm-84 php-fpm-83 php-fpm-82 php84-php-fpm php83-php-fpm php82-php-fpm; do
@@ -110,8 +173,8 @@ else
echo -e "${YELLOW}⚠️ 未识别到 PHP-FPM 服务,请在面板中手动重启当前站点使用的 PHP。${NC}"
fi
# 6. 权限 (针对宝塔或Nginx+FPM环境的修正)
echo -e "${YELLOW}[8/8] 修复权限...${NC}"
# 权限修正(针对宝塔或 Nginx + FPM 环境
echo -e "${YELLOW}[后处理] 修复权限...${NC}"
# 将所有文件所属权变更为 Web 运行用户(如 www),防止 root 权限导致框架日志或缓存写入失败
chown -R www:www .
# 默认读写执行权限
+2 -2
View File
@@ -216,7 +216,7 @@ a:hover {
}
.msg-line .msg-time {
font-size: 9px;
font-size: 0.72em;
color: #999;
}
@@ -245,7 +245,7 @@ a:hover {
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
font-size: 0.9em;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
+26 -2
View File
@@ -7,6 +7,30 @@
* @version 1.0.0
*/
/*
拍一拍屏幕抖动动画
*/
@keyframes chat-shake {
0%, 100% { transform: translateX(0); }
10%, 50%, 90% { transform: translateX(-4px); }
30%, 70% { transform: translateX(4px); }
}
.chat-shake {
animation: chat-shake 0.4s ease-in-out;
}
/*
斜杠命令菜单
*/
.slash-command-menu::-webkit-scrollbar {
width: 6px;
}
.slash-command-menu::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
/* Alpine.js x-cloak:初始化完成前完全隐藏,防止弹窗闪烁 */
[x-cloak] {
display: none !important;
@@ -216,7 +240,7 @@ a:hover {
}
.msg-line .msg-time {
font-size: 9px;
font-size: 0.72em;
color: #999;
}
@@ -245,7 +269,7 @@ a:hover {
.msg-line.sys-msg {
color: #cc0000;
text-align: center;
font-size: 9pt;
font-size: 0.9em;
}
/* ── 底部输入工具栏 ─────────────────────────────── */
+18 -1
View File
@@ -216,7 +216,7 @@ import { initChatImageLightboxEvents, closeChatImageLightbox, openChatImageLight
import { bindRoomStatusControls, normalizeRoomStatus, renderRoomStatusRow, renderRoomsOnlineStatus, renderRoomsOnlineStatusToContainer, resolveRoomUrl } from "./chat-room/rooms.js";
import { bindChatRightPanelControls } from "./chat-room/right-panel.js";
import { bindChatImageUploadControl } from "./chat-room/image-upload.js";
import { applyFontSize, bindChatFontSizeControl, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js";
import { applyFontSize, bindChatFontSizeControl, CHAT_DEFAULT_FONT_SIZE, CHAT_FONT_SIZE_STORAGE_KEY, restoreChatFontSize } from "./chat-room/font-size.js";
import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js";
import { bindChatBanner } from "./chat-room/banner.js";
import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js";
@@ -288,9 +288,22 @@ import { leaveRoom, notifyExpiredLeave, saveExp, startHeartbeat, stopHeartbeat }
import { bindToolbarControls, runFeatureShortcut, runToolbarAction } from "./chat-room/toolbar.js";
import { bindChatInitialStateControls } from "./chat-room/initial-state.js";
// 拍一拍模块
import "./chat-room/pat.js";
// 猜成语游戏模块
import "./chat-room/riddle-quiz.js";
import { bindIdiomQuizControls } from "./chat-room/riddle-quiz.js";
// 斜杠命令菜单
import { bindSlashCommands, registerSlashCommand } from "./chat-room/slash-commands.js";
if (typeof window !== "undefined") {
bindInstantHoverTooltip();
// 初始化斜杠命令菜单
bindSlashCommands();
// 保留聚合入口,懒加载模块通过按需动态导入自动初始化。
window.ChatRoomTools = {
// ── 静态核心模块(直接引用) ────────────────
@@ -448,6 +461,7 @@ if (typeof window !== "undefined") {
sendToChatBot,
applyFontSize,
bindChatFontSizeControl,
CHAT_DEFAULT_FONT_SIZE,
CHAT_FONT_SIZE_STORAGE_KEY,
restoreChatFontSize,
bindChatImageUploadControl,
@@ -469,6 +483,7 @@ if (typeof window !== "undefined") {
bindWelcomeMenuControls,
toggleWelcomeMenu,
bindAdminMenuControls,
registerSlashCommand,
bindBaccaratEvents,
bindBaccaratLossCoverAdminControls,
closeAdminBaccaratLossCoverModal,
@@ -680,6 +695,7 @@ if (typeof window !== "undefined") {
window.loadFeedbackData = loadFeedbackData;
window.loadMoreFeedback = loadMoreFeedback;
window.bindFeedbackControls = bindFeedbackControls;
window.registerSlashCommand = registerSlashCommand;
// ── Alpine 组件(静态导入,Blade 中 x-data 引用时同步可用) ──
window.userCardComponent = userCardComponent;
@@ -767,4 +783,5 @@ if (typeof window !== "undefined") {
bindChatBotControls();
bindGuestbookControls();
bindFeedbackControls();
bindIdiomQuizControls();
}
+68 -8
View File
@@ -7,6 +7,8 @@ import { enqueueChatMessage } from "./message-renderer.js";
// ── 事件注册标记 ──
let chatEventsBound = false;
let chatWebSocketInitRetryTimer = null;
const GOMOKU_INVITE_BUTTON_FONT_SIZE = "0.82em";
// ── 辅助函数 ──
function csrf() {
@@ -21,9 +23,40 @@ function getState() {
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/
function initChatWebSocket() {
if (chatWebSocketInitRetryTimer) {
window.clearTimeout(chatWebSocketInitRetryTimer);
chatWebSocketInitRetryTimer = null;
}
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
return;
}
// chat.js 会在模块末尾才把 initChat 挂到 window;若这里抢先执行,稍后自动补一次初始化。
chatWebSocketInitRetryTimer = window.setTimeout(() => {
chatWebSocketInitRetryTimer = null;
initChatWebSocket();
}, 100);
}
/**
* 在 DOM 已就绪时立即执行回调,避免 Vite 模块晚于 DOMContentLoaded 执行时漏掉初始化。
*
* @param {() => void} callback 页面就绪后的回调
* @returns {void}
*/
function runWhenDomReady(callback) {
if (typeof callback !== "function") {
return;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
return;
}
callback();
}
// ── 禁言逻辑 ──
@@ -203,13 +236,13 @@ function setupGomokuInviteListener() {
const acceptBtn = isSelf
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:12px;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:all .15s;">
⤴️ 打开面板
</button>`
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
border-radius:12px; background:#336699; color:#fff; font-size:12px;
border-radius:12px; background:#336699; color:#fff; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:opacity .15s;">
⚔️ 接受挑战
</button>`;
@@ -277,8 +310,8 @@ export function bindChatEvents() {
}
chatEventsBound = true;
// WebSocket 初始化
document.addEventListener("DOMContentLoaded", initChatWebSocket);
// 页面已完成解析时立刻补做初始化,确保 Presence 连接不会因为错过 DOMContentLoaded 而丢失。
runWhenDomReady(initChatWebSocket);
// chat:here — Presence 初始用户列表
window.addEventListener("chat:here", (e) => {
@@ -370,8 +403,8 @@ export function bindChatEvents() {
window.showVipPresenceBanner(msg);
}
// 若消息携带 toast_notification 字段且当前用户是接收者,弹右下角小卡片
if (msg.toast_notification && msg.to_user === window.chatContext?.username) {
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
const t = msg.toast_notification;
window.chatToast?.show({
title: t.title || "通知",
@@ -457,8 +490,35 @@ export function bindChatEvents() {
}
});
// Echo 级监听器(延迟绑定,等待 Echo 就绪)
document.addEventListener("DOMContentLoaded", () => {
// chat:pat — 拍一拍事件
window.addEventListener("chat:pat", (e) => {
const { from_user, target_user, display_text, from_user_headface } = e.detail || {};
if (!display_text) return;
if (typeof window.appendPatMessage === "function") {
window.appendPatMessage(display_text, from_user_headface, from_user, target_user);
}
if (typeof window.triggerPatShake === "function") {
window.triggerPatShake();
}
});
// chat:idiom-started — 猜成语出题
window.addEventListener("chat:idiom-started", (e) => {
if (typeof window.handleRiddleGameStarted === "function") {
window.handleRiddleGameStarted(e);
}
});
// chat:idiom-answered — 猜成语答题结果
window.addEventListener("chat:idiom-answered", (e) => {
if (typeof window.handleRiddleGameAnswered === "function") {
window.handleRiddleGameAnswered(e);
}
});
// Echo 级监听器同样要兼容“脚本加载时页面已完成”的场景。
runWhenDomReady(() => {
setupScreenClearedListener();
setupRoomBrowserRefreshListener();
setupChangelogPublishedListener();
+1 -1
View File
@@ -1,7 +1,7 @@
// 聊天室共享运行时状态,桥接 Blade 闭包作用域与 Vite 模块。
// 所有需要跨模块共享的可变状态集中在此管理,通过 window.chatState 访问。
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "猜成语", "星海小博士", "百家乐", "跑马", "神秘箱子"];
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
export const PUBLIC_MESSAGE_NODE_LIMIT = 600;
+12
View File
@@ -177,6 +177,18 @@ async function sendMessage(e) {
const composerState = collectChatComposerState();
const { contentInput, submitBtn, content, contentRaw, selectedImage, toUser } = composerState;
// 拦截 /拍一拍 命令:使用当前选中的聊天对象
if (content && typeof window.isPatCommand === "function" && window.isPatCommand(content)) {
if (state) {
state.isSending = false;
state.sendStartedAt = 0;
}
if (typeof window.executePat === "function") {
await window.executePat();
}
return;
}
if (!content && !selectedImage) {
contentInput?.focus();
if (state) {
+15 -1
View File
@@ -44,7 +44,7 @@ function openDialog({ message, title, color, type, defaultVal, confirmText, canc
header.textContent = title;
header.style.background = color;
messageBox.textContent = message;
messageBox.innerText = message;
confirmButton.style.background = color;
confirmButton.textContent = confirmText || "确定";
cancelButton.textContent = cancelText || "取消";
@@ -239,4 +239,18 @@ export function bindGlobalDialogControls() {
window.chatDialog?._cancel?.();
}
});
// 点击遮罩层(弹窗外部)关闭弹窗
document.addEventListener("click", (event) => {
const modal = document.getElementById("global-dialog-modal");
if (!modal || modal.style.display === "none") return;
if (event.target === modal) {
// alert 模式直接隐藏,confirm/prompt 视为取消
if (currentDialogType === "alert") {
window.chatDialog?._hide?.();
} else {
window.chatDialog?._cancel?.();
}
}
});
}
+84 -6
View File
@@ -9,6 +9,9 @@ let fishToken = null;
let autoFishing = false;
let autoFishCooldownTimer = null;
let autoFishCooldownCountdown = null;
let fishingCastPending = false;
const FISHING_MESSAGE_META_FONT_SIZE = "0.78em";
const FISHING_MESSAGE_BODY_FONT_SIZE = "1em";
/**
* 读取 CSRF Token。
@@ -199,6 +202,25 @@ function setFishingButton(text, disabled) {
button.disabled = disabled;
}
/**
* 判断当前是否已有进行中的钓鱼会话。
*
* 说明:
* - 手动点击抛竿后,在等待浮漂、等待点击、等待自动收竿期间都视为会话未结束。
* - 购买自动钓鱼卡后的自动接管,也必须避开这些中间态,避免重复抛竿。
*
* @returns {boolean}
*/
function hasActiveFishingSession() {
return Boolean(
fishingCastPending ||
fishToken ||
fishingTimer ||
fishingReelTimeout ||
document.getElementById("fishing-bobber"),
);
}
/**
* 启动自动钓鱼冷却倒计时(基于时间戳,不受浏览器后台节流影响)。
*
@@ -206,6 +228,8 @@ function setFishingButton(text, disabled) {
* @returns {void}
*/
function startAutoFishingCooldown(cooldown) {
clearAutoFishingTimers();
const endTime = Date.now() + cooldown * 1000;
setFishingButton(`⏳ 冷却 ${cooldown}s`, true);
showAutoFishStopButton(cooldown);
@@ -214,6 +238,7 @@ function startAutoFishingCooldown(cooldown) {
autoFishCooldownCountdown = window.setInterval(() => {
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
setFishingButton(`⏳ 冷却 ${remaining}s`, true);
updateAutoFishStopButtonCountdown(remaining);
if (remaining <= 0) {
window.clearInterval(autoFishCooldownCountdown);
@@ -226,10 +251,13 @@ function startAutoFishingCooldown(cooldown) {
const checkEnd = () => {
if (Date.now() >= endTime) {
autoFishCooldownTimer = null;
hideAutoFishStopButton();
if (autoFishing) {
updateAutoFishStopButtonCountdown(0, "正在继续自动钓鱼");
void startFishing();
return;
}
hideAutoFishStopButton();
return;
}
autoFishCooldownTimer = window.setTimeout(checkEnd, 200);
@@ -244,7 +272,9 @@ function startAutoFishingCooldown(cooldown) {
* @returns {void}
*/
function showAutoFishStopButton(cooldown) {
if (document.getElementById("auto-fish-stop-btn")) {
const currentButton = document.getElementById("auto-fish-stop-btn");
if (currentButton) {
updateAutoFishStopButtonCountdown(cooldown);
return;
}
@@ -272,6 +302,23 @@ function showAutoFishStopButton(cooldown) {
document.body.appendChild(button);
}
/**
* 同步更新停止自动钓鱼浮层上的冷却秒数,避免与主按钮倒计时不一致。
*
* @param {number} cooldown
* @param {string|null} text
* @returns {void}
*/
function updateAutoFishStopButtonCountdown(cooldown, text = null) {
const hint = document.querySelector("#auto-fish-stop-btn .drag-hint");
if (!(hint instanceof HTMLElement)) {
return;
}
hint.textContent = text || `冷却 ${Number(cooldown) || 0}s · 可拖动`;
}
/**
* 给停止自动钓鱼按钮绑定拖拽和点击停止事件。
*
@@ -363,6 +410,11 @@ function hideAutoFishStopButton() {
* @returns {Promise<void>}
*/
export async function startFishing() {
if (hasActiveFishingSession()) {
return;
}
fishingCastPending = true;
setFishingButton("🎣 抛竿中...", true);
try {
@@ -376,11 +428,25 @@ export async function startFishing() {
const data = await response.json();
if (!response.ok || data.status !== "success") {
if (autoFishing && response.status === 429) {
// 服务端冷却 TTL 可能与前端倒计时有毫秒级误差,自动模式下按服务端剩余时间续等。
startAutoFishingCooldown(Math.max(1, Number(data.cooldown) || 1));
return;
}
if (autoFishing && response.status === 409) {
// 多标签页或重复自动抛竿时,后端会保留先到的 token,当前页等待后再接管。
appendFishingMessage(`<span style="color:#d97706;">【钓鱼】${escapeHtml(data.message || "已有钓鱼正在进行,稍后自动重试。")}</span><span class="msg-time">(${timeText()})</span>`);
startAutoFishingCooldown(Math.max(1, Number(data.retry_after) || 5));
return;
}
window.chatDialog?.alert?.(data.message || "钓鱼失败", "操作失败", "#cc4444");
setFishingButton("🎣 钓鱼", false);
return;
}
// 抛竿成功后进入正式钓鱼会话,由 token / timer 接管后续状态。
fishToken = data.token;
autoFishing = Boolean(data.auto_fishing);
appendFishingMessage(`<span style="color:#2563eb;font-weight:bold;">🎣【钓鱼】</span>${escapeHtml(data.message)}<span class="msg-time">(${timeText()})</span>`);
@@ -389,12 +455,21 @@ export async function startFishing() {
const bobber = createBobber(data.bobber_x, data.bobber_y);
document.body.appendChild(bobber);
if (data.auto_fishing) {
showAutoFishStopButton(0);
updateAutoFishStopButtonCountdown(0, "等待鱼儿上钩 · 可拖动");
}
fishingTimer = window.setTimeout(() => {
// 等待计时器触发后必须立即释放句柄,否则自动钓鱼冷却结束会误判仍有会话进行中。
fishingTimer = null;
bobber.classList.add("sinking");
bobber.textContent = "🐟";
if (data.auto_fishing) {
appendFishingMessage(`<span style="color:#7c3aed;font-weight:bold;">🎣 自动钓鱼卡生效!自动收竿中... <span style="font-size:10px;opacity:0.7">(剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)</span></span>`);
showAutoFishStopButton(0);
updateAutoFishStopButtonCountdown(0, "自动收竿中 · 可拖动");
appendFishingMessage(`<span style="color:#7c3aed;font-weight:bold;">🎣 自动钓鱼卡生效!自动收竿中... <span style="font-size:${FISHING_MESSAGE_META_FONT_SIZE};opacity:0.7">(剩余${Number(data.auto_fishing_minutes_left) || 0}分钟)</span></span>`);
fishingReelTimeout = window.setTimeout(() => {
removeBobber();
void reelFish();
@@ -402,7 +477,7 @@ export async function startFishing() {
return;
}
appendFishingMessage('<span style="color:#d97706;font-weight:bold;font-size:14px;">🐟 鱼上钩了!快点击屏幕上的浮漂!</span>');
appendFishingMessage(`<span style="color:#d97706;font-weight:bold;font-size:${FISHING_MESSAGE_BODY_FONT_SIZE};">🐟 鱼上钩了!快点击屏幕上的浮漂!</span>`);
setFishingButton("🎣 点击浮漂!", true);
bobber.addEventListener("click", () => {
removeBobber();
@@ -426,6 +501,8 @@ export async function startFishing() {
window.chatDialog?.alert?.(`网络错误:${error.message}`, "网络异常", "#cc4444");
removeBobber();
setFishingButton("🎣 钓鱼", false);
} finally {
fishingCastPending = false;
}
}
@@ -462,7 +539,7 @@ export async function reelFish() {
const color = Number(result.exp || 0) >= 0 ? "#16a34a" : "#dc2626";
appendFishingMessage(
`<span style="color:${color};font-weight:bold;">${escapeHtml(result.emoji || "🎣")}【钓鱼结果】</span>${escapeHtml(result.message || "")}` +
` <span style="color:#666;font-size:11px;">(经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0}</span>` +
` <span style="color:#666;font-size:${FISHING_MESSAGE_META_FONT_SIZE};">(经验:${Number(data.exp_num) || 0} 金币:${Number(data.jjb) || 0}</span>` +
`<span class="msg-time">(${timeText()})</span>`,
);
@@ -547,6 +624,7 @@ function clearAutoFishingTimers() {
*/
export function resetFishingBtn() {
autoFishing = false;
fishingCastPending = false;
clearAutoFishingTimers();
hideAutoFishStopButton();
@@ -573,7 +651,7 @@ export function checkAndAutoStartFishing() {
const minutesLeft = Number(window.chatContext?.autoFishingMinutesLeft || 0);
const initialCooldown = Number(window.chatContext?.fishingCooldownSeconds || 0);
if (minutesLeft <= 0 || autoFishing) {
if (minutesLeft <= 0 || autoFishing || hasActiveFishingSession()) {
return;
}
+72 -8
View File
@@ -1,17 +1,70 @@
// 聊天室字号偏好控制,保留旧的 localStorage key 以兼容已有用户设置。
export const CHAT_FONT_SIZE_STORAGE_KEY = "chat_font_size";
export const CHAT_DEFAULT_FONT_SIZE = 13;
export const CHAT_FONT_SIZE_MIN = 10;
export const CHAT_FONT_SIZE_MAX = 30;
let fontSizeEventsBound = false;
/**
* 应用字号到聊天消息窗口,并保存到 localStorage
* 规整聊天室字号,过滤非法或越界的旧缓存值
*
* @param {unknown} size 字号大小
* @returns {number|null}
*/
export function normalizeChatFontSize(size) {
const px = Number.parseInt(String(size ?? ""), 10);
if (Number.isNaN(px) || px < CHAT_FONT_SIZE_MIN || px > CHAT_FONT_SIZE_MAX) {
return null;
}
return px;
}
/**
* 同步底部输入框上方工具按钮字号。
*
* @param {number} px 用户选择的聊天字号
* @returns {void}
*/
function applyInputToolbarFontSize(px) {
const toolbarRow = document.querySelector("#chat-form > .input-row");
if (!(toolbarRow instanceof HTMLElement)) {
return;
}
const toolbarFontSize = Math.max(1, px - 1);
const fontSize = `${toolbarFontSize}px`;
toolbarRow.style.fontSize = fontSize;
toolbarRow.style.fontFamily = "inherit";
toolbarRow.style.lineHeight = "1.2";
toolbarRow.querySelectorAll([
":scope > label",
":scope > label select",
":scope > label input",
":scope > button",
":scope > div > button",
"#feature-menu button",
"#admin-menu button",
].join(",")).forEach((control) => {
control.style.fontFamily = "inherit";
control.style.fontSize = "inherit";
control.style.lineHeight = "1.2";
control.style.fontWeight = "400";
});
}
/**
* 应用字号到聊天消息窗口和输入栏工具按钮,并保存到 localStorage。
*
* @param {string|number} size 字号大小
* @param {{syncContext?:boolean}} options 同步选项
* @returns {boolean}
*/
export function applyFontSize(size) {
const px = Number.parseInt(size, 10);
if (Number.isNaN(px) || px < 10 || px > 30) {
export function applyFontSize(size, options = {}) {
const px = normalizeChatFontSize(size);
if (px === null) {
return false;
}
@@ -23,8 +76,15 @@ export function applyFontSize(size) {
if (privateContainer) {
privateContainer.style.fontSize = `${px}px`;
}
applyInputToolbarFontSize(px);
localStorage.setItem(CHAT_FONT_SIZE_STORAGE_KEY, String(px));
if (options.syncContext !== false && window.chatContext && typeof window.chatContext === "object") {
window.chatContext.chatPreferences = {
...(window.chatContext.chatPreferences || {}),
font_size: px,
};
}
const selector = document.getElementById("font_size_select");
if (selector) {
@@ -35,14 +95,16 @@ export function applyFontSize(size) {
}
/**
* 从 localStorage 恢复已保存的聊天室字号。
* 从账号偏好或 localStorage 恢复已保存的聊天室字号。
*
* @returns {boolean}
*/
export function restoreChatFontSize() {
const saved = localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY);
const serverFontSize = normalizeChatFontSize(window.chatContext?.chatPreferences?.font_size);
const localFontSize = normalizeChatFontSize(localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY));
const saved = serverFontSize ?? localFontSize ?? CHAT_DEFAULT_FONT_SIZE;
return saved ? applyFontSize(saved) : false;
return applyFontSize(saved, { syncContext: serverFontSize !== null });
}
/**
@@ -61,6 +123,8 @@ export function bindChatFontSizeControl() {
return;
}
applyFontSize(event.target.value);
if (applyFontSize(event.target.value)) {
void window.saveChatPreferences?.();
}
});
}
+573 -29
View File
@@ -2,6 +2,13 @@
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import {
attachIdiomAnswerButton,
buildQuizActivityTitle,
disableIdiomAnswerButtons,
isQuizStartMessage,
normalizeQuizRoundPayload,
} from "./riddle-quiz.js";
import { isExpiredChatImageMessage } from "./message-utils.js";
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
import { escapePresenceText } from "./vip-presence.js";
@@ -16,6 +23,14 @@ import {
// ── 游戏标签判断 ──
const GAME_LABEL_PREFIXES = ["五子棋", "双色球", "钓鱼", "老虎机", "百家乐", "赛马"];
const CHAT_NOTICE_CHIP_FONT_SIZE = "0.82em";
const CHAT_NOTICE_META_FONT_SIZE = "0.72em";
const CHAT_NOTICE_BUTTON_FONT_SIZE = "0.82em";
const CHAT_NOTICE_BODY_FONT_SIZE = "1em";
const CHAT_NOTICE_ICON_FONT_SIZE = "1.08em";
const CHAT_NOTICE_LARGE_ICON_FONT_SIZE = "1.35em";
const CHAT_NOTICE_DECOR_ICON_FONT_SIZE = "4.25em";
function isGameLabel(name) {
if (GAME_LABEL_PREFIXES.some((p) => name.startsWith(p))) return true;
if (name.includes(" ")) return true;
@@ -49,6 +64,461 @@ function parseBracketUsers(content, color = "#000099") {
});
}
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildGameLabelChipHtml(label, accentColor) {
return `<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor};">${escapeHtml(label)}</span>`;
}
/**
* 判断当前是否为礼包发放公告。
*/
function isRedPacketAnnouncementMessage(msg) {
const content = String(msg?.content || "");
return String(msg?.from_user || "") === "系统公告"
&& content.includes("发出了一个")
&& content.includes("礼包")
&& content.includes("立即抢包");
}
/**
* 构建礼包发放公告的紧凑卡片,整体比例对齐猜谜活动。
*/
function buildRedPacketAnnouncementHtml(msg, timeStr) {
const rawContent = String(msg?.content || "");
const isExpPacket = rawContent.includes("经验的礼包");
const colorPalette = isExpPacket
? {
accent: "#16a34a",
text: "#166534",
softBackground: "linear-gradient(135deg,#f0fdf4,#f7fee7)",
softBorder: "rgba(22,163,74,.18)",
chipBackground: "#dcfce7",
chipBorder: "#86efac",
chipText: "#15803d",
}
: {
accent: "#dc2626",
text: "#b91c1c",
softBackground: "linear-gradient(135deg,#fef2f2,#fff7ed)",
softBorder: "rgba(220,38,38,.18)",
chipBackground: "#fee2e2",
chipBorder: "#fca5a5",
chipText: "#dc2626",
};
const accentColor = colorPalette.accent;
const typeLabel = isExpPacket ? "经验礼包" : "金币礼包";
const icon = isExpPacket ? "✨" : "🧧";
const buttonMatch = rawContent.match(/<button\b([^>]*)>([\s\S]*?)<\/button>/iu);
const buttonLabel = String(buttonMatch?.[2] || "立即抢包").trim();
const onclickMatch = String(buttonMatch?.[1] || "").match(/\bonclick=(["'])([\s\S]*?)\1/iu);
const buttonOnclick = onclickMatch ? onclickMatch[2] : "";
const textOnlyContent = rawContent
.replace(/<button\b[\s\S]*?<\/button>/giu, "")
.replace(/<\/?b>/giu, "")
.replace(/^🧧\s*/u, "")
.trim();
const summary = escapeHtml(textOnlyContent);
const actionButtonHtml = `<button type="button"${buttonOnclick ? ` onclick="${escapeHtml(buttonOnclick)}"` : ""} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor};color:#fff;font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor};cursor:pointer;box-shadow:none;vertical-align:middle;">${escapeHtml(buttonLabel)}</button>`;
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${colorPalette.softBackground};border:1px solid ${colorPalette.softBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${accentColor};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${colorPalette.softBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${colorPalette.text};">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex-shrink:0;">
${buildGameLabelChipHtml("礼包红包", accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${colorPalette.chipBackground};color:${colorPalette.chipText};font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${colorPalette.chipBorder};">${escapeHtml(typeLabel)}</span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summary}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
${actionButtonHtml}
</div>
</div>
</div>
`;
}
/**
* 构建统一的猜谜活动标题与题型标签。
*/
function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") {
const { activityLabel, typeLabel } = buildQuizActivityTitle(msg);
return `
<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;">
${buildGameLabelChipHtml(activityLabel, accentColor)}
<span style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:${accentColor}1A;color:${accentColor};font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${accentColor}33;">${escapeHtml(typeLabel)}</span>
</span>
`;
}
/**
* 判断当前公屏消息是否属于“我自己”的钓鱼结果广播。
*
* 说明:
* - 收竿后,钓鱼者本人已经会在包厢窗口收到本地结果提示;
* - 这里需要把同一条公屏广播对本人隐藏,避免自己同时看到两条。
*/
function isOwnFishingResultBroadcast(msg) {
const currentUsername = String(window.chatContext?.username || "").trim();
const fishingUsername = String(msg?.fishing_username || "").trim();
if (!currentUsername) {
return false;
}
return String(msg?.from_user || "") === "钓鱼播报"
&& String(msg?.action || "") === "fishing_result"
&& fishingUsername === currentUsername;
}
/**
* 判断当前消息是否应该使用统一的游戏通知卡片。
*/
function resolveGameNotificationCardMeta(msg) {
const normalizedContent = String(msg?.content || "");
const fromUser = String(msg?.from_user || "");
if (
normalizedContent.includes("【百家乐】")
|| (normalizedContent.includes("开局:") && normalizedContent.includes("点收割"))
|| (normalizedContent.startsWith("🎲") && normalizedContent.includes("点"))
|| (normalizedContent.includes("快速参与") && normalizedContent.includes("1:24"))
) {
return {
label: "百家乐",
icon: "🎲",
accent: "#2563eb",
background: "linear-gradient(135deg,#eff6ff,#f8fbff)",
border: "rgba(37,99,235,.16)",
text: "#1e3a8a",
chipBg: "#dbeafe",
};
}
if (
normalizedContent.includes("【赛马】")
|| normalizedContent.startsWith("🐎 开赛:")
|| normalizedContent.startsWith("🏇 比赛开始:")
|| normalizedContent.startsWith("🏆 冠军:")
|| /^🐎\s*第\s*#?\d+\s*场开赛/u.test(normalizedContent)
|| /^🏇\s*第\s*#?\d+\s*场比赛开始/u.test(normalizedContent)
|| /^🏆\s*第\s*#?\d+\s*场结束/u.test(normalizedContent)
) {
return {
label: "赛马",
icon: "🏇",
accent: "#0f766e",
background: "linear-gradient(135deg,#ecfeff,#f0fdfa)",
border: "rgba(15,118,110,.16)",
text: "#115e59",
chipBg: "#ccfbf1",
};
}
if (
normalizedContent.includes("神秘箱子")
|| (normalizedContent.includes("暗号") && normalizedContent.includes("《"))
|| normalizedContent.includes("抢到")
|| normalizedContent.includes("箱子消失")
) {
return {
label: "神秘箱子",
icon: "📦",
accent: "#7c3aed",
background: "linear-gradient(135deg,#faf5ff,#fdf4ff)",
border: "rgba(124,58,237,.16)",
text: "#6b21a8",
chipBg: "#ede9fe",
};
}
if (
normalizedContent.includes("双色球")
|| /购买\s+\d+\s*期/u.test(normalizedContent)
|| /\d+\s*期:\s*🔴/u.test(normalizedContent)
|| normalizedContent.includes("超级期")
) {
return {
label: "双色球彩票",
icon: "🎟️",
accent: "#dc2626",
background: "linear-gradient(135deg,#fef2f2,#fff7ed)",
border: "rgba(220,38,38,.16)",
text: "#991b1b",
chipBg: "#fee2e2",
};
}
if (normalizedContent.includes("【五子棋】")) {
return {
label: "五子棋",
icon: "♟️",
accent: "#475569",
background: "linear-gradient(135deg,#f8fafc,#f1f5f9)",
border: "rgba(71,85,105,.16)",
text: "#334155",
chipBg: "#e2e8f0",
};
}
if (normalizedContent.includes("老虎机")) {
return {
label: "老虎机",
icon: "🎰",
accent: "#d97706",
background: "linear-gradient(135deg,#fff7ed,#fffbeb)",
border: "rgba(217,119,6,.16)",
text: "#9a3412",
chipBg: "#fed7aa",
};
}
if (fromUser === "钓鱼播报") {
return {
label: "钓鱼",
icon: "🎣",
accent: "#059669",
background: "linear-gradient(135deg,#ecfdf5,#f0fdf4)",
border: "rgba(5,150,105,.16)",
text: "#065f46",
chipBg: "#a7f3d0",
};
}
return null;
}
/**
* 提炼系统传音卡片正文,去掉和标签重复的前缀。
*/
function extractSystemGameCardSummary(content, meta) {
const normalizedContent = String(content || "").trim();
if (!meta) {
return normalizedContent;
}
if (meta.label === "神秘箱子") {
return normalizedContent
.replace(/^[📦💎☠️]\s*/u, "")
.replace(/^【神秘箱子】/u, "")
.replace(/^开箱播报[:]\s*/u, "")
.trim();
}
if (meta.label === "百家乐") {
if (normalizedContent.includes("开局:")) {
return normalizedContent
.replace(/^[🎲]+\s*/u, "")
.replace(/【百家乐】/u, "")
.replace(/\s+/gu, " ")
.trim();
}
if (/第\s*#?\d+\s*局开奖/u.test(normalizedContent)) {
return normalizedContent
.replace(/^[🎲🎉]+\s*/u, "")
.replace(/^【百家乐】/u, "")
.replace(/\s+/gu, " ")
.trim();
}
return normalizedContent
.replace(/^[🎲🎉]+\s*/u, "")
.replace(/^【百家乐】/u, "")
.replace(/\s+/gu, " ")
.trim();
}
if (meta.label === "赛马") {
return normalizedContent
.replace(/^[🐎🏇🏆]+\s*/u, "")
.replace(/^【赛马】/u, "")
.trim();
}
if (meta.label === "双色球彩票") {
return normalizedContent
.replace(/^[🎟️🎊]+\s*/u, "")
.replace(/^【双色球[^】]*】/u, "")
.trim();
}
if (meta.label === "五子棋") {
return normalizedContent
.replace(/^[♟️🏆]+\s*/u, "")
.replace(/^【五子棋】/u, "")
.replace(/^玩家对战结果!/u, "对战结果:")
.replace(/^棋神降临!/u, "人机获胜:")
.replace(/^AI 大获全胜!/u, "AI获胜:")
.trim();
}
if (meta.label === "老虎机") {
return normalizedContent
.replace(/^[🎰🎉]+\s*/u, "")
.replace(/^【老虎机大奖】/u, "大奖:")
.trim();
}
return normalizedContent;
}
/**
* 统一系统游戏卡片中的内嵌按钮样式,避免不同游戏沿用旧尺寸。
*/
function normalizeSystemGameCardActions(content, meta) {
const normalizedContent = String(content || "");
return normalizedContent.replace(/<button\b([^>]*)>([\s\S]*?)<\/button>/giu, (_match, attributes, label) => {
const onclickMatch = String(attributes || "").match(/\bonclick=(["'])([\s\S]*?)\1/iu);
const onclickAttr = onclickMatch ? ` onclick="${escapeHtml(onclickMatch[2])}"` : "";
const safeLabel = String(label || "").trim();
return `<button type="button"${onclickAttr} style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:#fff;color:${meta.accent};font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};font-weight:700;line-height:1;border:1px solid ${meta.accent};cursor:pointer;box-shadow:none;vertical-align:middle;">${safeLabel}</button>`;
});
}
/**
* 将系统传音中的游戏通知渲染为和猜谜活动同级的紧凑卡片。
*/
function buildSystemGameNotificationHtml(msg, timeStr) {
const content = String(msg.content || "");
const meta = resolveGameNotificationCardMeta(msg);
if (!meta) {
return "";
}
const summary = normalizeSystemGameCardActions(extractSystemGameCardSummary(content, meta), meta);
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${meta.background};border:1px solid ${meta.border};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${meta.accent};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${meta.border};flex-shrink:0;">${meta.icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${meta.text};">
${buildGameLabelChipHtml(meta.label, meta.accent)}
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${parseBracketUsers(summary, meta.text)}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;font-weight:600;">(${timeStr})</span>
</div>
</div>
</div>
`;
}
/**
* 猜谜活动开题消息统一渲染为卡片。
*/
function buildQuizStartHtml(msg, timeStr) {
const quizMeta = normalizeQuizRoundPayload(msg);
const rawHint = String(quizMeta.hint || msg.content || "")
.replace(/^🧩\s*/, "")
.replace(/^📣\s*/, "")
.replace(/^【[^】]+】\s*第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
.replace(/^【[^】]+】\s*/u, "")
.replace(/^第\s*#?\d+\s*题开始!?\s*题面:\s*/u, "")
.replace(/^题面:\s*/u, "")
.trim();
const safeHint = escapeHtml(rawHint);
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:linear-gradient(135deg,#f5f3ff,#faf5ff);border:1px solid rgba(124,58,237,.16);box-shadow:0 4px 12px rgba(124,58,237,.07);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:linear-gradient(135deg,#7c3aed,#a78bfa);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px rgba(124,58,237,.16);flex-shrink:0;">🧩</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:#312e81;">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg)}</div>
<div data-quiz-inline-text style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${safeHint}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
<span data-quiz-inline-action-anchor></span>
</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:#6d28d9;font-size:${CHAT_NOTICE_META_FONT_SIZE};flex-shrink:0;margin-left:auto;">
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px rgba(124,58,237,.10);white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
</div>
</div>
`;
}
/**
* 提取猜谜活动结束消息里的正确答案,兼容中奖与超时文案。
*/
function resolveQuizResultAnswerText(msg) {
const quizMeta = normalizeQuizRoundPayload(msg);
const explicitAnswer = String(quizMeta.answer || "").trim();
if (explicitAnswer) {
return explicitAnswer;
}
const content = String(msg.content || "");
const matchedAnswer = content.match(/正确答案:(.+?)(?:|。|$)/u);
return matchedAnswer?.[1]?.trim() || content.trim();
}
/**
* 猜谜活动结束消息统一渲染为和开题通知同级的紧凑卡片。
*/
function buildQuizResultHtml(msg, timeStr) {
const quizMeta = normalizeQuizRoundPayload(msg);
const winnerUsername = String(msg.winner_username || "").trim();
const answerText = escapeHtml(resolveQuizResultAnswerText(msg));
const isAnsweredResult = winnerUsername !== "";
const accentColor = isAnsweredResult ? "#7c3aed" : "#d97706";
const accentBackground = isAnsweredResult
? "linear-gradient(135deg,#f5f3ff,#faf5ff)"
: "linear-gradient(135deg,#fff7ed,#fffbeb)";
const accentBorder = isAnsweredResult ? "rgba(124,58,237,.16)" : "rgba(217,119,6,.18)";
const textColor = isAnsweredResult ? "#312e81" : "#9a3412";
const icon = isAnsweredResult ? "🎉" : "⏳";
const iconBackground = isAnsweredResult
? "linear-gradient(135deg,#7c3aed,#a78bfa)"
: "linear-gradient(135deg,#f59e0b,#f97316)";
const badgeColor = isAnsweredResult ? "#7c3aed" : "#d97706";
const summaryHtml = isAnsweredResult
? `${clickableUser(winnerUsername, "#6d28d9")}】率先答对「${answerText}`
: `第 #${quizMeta.endedRoundId || quizMeta.roundId || 0} 题已超时结束,正确答案:${answerText}`;
return `
<div style="display:flex;align-items:center;gap:7px;padding:5px 9px;border-radius:11px;background:${accentBackground};border:1px solid ${accentBorder};box-shadow:0 4px 12px rgba(15,23,42,.045);overflow:hidden;">
<div style="width:23px;height:23px;border-radius:7px;background:${iconBackground};display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_ICON_FONT_SIZE};box-shadow:0 2px 6px ${accentBorder};flex-shrink:0;">${icon}</div>
<div style="min-width:0;flex:1;display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:${textColor};">
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex-shrink:0;">${buildQuizBadgeHtml(msg, badgeColor)}</div>
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.25;font-weight:700;min-width:200px;flex:1;">
<span>${summaryHtml}</span>
<span class="msg-time" style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
</div>
${isAnsweredResult ? `
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:${textColor};font-size:${CHAT_NOTICE_META_FONT_SIZE};flex-shrink:0;margin-left:auto;">
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px ${isAnsweredResult ? "rgba(124,58,237,.10)" : "rgba(217,119,6,.12)"};white-space:nowrap;">💰 ${quizMeta.rewardGold} 金币</span>
<span style="padding:1px 6px;border-radius:999px;background:#ffffff;box-shadow:inset 0 0 0 1px ${isAnsweredResult ? "rgba(124,58,237,.10)" : "rgba(217,119,6,.12)"};white-space:nowrap;">⭐ ${quizMeta.rewardExp} 经验</span>
</div>
` : ""}
</div>
</div>
`;
}
/**
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
*/
function prunePrivateIdiomResultMessages(targetContainer, maxRecords = 3) {
if (!targetContainer) {
return;
}
const nodes = Array.from(targetContainer.querySelectorAll('[data-idiom-result="1"]'));
while (nodes.length > maxRecords) {
const firstNode = nodes.shift();
firstNode?.remove();
}
}
/**
* 构建聊天消息的内容 HTML。
*/
@@ -84,7 +554,7 @@ export function buildChatMessageContent(msg, fontColor, textColorClass) {
return `
<span style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle;">
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:12px;">🖼️ 图片已过期</span>
<span style="display:inline-flex; align-items:center; padding:4px 8px; border:1px dashed #94a3b8; border-radius:999px; background:#f8fafc; color:#64748b; font-size:${CHAT_NOTICE_BUTTON_FONT_SIZE};">🖼️ 图片已过期</span>
${captionHtml}
</span>
`;
@@ -105,17 +575,42 @@ export function appendMessage(msg, renderBatch = null) {
state.trackMaxMsgId(msg.id || 0);
if (isOwnFishingResultBroadcast(msg)) {
return null;
}
const quizMeta = normalizeQuizRoundPayload(msg);
const idiomRoundId = quizMeta.roundId;
const isIdiomStartMessage = isQuizStartMessage(msg)
&& ["星海小博士", "系统传音"].includes(String(msg.from_user || ""));
if (isIdiomStartMessage) {
const existingIdiomNode = document.querySelector(`[data-idiom-round-id="${idiomRoundId}"]`);
if (existingIdiomNode) {
attachIdiomAnswerButton(existingIdiomNode, msg);
return existingIdiomNode;
}
}
const isMe = msg.from_user === window.chatContext?.username;
// 系统播报屏蔽只作用于公屏窗口;自己相关消息仍要进入包厢窗口,避免屏蔽误伤个人提示。
const isIdiomWinnerHistory = msg.action === "idiom_result" && msg.winner_username === window.chatContext?.username;
const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username || isIdiomWinnerHistory;
const fontColor = msg.font_color || "#000000";
const blockRuleKey = resolveBlockedSystemSenderKey(msg);
const shouldHideByBlock = blockRuleKey ? state.blockedSystemSenders.has(blockRuleKey) : false;
const shouldApplyBlockRule = Boolean(blockRuleKey && !isRelatedToMe);
const shouldHideByBlock = shouldApplyBlockRule ? state.blockedSystemSenders.has(blockRuleKey) : false;
const div = document.createElement("div");
div.className = "msg-line";
if (msg?.from_user) {
div.dataset.fromUser = msg.from_user;
}
if (blockRuleKey) {
if (idiomRoundId > 0) {
div.dataset.idiomRoundId = String(idiomRoundId);
div.dataset.quizRoundId = String(idiomRoundId);
}
if (shouldApplyBlockRule) {
div.dataset.blockKey = blockRuleKey;
}
@@ -172,6 +667,15 @@ export function appendMessage(msg, renderBatch = null) {
const iconImg = `<img src="/images/bugle.png" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src='/images/headface/1.gif'">`;
const parsedContent = parseBracketUsers(msg.content);
html = `${iconImg} ${parsedContent}`;
} else if (msg.action === "idiom_result") {
div.dataset.idiomResult = "1";
div.dataset.quizRoundEndedId = String(quizMeta.endedRoundId || quizMeta.roundId || 0);
div.dataset.quizWinnerUsername = String(msg.winner_username || "");
html = buildQuizResultHtml(msg, timeStr);
timeStrOverride = true;
} else if (isIdiomStartMessage) {
html = buildQuizStartHtml(msg, timeStr);
timeStrOverride = true;
} else if (msg.action === "vip_presence") {
const accent = msg.presence_color || "#f59e0b";
div.style.cssText =
@@ -186,16 +690,16 @@ export function appendMessage(msg, renderBatch = null) {
html = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:24px;box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg, ${accent}, #fbbf24);display:flex;align-items:center;justify-content:center;font-size:${CHAT_NOTICE_LARGE_ICON_FONT_SIZE};box-shadow: 0 4px 12px ${accent}44; flex-shrink: 0;">${icon}</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:13px;font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:13px;color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:11px;color:#94a3b8;">(${timeStr})</span>
<span style="font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};font-weight:900;letter-spacing:.05em;color:${accent}; text-shadow: 0.5px 0.5px 0px rgba(0,0,0,0.05);">${typeLabel}</span>
<span style="font-size:${CHAT_NOTICE_CHIP_FONT_SIZE};color:#475569;font-weight:bold;">${levelName}</span>
<span style="font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;">(${timeStr})</span>
</div>
<div style="margin-top:4px;font-size:15px;line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
<div style="margin-top:4px;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};line-height:1.6;color:#1e293b;font-weight:500;">${safeText}</div>
</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:60px; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
<div style="position:absolute; right:-10px; bottom:-10px; font-size:${CHAT_NOTICE_DECOR_ICON_FONT_SIZE}; opacity:0.05; transform:rotate(-15deg); pointer-events:none;">${icon}</div>
</div>
`;
timeStrOverride = true;
@@ -220,32 +724,55 @@ export function appendMessage(msg, renderBatch = null) {
}
}
const parsedBody = parseBracketUsers(bodyPart, "#1d4ed8");
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: 11px; font-weight: normal;">(${timeStr})</span></div>`;
html = `<div style="color: #1e40af;">💬 ${clickablePrefix}${parsedBody} <span style="color: #93c5fd; font-size: ${CHAT_NOTICE_META_FONT_SIZE}; font-weight: normal;">(${timeStr})</span></div>`;
timeStrOverride = true;
} else if (SYSTEM_USERS.includes(msg.from_user)) {
if (msg.from_user === "系统公告") {
div.style.cssText =
"background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);";
const parsedContent = parseBracketUsers(msg.content, "#dc2626");
html = `<div style="font-size: 18px; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: 14px; font-weight: 500;">(${timeStr})</span></div>`;
timeStrOverride = true;
if (isRedPacketAnnouncementMessage(msg)) {
html = buildRedPacketAnnouncementHtml(msg, timeStr);
timeStrOverride = true;
} else {
div.style.cssText =
"background: linear-gradient(135deg, #fef2f2, #fff1f2); border: 2px solid #ef4444; border-radius: 8px; padding: 10px 14px; margin: 6px 0; box-shadow: 0 3px 8px rgba(239,68,68,0.16);";
const parsedContent = parseBracketUsers(msg.content, "#dc2626");
html = `<div style="font-size: ${CHAT_NOTICE_BODY_FONT_SIZE}; line-height: 1.75; font-weight: 800; color: #dc2626;">${parsedContent} <span style="color: #999; font-size: ${CHAT_NOTICE_META_FONT_SIZE}; font-weight: 500;">(${timeStr})</span></div>`;
timeStrOverride = true;
}
} else if (msg.from_user === "系统传音") {
const content = msg.content || "";
const isRedPacketClaimNotification = content.includes("抢到了") && content.includes("礼包");
const isBaccaratLossCoverNotification = content.includes("【你玩游戏我买单】") || content.includes("金币补偿");
const isDailySignInNotification = content.includes("完成今日签到") || content.includes("使用补签卡补签");
const isPlainNotification =
content.includes("【百家乐】") ||
content.includes("【赛马】") ||
content.includes("神秘箱子") ||
content.includes("【双色球") ||
content.includes("【五子棋】") ||
content.includes("【老虎机】") ||
content.includes("购买了");
const isQuizEndNotification = content.includes("猜谜活动") && (content.includes("已超时结束") || content.includes("正确答案"));
const isQuizStartNotification = !isQuizEndNotification && (isIdiomStartMessage || content.includes("猜谜活动") || content.includes("猜成语时间"));
const systemGameCardMeta = resolveGameNotificationCardMeta(msg);
const isPlainNotification = content.includes("购买了");
if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
if (isQuizEndNotification) {
html = buildQuizResultHtml(msg, timeStr);
timeStrOverride = true;
} else if (isQuizStartNotification) {
div.style.cssText =
"background:linear-gradient(135deg,#fff7ed,#fffbeb);border:1px solid rgba(245,158,11,.28);border-left:4px solid #f59e0b;border-radius:12px;padding:8px 12px;margin:4px 0;box-shadow:0 10px 24px rgba(245,158,11,.14);";
html = `
<div style="display:flex;align-items:flex-start;gap:10px;">
<div style="width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#f59e0b,#f97316);display:flex;align-items:center;justify-content:center;color:#fff;font-size:${CHAT_NOTICE_LARGE_ICON_FONT_SIZE};box-shadow:0 8px 18px rgba(249,115,22,.22);flex-shrink:0;">📣</div>
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${buildQuizBadgeHtml(msg, "#d97706")}
<span class="msg-time">(${timeStr})</span>
</div>
<div style="margin-top:7px;color:#9a3412;font-size:${CHAT_NOTICE_BODY_FONT_SIZE};font-weight:800;line-height:1.75;">${parseBracketUsers(content, "#b45309")}</div>
</div>
</div>
`;
timeStrOverride = true;
} else if (isRedPacketClaimNotification || isBaccaratLossCoverNotification || isDailySignInNotification) {
let plainAccentContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${plainAccentContent}</span>`;
} else if (systemGameCardMeta) {
html = buildSystemGameNotificationHtml(msg, timeStr);
timeStrOverride = true;
} else if (isPlainNotification) {
let parsedContent = parseBracketUsers(msg.content);
html = `${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}</span><span class="msg-content${textColorClass}" style="color: ${fontColor}; font-weight: bold;">${parsedContent}</span>`;
@@ -255,6 +782,9 @@ export function appendMessage(msg, renderBatch = null) {
let sysTranContent = parseBracketUsers(msg.content);
html = `<span style="color: #b45309;">🌟 ${sysTranContent}</span>`;
}
} else if (resolveGameNotificationCardMeta(msg)) {
html = buildSystemGameNotificationHtml(msg, timeStr);
timeStrOverride = true;
} else if (msg.from_user === "系统" && msg.to_user && msg.to_user !== "大家") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;";
@@ -270,7 +800,7 @@ export function appendMessage(msg, renderBatch = null) {
} else if (msg.is_secret) {
if (msg.from_user === "系统") {
div.style.cssText =
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;font-size:12px;";
"background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px;padding:3px 8px;margin:2px 0;";
html = `<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
} else {
const fromHtml = clickableUser(msg.from_user, "#cc00cc", nameClass);
@@ -300,6 +830,12 @@ export function appendMessage(msg, renderBatch = null) {
html += ` <span class="msg-time">(${timeStr})</span>`;
}
div.innerHTML = html;
attachIdiomAnswerButton(div, msg);
// 历史消息恢复或实时结算时,都立即把对应回合按钮置为结束态,保留消息结构便于回看。
if (quizMeta.endedRoundId > 0) {
disableIdiomAnswerButtons(quizMeta.endedRoundId, "本回合已结束", String(msg.winner_username || ""));
}
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
if (shouldHideByBlock) {
@@ -324,9 +860,6 @@ export function appendMessage(msg, renderBatch = null) {
removeSameWelcome(renderBatch?.privateFragment);
}
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
const isRelatedToMe = isMe || msg.is_secret || msg.to_user === window.chatContext?.username;
// 存点通知标记
const isAutoSave = (msg.from_user === "系统" || msg.from_user === "") &&
msg.content && (msg.content.includes("自动存点") || msg.content.includes("手动存点"));
@@ -343,12 +876,18 @@ export function appendMessage(msg, renderBatch = null) {
renderBatch.privateFragment.appendChild(div);
renderBatch.shouldPrunePrivate = true;
renderBatch.shouldScrollPrivate = renderBatch.shouldScrollPrivate || state.autoScroll;
if (msg.action === "idiom_result") {
renderBatch.shouldPrunePrivateIdiomResults = true;
}
return;
}
const container2 = state.container2;
if (container2) {
container2.appendChild(div);
pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
if (msg.action === "idiom_result") {
prunePrivateIdiomResultMessages(container2, 3);
}
if (state.autoScroll) {
container2.scrollTop = container2.scrollHeight;
}
@@ -398,6 +937,7 @@ export function createChatMessageRenderBatch() {
privateFragment: document.createDocumentFragment(),
shouldPrunePublic: false,
shouldPrunePrivate: false,
shouldPrunePrivateIdiomResults: false,
shouldScrollPublic: false,
shouldScrollPrivate: false,
};
@@ -429,6 +969,10 @@ export function commitChatMessageRenderBatch(renderBatch) {
const container2 = state.container2;
if (container2) pruneMessageContainer(container2, PRIVATE_MESSAGE_NODE_LIMIT);
}
if (renderBatch.shouldPrunePrivateIdiomResults) {
const container2 = state.container2;
if (container2) prunePrivateIdiomResultMessages(container2, 3);
}
if (renderBatch.shouldScrollPublic) {
const container = state.container;
if (container) container.scrollTop = container.scrollHeight;
@@ -523,7 +1067,7 @@ export function flushQueuedChatMessages() {
const notice = document.createElement("div");
notice.className = "msg-line msg-burst-notice";
notice.style.cssText =
"text-align:center;padding:6px 0;margin:4px 0;font-size:12px;color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;";
`text-align:center;padding:6px 0;margin:4px 0;font-size:${CHAT_NOTICE_META_FONT_SIZE};color:#94a3b8;border-top:1px dashed #d1d5db;border-bottom:1px dashed #d1d5db;`;
notice.textContent = `⏫ 省略了 ${dropped} 条系统通知`;
container.appendChild(notice);
}
+158
View File
@@ -0,0 +1,158 @@
// 拍一拍功能模块
// 拦截输入框中的 /拍一拍 命令,向所选对象发送拍一拍通知并触发屏幕抖动。
import { pruneMessageContainer } from "./message-renderer.js";
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 判断输入是否为 /拍一拍 命令。
*/
function isPatCommand(text) {
return /^\/拍一拍\s*$/.test(text);
}
/**
* 获取当前选中的聊天对象。
*/
function getSelectedTarget() {
const toUserSelect = document.getElementById("to_user");
if (!toUserSelect) return null;
const val = toUserSelect.value?.trim();
return val || null;
}
/**
* 执行拍一拍请求。
*/
async function executePat() {
const targetUser = getSelectedTarget();
if (!targetUser || targetUser === "大家") {
window.chatDialog?.alert("请先选择一个聊天对象(不能为大家),再进行拍一拍。", "拍一拍", "#f472b6");
return false;
}
const roomId = window.chatContext?.roomId;
if (!roomId) return false;
try {
const response = await fetch(`/room/${roomId}/pat`, {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ target_user: targetUser }),
});
const data = await response.json();
if (data.status === "success") {
// 清空输入并触发本机抖动
const contentInput = document.getElementById("content");
if (contentInput) {
contentInput.value = "";
if (typeof window.persistChatDraft === "function") {
window.persistChatDraft("");
}
contentInput.focus();
}
triggerPatShake();
return true;
}
window.chatDialog?.alert(data.message || "拍一拍失败", "拍一拍", "#f472b6");
return false;
} catch (error) {
console.error("拍一拍请求失败:", error);
window.chatDialog?.alert("网络错误,拍一拍发送失败。", "拍一拍", "#f472b6");
return false;
}
}
/**
* 触发屏幕抖动动画。
*/
function triggerPatShake() {
const layout = document.querySelector(".chat-layout");
if (!layout) return;
layout.classList.remove("chat-shake");
// 强制回流后重新添加动画
void layout.offsetWidth;
layout.classList.add("chat-shake");
// 动画结束后移除 class
setTimeout(() => {
layout.classList.remove("chat-shake");
}, 500);
}
/**
* 添加拍一拍消息到聊天窗口(使用正常聊天样式渲染)。
*/
function appendPatMessage(displayText, fromUserHeadface, fromUser, targetUser) {
const state = window.chatState;
const container = state?.container;
if (!container) return;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const fromUserSafe = fromUser || "";
const targetUserSafe = targetUser || "";
// 获取发送者的在线数据,获取正确头像
const senderInfo = state.onlineUsers[fromUserSafe];
const senderHead = (senderInfo && senderInfo.headface) || "1.gif";
let headImgSrc = senderHead.startsWith("storage/") ? "/" + senderHead : "/images/headface/" + senderHead;
const headImg = '<img src="' + headImgSrc + '" style="display:inline;width:16px;height:16px;vertical-align:middle;margin-right:2px;mix-blend-mode: multiply;" onerror="this.src=\'/images/headface/1.gif\'">';
// 可点击用户名(与正常消息一致)
const fromHtml = '<span class="msg-user" data-chat-message-user data-u="' + fromUserSafe + '" style="color: #000099; cursor: pointer;">' + fromUserSafe + '</span>';
const toHtml = '<span class="msg-user" data-chat-message-user data-u="' + targetUserSafe + '" style="color: #000099; cursor: pointer;">' + targetUserSafe + '</span>';
const div = document.createElement("div");
div.className = "msg-line";
if (fromUserSafe) {
div.dataset.fromUser = fromUserSafe;
}
div.innerHTML = headImg + fromHtml + "对" + toHtml + "说:<span class=\"msg-content\" style=\"color: #000000\">👋 我刚拍了拍你</span> <span class=\"msg-time\">(" + timeStr + ")</span>";
// 路由规则:发送者和被拍者在包厢看到,其他用户在公屏看到
const currentUser = window.chatContext?.username || "";
const isRelatedToMe = fromUser === currentUser || targetUser === currentUser;
if (isRelatedToMe) {
const container2 = state?.container2;
if (container2) {
container2.appendChild(div);
pruneMessageContainer(container2, 300);
if (state?.autoScroll) {
container2.scrollTop = container2.scrollHeight;
}
}
} else {
container.appendChild(div);
pruneMessageContainer(container, 600);
if (state?.autoScroll) {
container.scrollTop = container.scrollHeight;
}
}
}
// ── 导出 ──
export { isPatCommand, executePat, triggerPatShake, appendPatMessage };
// 挂载到 window 供其他模块使用
window.isPatCommand = isPatCommand;
window.executePat = executePat;
window.triggerPatShake = triggerPatShake;
window.appendPatMessage = appendPatMessage;
+105 -23
View File
@@ -1,6 +1,8 @@
// 聊天室偏好与每日状态工具,承接从 Blade 内联脚本迁移出的纯数据规整逻辑。
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "星海小博士", "百家乐", "跑马", "神秘箱子"];
import { CHAT_FONT_SIZE_STORAGE_KEY, normalizeChatFontSize } from "./font-size.js";
export const BLOCKABLE_SYSTEM_SENDERS = ["钓鱼播报", "猜成语", "星海小博士", "百家乐", "跑马", "神秘箱子", "五子棋", "老虎机", "双色球彩票"];
export const BLOCKED_SYSTEM_SENDERS_STORAGE_KEY = "chat_blocked_system_senders";
export const CHAT_SOUND_MUTED_STORAGE_KEY = "chat_sound_muted";
// 白名单、localStorage key 与绑定标记共同保证偏好读取可控、事件只注册一次。
@@ -12,7 +14,7 @@ let blockMenuEventsBound = false;
*
* @param {Record<string, unknown>|null|undefined} raw
* @param {string[]} blockableSystemSenders
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
* @returns {{blocked_system_senders:string[],sound_muted:boolean,font_size:number|null}}
*/
export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE_SYSTEM_SENDERS) {
// 服务端或旧本地缓存可能包含已下架发送者,规整时只保留当前白名单。
@@ -23,6 +25,7 @@ export function normalizeChatPreferences(raw, blockableSystemSenders = BLOCKABLE
return {
blocked_system_senders: Array.from(new Set(blocked)),
sound_muted: Boolean(raw?.sound_muted),
font_size: normalizeChatFontSize(raw?.font_size),
};
}
@@ -454,17 +457,19 @@ export function bindBlockMenuControls() {
/**
* 当前登录账号没有服务端偏好时,判断是否需要迁移旧本地偏好。
*
* @param {{blocked_system_senders?:string[],sound_muted?:boolean}} serverPreferences
* @param {{blocked_system_senders?:string[],sound_muted?:boolean,font_size?:number|null}} serverPreferences
* @param {string[]} localBlockedSenders
* @param {boolean} localMuted
* @returns {boolean}
*/
export function shouldMigrateLocalChatPreferences(serverPreferences, localBlockedSenders, localMuted) {
// 只有服务端尚无偏好时才迁移旧本地设置,避免覆盖已同步的账号配置。
const localFontSize = normalizeChatFontSize(localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY));
const hasServerPreferences = (serverPreferences?.blocked_system_senders || []).length > 0
|| Boolean(serverPreferences?.sound_muted);
|| Boolean(serverPreferences?.sound_muted)
|| normalizeChatFontSize(serverPreferences?.font_size) !== null;
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted);
return !hasServerPreferences && (localBlockedSenders.length > 0 || localMuted || localFontSize !== null);
}
/**
@@ -476,6 +481,26 @@ export function shouldMigrateLocalChatPreferences(serverPreferences, localBlocke
export function resolveBlockedSystemSenderKey(msg) {
const fromUser = String(msg?.from_user || "");
const content = String(msg?.content || "");
const action = String(msg?.action || "");
const idiomRoundId = Number.parseInt(
String(msg?.quiz_round_id || msg?.idiom_game_round_id || msg?.idom_game_round_id || msg?.quiz_round_ended_id || msg?.idiom_game_round_ended_id || "0"),
10,
);
const quizType = String(msg?.quiz_type || "");
const quizTypeLabel = String(msg?.quiz_type_label || "");
const isSystemBroadcast = fromUser === "系统传音" || fromUser === "系统";
// 猜谜活动消息独立作为一个通知类型管理,不再复用“星海小博士”的屏蔽规则。
if (
idiomRoundId > 0 ||
action === "idiom_result" ||
quizType === "idiom" ||
quizTypeLabel.includes("成语") ||
(fromUser === "星海小博士" && (content.includes("猜成语") || content.includes("猜谜活动"))) ||
(isSystemBroadcast && (content.includes("猜成语") || content.includes("猜谜活动")))
) {
return "猜成语";
}
if (fromUser === "钓鱼播报") {
return "钓鱼播报";
@@ -490,22 +515,68 @@ export function resolveBlockedSystemSenderKey(msg) {
}
// 兼容旧版自动钓鱼卡购买通知:历史上该消息曾以"系统传音"发送,但正文里带有"钓鱼播报"字样。
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("钓鱼播报") || content.includes("自动钓鱼模式"))) {
if (isSystemBroadcast && (content.includes("钓鱼播报") || content.includes("自动钓鱼模式"))) {
return "钓鱼播报";
}
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("神秘箱子")) {
// 神秘箱子公告已精简为“《普通箱》...暗号...”等格式,不能再只依赖“神秘箱子”字样。
if (
isSystemBroadcast && (
content.includes("神秘箱子") ||
(content.includes("暗号") && content.includes("《")) ||
content.includes("抢到普通箱") ||
content.includes("抢到黑化箱") ||
content.includes("抢到神秘") ||
content.includes("箱子消失")
)
) {
return "神秘箱子";
}
if ((fromUser === "系统传音" || fromUser === "系统") && content.includes("百家乐")) {
// 百家乐通知已缩短为“开局:...”“🎲 9点...”这类格式,这里同步兼容新旧文案。
if (
isSystemBroadcast && (
content.includes("百家乐") ||
(content.includes("开局:") && content.includes("点收割")) ||
(content.startsWith("🎲") && content.includes("点")) ||
(content.includes("快速参与") && content.includes("1:24"))
)
) {
return "百家乐";
}
if ((fromUser === "系统传音" || fromUser === "系统") && (content.includes("赛马") || content.includes("跑马"))) {
// 赛马通知已缩短为“开赛:...”“比赛开始:...”“冠军:...”等格式。
if (
isSystemBroadcast && (
content.includes("赛马") ||
content.includes("跑马") ||
content.startsWith("🐎 开赛:") ||
content.startsWith("🏇 比赛开始:") ||
content.startsWith("🏆 冠军:")
)
) {
return "跑马";
}
if (isSystemBroadcast && (content.includes("【五子棋】") || content.includes("五子棋"))) {
return "五子棋";
}
if (isSystemBroadcast && (content.includes("老虎机") || content.includes("【老虎机大奖】"))) {
return "老虎机";
}
if (
isSystemBroadcast && (
content.includes("双色球") ||
/购买\s+\d+\s*期/u.test(content) ||
/\d+\s*期:\s*🔴/u.test(content) ||
content.includes("超级期")
)
) {
return "双色球彩票";
}
return null;
}
@@ -514,13 +585,19 @@ export function resolveBlockedSystemSenderKey(msg) {
/**
* 构建当前聊天室偏好快照。
*
* @returns {{blocked_system_senders:string[],sound_muted:boolean}}
* @returns {{blocked_system_senders:string[],sound_muted:boolean,font_size:number|null}}
*/
export function buildChatPreferencesPayload() {
const state = window.chatState;
const selector = document.getElementById("font_size_select");
const fontSize = normalizeChatFontSize(selector?.value)
?? normalizeChatFontSize(localStorage.getItem(CHAT_FONT_SIZE_STORAGE_KEY))
?? normalizeChatFontSize(window.chatContext?.chatPreferences?.font_size);
return {
blocked_system_senders: state ? Array.from(state.blockedSystemSenders) : [],
sound_muted: isSoundMuted(),
font_size: fontSize,
};
}
@@ -581,10 +658,14 @@ export function syncBlockedSystemSenderCheckboxes() {
const checkboxMap = {
"block-sender-fishing": "钓鱼播报",
"block-sender-idiom": "猜成语",
"block-sender-doctor": "星海小博士",
"block-sender-baccarat": "百家乐",
"block-sender-horse-race": "跑马",
"block-sender-mystery-box": "神秘箱子",
"block-sender-gomoku": "五子棋",
"block-sender-slot-machine": "老虎机",
"block-sender-lottery": "双色球彩票",
};
Object.entries(checkboxMap).forEach(([id, sender]) => {
@@ -603,27 +684,28 @@ export function syncBlockedSystemSenderCheckboxes() {
*/
export function setRenderedMessagesVisibilityBySender(blockKey, hidden) {
const state = window.chatState;
[state?.container, state?.container2].forEach(targetContainer => {
if (!targetContainer) return;
const targetContainer = state?.container;
if (targetContainer) {
targetContainer.querySelectorAll("[data-block-key]").forEach(node => {
if (node.dataset.blockKey === blockKey) {
if (hidden) {
node.dataset.blockHidden = "1";
node.style.display = "none";
} else if (node.dataset.blockHidden === "1") {
node.removeAttribute("data-block-hidden");
node.style.display = "";
}
if (node.dataset.blockKey !== blockKey) {
return;
}
// 屏蔽项只清理公屏已有播报,包厢窗口保留用户自己的钓鱼过程和结果提示。
if (hidden) {
node.dataset.blockHidden = "1";
node.style.display = "none";
} else if (node.dataset.blockHidden === "1") {
node.removeAttribute("data-block-hidden");
node.style.display = "";
}
});
});
}
if (!hidden && state?.autoScroll) {
const container = state.container;
const container2 = state.container2;
if (container) container.scrollTop = container.scrollHeight;
if (container2) container2.scrollTop = container2.scrollHeight;
}
}
+652
View File
@@ -0,0 +1,652 @@
// 猜谜活动前端模块
// 监听 RiddleGameStarted / RiddleGameAnswered 事件,提供答题弹窗与刷新恢复能力。
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
let currentRoundId = 0;
let currentRoomId = 0;
let currentQuizType = "idiom";
const QUIZ_TYPES = ["idiom", "brain_teaser"];
const QUIZ_INLINE_BUTTON_FONT_SIZE = "0.82em";
const QUIZ_INLINE_META_FONT_SIZE = "0.78em";
/**
* 兼容新旧字段,提取前端统一使用的猜谜活动回合信息。
*/
export function normalizeQuizRoundPayload(payload) {
const source = payload && typeof payload === "object" ? payload : {};
const quizType = String(source.quiz_type || source.idiom_type || "idiom");
const quizTypeLabel = String(source.quiz_type_label || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
const roundId = Number.parseInt(
String(source.quiz_round_id || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
10,
);
const endedRoundId = Number.parseInt(
String(source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
10,
);
const rewardGold = Number.parseInt(
String(source.quiz_reward_gold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? 0),
10,
);
const rewardExp = Number.parseInt(
String(source.quiz_reward_exp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? 0),
10,
);
const hint = String(source.quiz_hint || source.hint || source.content || "");
const answer = String(source.quiz_answer || source.idiom_answer || source.answer || "");
return {
quizType,
quizTypeLabel,
roundId: Number.isNaN(roundId) ? 0 : roundId,
endedRoundId: Number.isNaN(endedRoundId) ? 0 : endedRoundId,
rewardGold: Number.isNaN(rewardGold) ? 0 : rewardGold,
rewardExp: Number.isNaN(rewardExp) ? 0 : rewardExp,
hint,
answer,
};
}
/**
* 统一构建“猜谜活动 + 题型”展示标题。
*/
export function buildQuizActivityTitle(payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
return {
activityLabel: "猜谜活动",
typeLabel: quizMeta.quizTypeLabel || "谜题",
quizType: quizMeta.quizType,
};
}
/**
* 判断一条消息是否属于开题消息。
*/
export function isQuizStartMessage(payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
const action = String(payload?.action || "");
return quizMeta.roundId > 0 && quizMeta.endedRoundId <= 0 && !action;
}
/**
* 查找当前回合是否已经有对应的聊天室消息节点。
*/
function findIdiomRoundMessageNode(roundId) {
if (roundId <= 0) {
return null;
}
return document.querySelector(`[data-idiom-round-id="${roundId}"]`);
}
/**
* 刷新后若历史消息里缺少当前进行中的开题卡片,则主动补回一条系统传音消息。
*/
function restoreCurrentQuizMessage(roomId, payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
if (quizMeta.roundId <= 0 || !quizMeta.hint) {
return;
}
if (findIdiomRoundMessageNode(quizMeta.roundId)) {
return;
}
const { activityLabel, typeLabel } = buildQuizActivityTitle(payload);
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-start-restore-${quizMeta.roundId}`,
room_id: roomId,
from_user: "系统传音",
to_user: "大家",
content: `🧩 【${activityLabel}${typeLabel}${quizMeta.hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: quizMeta.roundId,
quiz_hint: quizMeta.hint,
quiz_reward_gold: quizMeta.rewardGold,
quiz_reward_exp: quizMeta.rewardExp,
idiom_game_round_id: quizMeta.roundId,
idiom_reward_gold: quizMeta.rewardGold,
idiom_reward_exp: quizMeta.rewardExp,
sent_at: timeStr,
});
}
/**
* 为指定回合创建统一样式的答题按钮。
*/
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp, typeLabel, quizType = "idiom") {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.idiomAnswerBtn = String(roundId);
btn.dataset.quizAnswerBtn = String(roundId);
btn.dataset.idiomHint = hint;
btn.dataset.quizHint = hint;
btn.dataset.idiomGold = String(rewardGold);
btn.dataset.quizGold = String(rewardGold);
btn.dataset.idiomExp = String(rewardExp);
btn.dataset.quizExp = String(rewardExp);
btn.dataset.quizTypeLabel = typeLabel;
btn.dataset.quizType = quizType;
btn.dataset.quizEnded = "0";
btn.textContent = "🎯 立即答题";
btn.style.cssText =
"display:inline-flex;align-items:center;gap:4px;padding:2px 9px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
`color:#fff;border:1px solid #7c3aed;border-radius:999px;font-size:${QUIZ_INLINE_BUTTON_FONT_SIZE};cursor:pointer;` +
"font-weight:700;line-height:1;vertical-align:middle;box-shadow:0 2px 6px rgba(124,58,237,.14);";
return btn;
}
/**
* 查找指定回合的所有答题按钮。
*/
function queryQuizAnswerButtons(roundId = 0) {
const selector = roundId > 0
? `[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`
: "[data-quiz-answer-btn], [data-idiom-answer-btn]";
return Array.from(document.querySelectorAll(selector));
}
/**
* 读取当前页面上该回合已渲染的结算消息,用于历史恢复时补挂答对人名字。
*/
function findQuizWinnerUsername(roundId = 0) {
if (roundId <= 0) {
return "";
}
const resultNode = document.querySelector(`[data-quiz-round-ended-id="${roundId}"]`);
return String(resultNode?.dataset?.quizWinnerUsername || "");
}
/**
* 清理指定回合的所有答题按钮。
*/
export function removeIdiomAnswerButtons(roundId = 0) {
queryQuizAnswerButtons(roundId).forEach((button) => button.remove());
}
/**
* 为结束态按钮补一个答对人标记,避免用户只看到“已结束”不知道是谁抢到了。
*/
function syncQuizWinnerLabel(button, winnerUsername = "") {
if (!(button instanceof HTMLElement)) {
return;
}
const existingLabel = button.parentElement?.querySelector(`[data-quiz-winner-label="${button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || ""}"]`);
if (!winnerUsername) {
existingLabel?.remove();
return;
}
const winnerLabel = existingLabel || document.createElement("span");
winnerLabel.dataset.quizWinnerLabel = String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0");
winnerLabel.textContent = `答对:${winnerUsername}`;
winnerLabel.style.cssText = `margin-left:6px;font-size:${QUIZ_INLINE_META_FONT_SIZE};line-height:1.2;color:#64748b;font-weight:700;white-space:nowrap;`;
if (!existingLabel) {
button.insertAdjacentElement("afterend", winnerLabel);
}
}
/**
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
*/
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
queryQuizAnswerButtons(roundId).forEach((button) => {
button.disabled = true;
button.dataset.quizEnded = "1";
button.style.background = "linear-gradient(135deg,#94a3b8,#cbd5e1)";
button.style.color = "#f8fafc";
button.style.border = "1px solid #94a3b8";
button.style.cursor = "not-allowed";
button.style.boxShadow = "none";
button.style.opacity = ".92";
button.style.padding = "2px 9px";
button.style.fontSize = QUIZ_INLINE_BUTTON_FONT_SIZE;
button.style.lineHeight = "1";
button.title = endedText;
button.textContent = "已结束";
syncQuizWinnerLabel(button, winnerUsername);
});
}
/**
* 根据当前回合状态同步按钮可点击性,避免刷新后仍显示过期入口。
*/
function syncQuizAnswerButtons(activeRoundIds) {
const activeIds = new Set((Array.isArray(activeRoundIds) ? activeRoundIds : [activeRoundIds]).filter((roundId) => roundId > 0));
queryQuizAnswerButtons().forEach((button) => {
const buttonRoundId = Number.parseInt(String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0"), 10);
if (activeIds.has(buttonRoundId)) {
button.disabled = false;
button.dataset.quizEnded = "0";
button.style.background = "linear-gradient(135deg,#7c3aed,#a78bfa)";
button.style.color = "#fff";
button.style.border = "1px solid #7c3aed";
button.style.cursor = "pointer";
button.style.boxShadow = "0 2px 6px rgba(124,58,237,.14)";
button.style.opacity = "1";
button.style.padding = "2px 9px";
button.style.fontSize = QUIZ_INLINE_BUTTON_FONT_SIZE;
button.style.lineHeight = "1";
button.title = "";
button.textContent = "🎯 立即答题";
syncQuizWinnerLabel(button, "");
return;
}
disableIdiomAnswerButtons(buttonRoundId);
});
}
/**
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
*/
export function attachIdiomAnswerButton(messageNode, message) {
if (!messageNode || !message) {
return;
}
const quizMeta = normalizeQuizRoundPayload(message);
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (roundId <= 0) {
return;
}
if (quizMeta.endedRoundId > 0) {
return;
}
if (!["星海小博士", "系统传音"].includes(String(message.from_user || ""))) {
return;
}
if (messageNode.querySelector(`[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`)) {
return;
}
const button = buildIdiomAnswerButton(roundId, quizMeta.hint, quizMeta.rewardGold, quizMeta.rewardExp, quizMeta.quizTypeLabel, quizMeta.quizType);
const inlineActionAnchor = messageNode.querySelector("[data-quiz-inline-action-anchor]");
if (inlineActionAnchor?.parentNode) {
inlineActionAnchor.parentNode.insertBefore(button, inlineActionAnchor.nextSibling);
} else {
messageNode.appendChild(button);
}
if (quizMeta.endedRoundId > 0) {
disableIdiomAnswerButtons(roundId);
return;
}
const winnerUsername = findQuizWinnerUsername(roundId);
if (winnerUsername) {
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
}
}
/**
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
*/
async function syncCurrentIdiomRound() {
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
if (roomId <= 0) {
return;
}
currentRoomId = roomId;
try {
const responses = await Promise.all(QUIZ_TYPES.map(async (quizType) => {
const response = await fetch(`/riddle-quiz/current?room_id=${roomId}&type=${encodeURIComponent(quizType)}`, {
headers: {
Accept: "application/json",
},
});
return response.json();
}));
responses.forEach((data) => {
if (data?.status === "success" && data?.data) {
restoreCurrentQuizMessage(roomId, data.data);
}
});
const activeRoundIds = responses
.map((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10))
.filter((roundId) => roundId > 0);
currentRoundId = activeRoundIds[0] || 0;
currentQuizType = responses.find((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10) === currentRoundId)?.data?.quiz_type || currentQuizType;
syncQuizAnswerButtons(activeRoundIds);
} catch (_error) {
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
}
}
/**
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
*/
function handleRiddleGameStarted(e) {
const quizMeta = normalizeQuizRoundPayload(e.detail);
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
const { roundId, hint, rewardGold, rewardExp } = quizMeta;
const message = String(e.detail?.message || "");
if (!roundId || !hint) return;
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
currentQuizType = quizMeta.quizType || "idiom";
// 线上如果 MessageSent 补消息没有到达,这里主动补一条公屏消息兜底;
// 本地或正常链路下若消息已存在,则只补挂答题按钮,避免重复渲染。
const existingMessageNode = findIdiomRoundMessageNode(roundId);
if (existingMessageNode) {
attachIdiomAnswerButton(existingMessageNode, {
from_user: "星海小博士",
content: message || `🧩 【${activityLabel}${typeLabel}${hint}`,
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: roundId,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
idiom_game_round_id: roundId,
idiom_reward_gold: rewardGold,
idiom_reward_exp: rewardExp,
});
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
return;
}
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-start-live-${roundId}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "系统传音",
to_user: "大家",
content: message || `🧩 【${activityLabel}${typeLabel}${hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: roundId,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
idiom_game_round_id: roundId,
idiom_reward_gold: rewardGold,
idiom_reward_exp: rewardExp,
sent_at: timeStr,
});
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
}
/**
* 收到猜成语结果事件。
*/
function handleRiddleGameAnswered(e) {
const quizMeta = normalizeQuizRoundPayload(e.detail);
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
const answer = quizMeta.answer;
const winnerUsername = String(e.detail?.winner_username || "");
const rewardGold = quizMeta.rewardGold;
const rewardExp = quizMeta.rewardExp;
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (!answer) return;
currentRoundId = 0;
currentQuizType = "idiom";
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
// 关闭当前用户的答题弹窗(如果开着的话)
const answerModal = document.getElementById("idiom-answer-modal");
if (answerModal && answerModal.style.display !== "none") {
answerModal.style.display = "none";
}
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-result-live-${roundId}-${Date.now()}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "系统传音",
to_user: "大家",
content: `🎉 【${winnerUsername}】率先答对${typeLabel}${answer}」,获得 ${rewardGold} 金币、${rewardExp} 经验!`,
is_secret: false,
font_color: "#16a34a",
action: "idiom_result",
winner_username: winnerUsername,
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_answer: answer,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
quiz_round_ended_id: roundId,
idiom_answer: answer,
idiom_result_reward_gold: rewardGold,
idiom_result_reward_exp: rewardExp,
idiom_game_round_ended_id: roundId,
sent_at: timeStr,
});
// ── Toast 通知(所有用户都能看到) ──
window.chatToast?.show({
title: `🧩 ${activityLabel} · ${typeLabel}`,
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
icon: "🎉",
color: "#16a34a",
duration: 6000,
});
}
/**
* 打开答题弹窗。
*/
function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel = "成语题", quizType = "idiom") {
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
currentQuizType = quizType || "idiom";
const modal = document.getElementById("idiom-answer-modal");
if (!modal) return;
const hintEl = document.getElementById("idiom-answer-hint");
const rewardEl = document.getElementById("idiom-answer-reward");
const typeEl = document.getElementById("idiom-answer-type");
if (hintEl) hintEl.textContent = hint;
if (rewardEl) rewardEl.textContent = `🎁 答对奖励:${rewardGold} 金币 + ${rewardExp} 经验`;
if (typeEl) typeEl.textContent = typeLabel;
modal.style.display = "flex";
const input = document.getElementById("idiom-answer-input");
if (input) {
input.value = "";
input.focus();
input.disabled = false;
}
const submitBtn = document.getElementById("idiom-answer-submit");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
const feedbackEl = document.getElementById("idiom-answer-feedback");
if (feedbackEl) feedbackEl.textContent = "";
}
/**
* 关闭答题弹窗。
*/
function closeIdiomAnswerModal() {
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.style.display = "none";
}
/**
* 提交答案。
*/
async function submitIdiomAnswer() {
const input = document.getElementById("idiom-answer-input");
const feedbackEl = document.getElementById("idiom-answer-feedback");
const submitBtn = document.getElementById("idiom-answer-submit");
if (!input || !feedbackEl || !submitBtn) return;
const answer = input.value.trim();
if (!answer) {
feedbackEl.textContent = "请输入答案";
feedbackEl.style.color = "#ef4444";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中...";
try {
const response = await fetch("/riddle-quiz/answer", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
round_id: currentRoundId,
answer: answer,
room_id: currentRoomId,
quiz_type: currentQuizType,
}),
});
const data = await response.json();
if (data.status === "success") {
feedbackEl.textContent = data.message || "🎉 回答正确!";
feedbackEl.style.color = "#16a34a";
input.disabled = true;
disableIdiomAnswerButtons(
currentRoundId,
"本回合已结束",
String(window.chatContext?.username || ""),
);
// 延迟关闭弹窗
setTimeout(() => {
closeIdiomAnswerModal();
}, 2000);
} else {
feedbackEl.textContent = data.message || "答案不正确";
feedbackEl.style.color = "#ef4444";
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
disableIdiomAnswerButtons(currentRoundId, data.message || "本回合已结束");
}
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
input.focus();
input.select();
}
} catch (error) {
feedbackEl.textContent = "网络错误,请稍后重试";
feedbackEl.style.color = "#ef4444";
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
}
// ── 事件绑定 ──
export function bindIdiomQuizControls() {
// 已经绑定的不再重复绑定
if (document.getElementById("idiom-answer-modal")?.dataset?.idiomBound) return;
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.dataset.idiomBound = "1";
// 关闭按钮
document.addEventListener("click", (e) => {
const closeBtn = e.target.closest("[data-idiom-answer-close]");
if (closeBtn) {
closeIdiomAnswerModal();
return;
}
// 点击遮罩层关闭
const overlay = e.target.closest("#idiom-answer-modal");
if (overlay && e.target === overlay) {
closeIdiomAnswerModal();
}
});
// 提交按钮
document.addEventListener("click", (e) => {
const submitBtn = e.target.closest("[data-idiom-answer-submit]");
if (submitBtn) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 输入框 Enter 提交
document.addEventListener("keydown", (e) => {
const input = e.target.closest("#idiom-answer-input");
if (input && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 聊天消息中的【答题】按钮点击
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-idiom-answer-btn]");
if (!btn) return;
if (btn instanceof HTMLButtonElement && (btn.disabled || btn.dataset.quizEnded === "1")) {
return;
}
const roundId = parseInt(btn.dataset.quizAnswerBtn || btn.dataset.idiomAnswerBtn || "0", 10);
const hint = btn.dataset.quizHint || btn.dataset.idiomHint || "";
const rewardGold = parseInt(btn.dataset.quizGold || btn.dataset.idiomGold || "0", 10);
const rewardExp = parseInt(btn.dataset.quizExp || btn.dataset.idiomExp || "0", 10);
const typeLabel = btn.dataset.quizTypeLabel || "成语题";
currentQuizType = btn.dataset.quizType || (typeLabel === "脑筋急转弯" ? "brain_teaser" : "idiom");
if (roundId > 0) {
openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel, currentQuizType);
}
});
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
window.setTimeout(() => {
syncCurrentIdiomRound();
}, 0);
}
// ── 挂载到 window ──
window.openIdiomAnswerModal = openIdiomAnswerModal;
window.closeIdiomAnswerModal = closeIdiomAnswerModal;
window.submitIdiomAnswer = submitIdiomAnswer;
window.handleRiddleGameStarted = handleRiddleGameStarted;
window.handleRiddleGameAnswered = handleRiddleGameAnswered;
+47 -4
View File
@@ -548,16 +548,18 @@ async function confirmAndBuyItem(item) {
}
}
// 个性装扮支持多份购买(叠加天数),弹出数量选择
if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") {
quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1;
const isDecoration = DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair";
if (isDecoration) {
const unitPrice = Number(item.price || 0);
quantity = await promptDecorationQuantity(item);
if (quantity === null || quantity === undefined) {
return;
}
}
const validityText = buildValidityText(item);
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\n\n确定购买吗?`;
const stackingHint = isDecoration ? "\n💡 已激活同款续购自动叠加天数,可多次购买" : "";
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${stackingHint}\n\n确定购买吗?`;
const confirmed = await confirmShopPurchase(confirmMessage);
if (confirmed) {
@@ -565,6 +567,47 @@ async function confirmAndBuyItem(item) {
}
}
/**
* 装饰品数量输入弹窗(参照补签卡样式)。
*
* @param {Record<string, any>} item 装饰品数据
* @returns {Promise<number|null>} 返回数量,取消返回 null
*/
async function promptDecorationQuantity(item) {
const unitPrice = Number(item.price || 0);
const validityText = buildValidityText(item);
const validHint = validityText ? `\n有效期:${validityText}` : "";
const promptPromise = window.chatDialog?.prompt(
`请输入要购买的份数(1-99份):\n单价 ${unitPrice.toLocaleString()} 金币${validHint}\n💡 已激活同款续购自动叠加天数,可多次购买`,
'1',
`购买 ${item.name}`,
'#7c3aed',
);
const inputEl = document.getElementById('global-dialog-input');
const previousInputStyle = inputEl?.getAttribute('style') || '';
if (inputEl) {
inputEl.style.minHeight = '40px';
inputEl.style.height = '40px';
inputEl.style.resize = 'none';
inputEl.style.overflow = 'hidden';
}
const rawQuantity = await promptPromise;
if (inputEl) inputEl.setAttribute('style', previousInputStyle);
if (rawQuantity === null || rawQuantity === undefined) return null;
const quantity = Number.parseInt(String(rawQuantity).trim(), 10);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
window.chatDialog?.alert('购买数量必须是 1 到 99 之间的整数。', '数量不正确', '#cc4444');
return null;
}
return quantity;
}
/**
* 通用数量输入弹窗。
*
+280
View File
@@ -0,0 +1,280 @@
// 斜杠命令菜单模块
// 输入 / 时弹出可用命令列表,支持键盘/鼠标选择,可扩展的命令注册表。
// ── 命令注册表(后续新命令只需 push 到此数组)──
const SLASH_COMMANDS = [
{
id: "pat",
name: "/拍一拍",
description: "向当前选中的聊天对象发送拍一拍,屏幕会抖动",
icon: "👋",
fill(_input) {
if (typeof window.executePat === "function") {
window.executePat();
}
},
},
{
id: "profile",
name: "/查看资料",
description: "查看当前选中对象的个人资料名片",
icon: "📋",
fill(_input) {
const toUserSelect = document.getElementById("to_user");
const target = toUserSelect?.value?.trim() || null;
if (!target || target === "大家") {
window.chatDialog?.alert("请先选择一个聊天对象,再查看资料。", "查看资料", "#6366f1");
return;
}
if (typeof window.openUserCard === "function") {
window.openUserCard(target);
}
},
},
{
id: "signin",
name: "/签到",
description: "自动完成今日签到,领取每日奖励",
icon: "✅",
fill(_input) {
if (typeof window.claimDailySignInFromModal === "function") {
window.claimDailySignInFromModal();
}
},
},
];
// ── 菜单状态 ──
let visible = false;
let selectedIndex = 0;
let currentFilter = "";
// ── 过滤 ──
function getFilteredCommands(filter) {
if (!filter || filter === "/") return SLASH_COMMANDS;
const q = filter.toLowerCase();
return SLASH_COMMANDS.filter((c) => c.id.includes(q) || c.name.includes(q));
}
// ── DOM 构建 ──
function ensureMenu() {
const input = document.getElementById("content");
const inputRow = input?.closest(".input-row");
if (!input || !inputRow) return null;
let menu = document.getElementById("slash-command-menu");
if (!menu) {
menu = document.createElement("div");
menu.id = "slash-command-menu";
menu.className = "slash-command-menu";
menu.style.cssText =
"display:none;position:absolute;bottom:100%;left:0;z-index:9999;" +
"min-width:380px;max-width:420px;max-height:200px;overflow-y:auto;" +
"background:#fff;border:1px solid #cbd5e1;border-radius:10px;" +
"box-shadow:0 6px 20px rgba(15,23,42,.18);padding:6px 0;";
inputRow.style.position = "relative";
inputRow.appendChild(menu);
}
return menu;
}
function renderMenu(filtered, filter) {
const menu = ensureMenu();
if (!menu) return;
menu.innerHTML = "";
if (filtered.length === 0) {
const empty = document.createElement("div");
empty.style.cssText =
"padding:10px 14px;color:#94a3b8;font-size:12px;text-align:center;";
empty.textContent = "没有匹配的命令";
menu.appendChild(empty);
return;
}
filtered.forEach((cmd, i) => {
const item = document.createElement("div");
item.dataset.index = String(i);
item.style.cssText =
"display:flex;align-items:center;gap:10px;padding:8px 14px;" +
"cursor:pointer;transition:background .1s;white-space:nowrap;" +
(i === selectedIndex ? "background:#eef2ff;" : "");
// 高亮匹配文字
const nameHtml = highlightMatch(cmd.name, filter);
const descHtml = cmd.description
? `<span style="font-size:11px;color:#64748b;margin-left:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${cmd.description}</span>`
: "";
item.innerHTML =
`<span style="font-size:16px;flex:none;">${cmd.icon}</span>` +
`<span style="flex:1;min-width:0;display:flex;align-items:baseline;gap:4px;">${nameHtml}${descHtml}</span>`;
item.addEventListener("mousedown", (e) => {
e.preventDefault();
selectCommand(i);
});
item.addEventListener("mouseenter", () => {
selectedIndex = i;
highlightItem(menu, i);
});
menu.appendChild(item);
});
}
function highlightMatch(text, filter) {
if (!filter || filter === "/") return text;
const idx = text.toLowerCase().indexOf(filter.toLowerCase());
if (idx === -1) return text;
return (
text.slice(0, idx) +
`<b style="color:#4f46e5;">${text.slice(idx, idx + filter.length)}</b>` +
text.slice(idx + filter.length)
);
}
function highlightItem(menu, index) {
const items = menu.querySelectorAll("[data-index]");
items.forEach((el, i) => {
el.style.background = i === index ? "#eef2ff" : "";
});
}
// ── 选择 ──
function selectCommand(index) {
const filtered = getFilteredCommands(currentFilter);
const cmd = filtered[index];
if (!cmd) return;
const input = document.getElementById("content");
if (!input) return;
if (typeof cmd.fill === "function") {
cmd.fill(input);
} else {
input.value = cmd.name;
window.persistChatDraft?.(cmd.name);
}
// 统一清除输入框中的 /
input.value = "";
window.persistChatDraft?.("");
hideMenu();
input.focus();
}
// ── 显示/隐藏 ──
function showMenu(filter) {
const menu = ensureMenu();
if (!menu) return;
currentFilter = filter;
selectedIndex = 0;
const filtered = getFilteredCommands(filter);
visible = filtered.length > 0;
renderMenu(filtered, filter);
menu.style.display = visible ? "block" : "none";
}
function hideMenu() {
const menu = document.getElementById("slash-command-menu");
if (menu) menu.style.display = "none";
visible = false;
selectedIndex = 0;
currentFilter = "";
}
// ── 事件绑定 ──
function handleInput(e) {
const input = e.target;
const val = input.value;
if (val.startsWith("/")) {
// 如果输入值已是完整命令名,不弹出菜单
const exactMatch = SLASH_COMMANDS.some(
(c) => c.name === val.trim()
);
if (!exactMatch) {
const filter = val.trim();
showMenu(filter);
return;
}
}
hideMenu();
}
function handleKeydown(e) {
if (!visible) return;
const filtered = getFilteredCommands(currentFilter);
if (filtered.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1);
highlightItem(ensureMenu(), selectedIndex);
break;
case "ArrowUp":
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
highlightItem(ensureMenu(), selectedIndex);
break;
case "Enter":
if (visible) {
e.preventDefault();
selectCommand(selectedIndex);
}
break;
case "Escape":
e.preventDefault();
hideMenu();
break;
}
}
function handleDocumentClick(e) {
if (visible) {
const menu = document.getElementById("slash-command-menu");
const input = document.getElementById("content");
if (menu && !menu.contains(e.target) && input !== e.target) {
hideMenu();
}
}
}
// ── 初始化 ──
export function bindSlashCommands() {
const input = document.getElementById("content");
if (!input) {
// 如果 DOM 未就绪,稍后重试
setTimeout(bindSlashCommands, 200);
return;
}
// 避免重复绑定
if (input.dataset.slashBound) return;
input.dataset.slashBound = "1";
input.addEventListener("input", handleInput);
input.addEventListener("keydown", handleKeydown);
document.addEventListener("click", handleDocumentClick);
}
// 允许外部扩展命令列表
export function registerSlashCommand(cmd) {
SLASH_COMMANDS.push(cmd);
}
export { SLASH_COMMANDS };
+26
View File
@@ -175,12 +175,19 @@ export function normalizeHolidayBroadcastEvent(payload = {}) {
window.normalizeHolidayBroadcastEvent = normalizeHolidayBroadcastEvent;
let chatConnectionInitialized = false;
export function initChat(roomId) {
if (chatConnectionInitialized) {
return;
}
if (!roomId) {
console.error("未提供 roomId,无法初始化 WebSocket 连接。");
return;
}
chatConnectionInitialized = true;
const userId = window.chatContext?.userId;
// 监听全局系统事件(如 AI 机器人开关)
@@ -264,6 +271,21 @@ export function initChat(roomId) {
console.log("特效播放:", e);
window.dispatchEvent(new CustomEvent("chat:effect", { detail: e }));
})
// 监听拍一拍
.listen("UserPat", (e) => {
console.log("拍一拍:", e);
window.dispatchEvent(new CustomEvent("chat:pat", { detail: e }));
})
// 监听猜成语出题
.listen("RiddleGameStarted", (e) => {
console.log("猜成语:", e);
window.dispatchEvent(new CustomEvent("chat:idiom-started", { detail: e }));
})
// 监听猜成语答题结果
.listen("RiddleGameAnswered", (e) => {
console.log("猜成语结果:", e);
window.dispatchEvent(new CustomEvent("chat:idiom-answered", { detail: e }));
})
// 监听任命公告(礼花 + 隆重弹窗)
.listen("AppointmentAnnounced", (e) => {
console.log("任命公告:", e);
@@ -405,3 +427,7 @@ export function initMarriagePrivateChannel(userId) {
// 供全局调用
window.initChat = initChat;
window.initMarriagePrivateChannel = initMarriagePrivateChannel;
if (window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
}
@@ -3,6 +3,10 @@
@section('title', '游戏管理')
@section('content')
@php
$riddleTypeOptions = \App\Models\Riddle::typeOptions();
@endphp
<div class="space-y-6">
{{-- 页头 --}}
@@ -82,7 +86,9 @@
{{-- 参数配置区域 --}}
<div class="p-5">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST"
data-game-room-form
@if ($game->game_key === 'idiom') data-idiom-config-form @endif>
@csrf
@php
@@ -91,57 +97,77 @@
$hiddenLegacyKeys = $game->game_key === 'mystery_box'
? ['min_reward', 'max_reward', 'rare_min_reward', 'rare_max_reward']
: [];
$hiddenConfigKeys = ['room_scope_mode', 'room_ids'];
$paramKeys = array_values(array_unique(array_merge(array_keys($labels), array_keys($params))));
$paramKeys = array_values(array_filter($paramKeys, fn ($key) => ! in_array($key, $hiddenLegacyKeys, true)));
$paramKeys = array_values(array_filter($paramKeys, fn ($key) => ! in_array($key, array_merge($hiddenLegacyKeys, $hiddenConfigKeys), true)));
@endphp
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@foreach ($paramKeys as $paramKey)
@php
$paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '');
@if ($game->game_key === 'idiom')
@include('admin.game-configs.partials.riddle-config-card', [
'params' => $params,
'availableRooms' => $availableRooms,
'riddleTypeOptions' => $riddleTypeOptions,
])
@else
@php
$roomScopeConfig = gameRoomScopeConfig($params);
@endphp
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
@foreach ($paramKeys as $paramKey)
@php
$paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '');
if ($game->game_key === 'mystery_box') {
$legacyFallbackMap = [
'normal_reward_min' => 'min_reward',
'normal_reward_max' => 'max_reward',
'rare_reward_min' => 'rare_min_reward',
'rare_reward_max' => 'rare_max_reward',
];
if ($game->game_key === 'mystery_box') {
$legacyFallbackMap = [
'normal_reward_min' => 'min_reward',
'normal_reward_max' => 'max_reward',
'rare_reward_min' => 'rare_min_reward',
'rare_reward_max' => 'rare_max_reward',
];
if (($paramValue === '' || $paramValue === null) && isset($legacyFallbackMap[$paramKey])) {
$paramValue = $params[$legacyFallbackMap[$paramKey]] ?? $paramValue;
if (($paramValue === '' || $paramValue === null) && isset($legacyFallbackMap[$paramKey])) {
$paramValue = $params[$legacyFallbackMap[$paramKey]] ?? $paramValue;
}
}
}
@endphp
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">
{{ $meta['label'] }}
@if ($meta['unit'])
<span class="font-normal text-gray-400">{{ $meta['unit'] }}</span>
@endif
</label>
@endphp
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">
{{ $meta['label'] }}
@if ($meta['unit'])
<span class="font-normal text-gray-400">{{ $meta['unit'] }}</span>
@endif
</label>
@if ($meta['type'] === 'boolean')
<select name="params[{{ $paramKey }}]"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
<option value="1" {{ $paramValue ? 'selected' : '' }}></option>
<option value="0" {{ !$paramValue ? 'selected' : '' }}></option>
</select>
@elseif ($meta['type'] === 'array')
<input type="text" name="params[{{ $paramKey }}]"
value="{{ implode(',', (array) $paramValue) }}"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400"
placeholder="多个值用逗号分隔">
@else
<input type="{{ $meta['type'] }}" name="params[{{ $paramKey }}]"
value="{{ $paramValue }}" step="{{ $meta['step'] ?? 1 }}"
min="{{ $meta['min'] ?? 0 }}"
class="w-full border border-gray-300 rounded-lg p-2 text-sm focus:border-indigo-400">
@endif
</div>
@endforeach
</div>
@if ($meta['type'] === 'boolean')
<select name="params[{{ $paramKey }}]"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="1" {{ $paramValue ? 'selected' : '' }}></option>
<option value="0" {{ !$paramValue ? 'selected' : '' }}></option>
</select>
@elseif ($meta['type'] === 'array')
<input type="text" name="params[{{ $paramKey }}]"
value="{{ implode(',', (array) $paramValue) }}"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400"
placeholder="多个值用逗号分隔">
@else
<input type="{{ $meta['type'] }}" name="params[{{ $paramKey }}]"
value="{{ $paramValue }}" step="{{ $meta['step'] ?? 1 }}"
min="{{ $meta['min'] ?? 0 }}"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
@endif
</div>
@endforeach
</div>
<div class="mt-4 border-t border-gray-100 pt-4">
@include('admin.game-configs.partials.common-room-scope', [
'availableRooms' => $availableRooms,
'roomScopeConfig' => $roomScopeConfig,
'roomScopeTitle' => '参与房间',
])
</div>
@endif
<div class="mt-4 flex items-center gap-3">
<button type="submit"
@@ -175,7 +201,7 @@
style="padding:8px 16px; background:linear-gradient(135deg,#7f1d1d,#ef4444); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;">
☠️ 投放黑化箱
</button>
<span class="text-xs text-gray-400">直接向 #1 房间投放,立即广播暗号</span>
<span class="text-xs text-gray-400">投放到当前配置的首选房间,立即广播暗号。</span>
</div>
</div>
@endif
@@ -408,7 +434,55 @@
'pve_fee_level_4' => ['label' => 'AI专家 入场费', 'type' => 'number', 'unit' => '金币', 'min' => 0],
'pve_reward_level_4' => ['label' => 'AI专家 胜利奖励', 'type' => 'number', 'unit' => '金币', 'min' => 0],
],
'idiom' => [
'reward_gold' => ['label' => '答对奖励金币', 'type' => 'number', 'unit' => '枚', 'min' => 0],
'reward_exp' => ['label' => '答对奖励经验', 'type' => 'number', 'unit' => '点', 'min' => 0],
'auto_start_interval' => ['label' => '自动出题间隔', 'type' => 'number', 'unit' => '分钟(0=仅手动)', 'min' => 0],
],
default => [],
};
}
/**
* 解析猜谜活动公共配置,并兼容旧版题型拆分配置。
*
* @return array{reward_gold:int,reward_exp:int,expire_minutes:int,auto_start_interval:int,room_mode:string,room_ids:array<int, int>}
*/
function gameRiddleSharedConfig(array $params): array
{
$fallbackTypeConfig = collect((array) ($params['type_configs'] ?? []))
->first(fn ($typeConfig) => is_array($typeConfig) && $typeConfig !== [], []);
$roomMode = (string) ($params['room_scope_mode'] ?? ($fallbackTypeConfig['room_mode'] ?? 'single'));
if (! in_array($roomMode, ['all', 'single', 'multiple'], true)) {
$roomMode = 'single';
}
return [
'reward_gold' => max(0, (int) ($params['reward_gold'] ?? ($fallbackTypeConfig['reward_gold'] ?? 50))),
'reward_exp' => max(0, (int) ($params['reward_exp'] ?? ($fallbackTypeConfig['reward_exp'] ?? 30))),
'expire_minutes' => max(0, (int) ($params['expire_minutes'] ?? ($fallbackTypeConfig['expire_minutes'] ?? 5))),
'auto_start_interval' => max(0, (int) ($params['auto_start_interval'] ?? ($fallbackTypeConfig['auto_start_interval'] ?? 0))),
'room_mode' => $roomMode,
'room_ids' => collect((array) ($params['room_ids'] ?? ($fallbackTypeConfig['room_ids'] ?? [])))
->map(fn ($roomId) => (int) $roomId)
->filter(fn ($roomId) => $roomId > 0)
->unique()
->values()
->all(),
];
}
/**
* 解析通用游戏的房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
function gameRoomScopeConfig(array $params): array
{
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
return $roomScopeService->getScopeConfigForParams($params);
}
@endphp
@@ -0,0 +1,79 @@
@php
$roomScopeConfig = $roomScopeConfig ?? ['room_scope_mode' => 'single', 'room_ids' => [1]];
$roomScopeDataKey = $roomScopeDataKey ?? 'game';
$roomScopeTitle = $roomScopeTitle ?? '参与房间';
$roomScopeCheckedRoomIds = collect($roomScopeConfig['room_ids'] ?? [1])->map(fn ($roomId) => (int) $roomId)->all();
@endphp
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">参与房间模式</label>
<select name="params[room_scope_mode]"
data-game-room-mode
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="all" @selected(($roomScopeConfig['room_scope_mode'] ?? 'single') === 'all')>全部房间</option>
<option value="single" @selected(($roomScopeConfig['room_scope_mode'] ?? 'single') === 'single')>单选房间</option>
<option value="multiple" @selected(($roomScopeConfig['room_scope_mode'] ?? 'single') === 'multiple')>多选房间</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-2 block text-xs font-bold text-gray-600">{{ $roomScopeTitle }}</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-dashed border-slate-200 bg-white p-3 lg:grid-cols-3">
@foreach ($availableRooms as $room)
<label class="flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm text-slate-600">
<input type="checkbox"
name="params[room_ids][]"
value="{{ $room->id }}"
@checked(in_array((int) $room->id, $roomScopeCheckedRoomIds, true))
data-game-room-checkbox
class="rounded border-slate-300 text-indigo-600">
<span>#{{ $room->id }} {{ $room->name }}</span>
</label>
@endforeach
</div>
<p class="mt-2 text-xs text-gray-400">单选模式下只保留一个房间,多选模式可同时勾选多个房间。</p>
</div>
</div>
@once
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
function showAdminAlert(message, title = '提示', icon = '️') {
if (window.adminDialog?.alert) {
window.adminDialog.alert(message, title, icon);
return;
}
window.alert(message);
}
document.querySelectorAll('[data-game-room-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
const modeSelect = this.querySelector('[data-game-room-mode]');
const roomCheckboxes = Array.from(this.querySelectorAll('[data-game-room-checkbox]'));
const checkedRooms = roomCheckboxes.filter(function (checkbox) {
return checkbox.checked;
});
if (!modeSelect) {
return;
}
if (modeSelect.value === 'single' && checkedRooms.length > 1) {
event.preventDefault();
showAdminAlert('单选房间模式下只能选择一个房间。', '房间选择有误', '⚠️');
return;
}
if (modeSelect.value === 'multiple' && checkedRooms.length === 0) {
event.preventDefault();
showAdminAlert('多选房间模式下,请至少选择一个房间。', '房间选择有误', '⚠️');
}
});
});
});
</script>
@endpush
@endonce
@@ -0,0 +1,160 @@
@php
$sharedConfig = gameRiddleSharedConfig($params);
@endphp
<div class="space-y-4">
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 p-4 text-xs leading-6 text-indigo-700">
猜成语与脑筋急转弯共用同一套奖励、过期时间、自动出题间隔与参与房间范围配置。
手动出题时再单独选择题型即可。
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/70 p-4" data-idiom-config-card>
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-bold text-slate-800">猜谜活动公共设置</div>
<div class="text-xs text-slate-500">以下参数会同时作用于猜成语与脑筋急转弯。</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@foreach ($riddleTypeOptions as $typeLabel)
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-500 shadow-sm">{{ $typeLabel }}</span>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">答对奖励金币</label>
<input type="number" name="params[reward_gold]"
value="{{ old('params.reward_gold', $sharedConfig['reward_gold']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">答对奖励经验</label>
<input type="number" name="params[reward_exp]"
value="{{ old('params.reward_exp', $sharedConfig['reward_exp']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">题目过期时间</label>
<input type="number" name="params[expire_minutes]"
value="{{ old('params.expire_minutes', $sharedConfig['expire_minutes']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示不过期。</p>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">自动出题间隔</label>
<input type="number" name="params[auto_start_interval]"
value="{{ old('params.auto_start_interval', $sharedConfig['auto_start_interval']) }}"
min="0"
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示仅手动出题。</p>
</div>
</div>
<div class="mt-4 border-t border-gray-100 pt-4">
@include('admin.game-configs.partials.common-room-scope', [
'availableRooms' => $availableRooms,
'roomScopeConfig' => [
'room_scope_mode' => $sharedConfig['room_mode'],
'room_ids' => $sharedConfig['room_ids'],
],
'roomScopeTitle' => '参与房间',
])
</div>
</div>
</div>
<div class="mt-4 border-t border-gray-100 pt-4">
<div class="mb-3 text-xs font-bold text-gray-600">🧩 手动出题</div>
<div class="flex flex-wrap items-center gap-3">
<select id="idiom-manual-room"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-slate-700">
@foreach ($availableRooms as $room)
<option value="{{ $room->id }}">#{{ $room->id }} {{ $room->name }}</option>
@endforeach
</select>
<select id="idiom-manual-type"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-slate-700">
@foreach ($riddleTypeOptions as $typeKey => $typeLabel)
<option value="{{ $typeKey }}">{{ $typeLabel }}</option>
@endforeach
</select>
<button type="button" id="idiom-manual-start-btn"
data-idiom-start-url="{{ route('riddle-quiz.start') }}"
class="rounded-lg bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:opacity-90">
立即出题
</button>
<span class="text-xs text-gray-400">先选房间,再选题型,后台会按对应题型配置发题。</span>
</div>
</div>
@once
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
function showAdminAlert(message, title = '提示', icon = '️') {
if (window.adminDialog?.alert) {
window.adminDialog.alert(message, title, icon);
return;
}
window.alert(message);
}
const manualStartButton = document.getElementById('idiom-manual-start-btn');
if (!manualStartButton) {
return;
}
manualStartButton.addEventListener('click', function () {
const startUrl = this.getAttribute('data-idiom-start-url') || '';
const roomId = Number.parseInt(document.getElementById('idiom-manual-room')?.value || '0', 10);
const quizType = document.getElementById('idiom-manual-type')?.value || '';
if (!startUrl || roomId <= 0 || !quizType) {
showAdminAlert('请先选择房间和题型。', '手动出题', '⚠️');
return;
}
const button = this;
button.disabled = true;
button.textContent = '出题中...';
fetch(startUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
room_id: roomId,
quiz_type: quizType,
}),
})
.then(function (response) {
return response.json();
})
.then(function (response) {
if (response.status === 'success') {
showAdminAlert('题目已发送到目标房间。', '手动出题成功', '✅');
return;
}
showAdminAlert(response.message || '出题失败', '手动出题失败', '❌');
})
.catch(function () {
showAdminAlert('网络错误,出题失败', '手动出题失败', '🌐');
})
.finally(function () {
button.disabled = false;
button.textContent = '立即出题';
});
});
});
</script>
@endpush
@endonce
+6 -1
View File
@@ -98,7 +98,11 @@
</a>
<a href="{{ route('admin.fishing.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.fishing.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
{!! '🎣 钓鱼事件' !!}
🎣 钓鱼事件
</a>
<a href="{{ route('admin.riddles.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.riddles.*') || request()->routeIs('admin.idioms.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
🧩 猜谜活动题库
</a>
<a href="{{ route('admin.departments.index') }}"
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.departments.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
@@ -299,6 +303,7 @@
}
}
</style>
@stack('scripts')
</body>
</html>
@@ -0,0 +1,304 @@
@extends('admin.layouts.app')
@section('title', '猜谜活动题库管理')
@section('content')
@php require resource_path('views/admin/partials/list-theme.php'); @endphp
@php
$quizTypes = $typeOptions;
$idiomPayload = $idioms->mapWithKeys(
fn ($item) => [
(string) $item->id => [
'id' => $item->id,
'type' => $item->type,
'answer' => $item->answer,
'hint' => $item->hint,
'sort' => $item->sort,
'is_active' => (bool) $item->is_active,
'update_url' => route('admin.riddles.update', $item->id),
],
],
);
@endphp
<script type="application/json" id="admin-idioms-data">@json($idiomPayload)</script>
<div class="{{ $adminListPageClass }}">
<div class="{{ $adminListHeaderCardClass }}">
<div>
<h2 class="{{ $adminListHeaderTitleClass }}">🧩 猜谜活动题库管理</h2>
<p class="{{ $adminListHeaderSubtitleClass }}">
这里只管理题目本身;奖励、自动出题、参与房间和手动开题请前往
<a href="{{ route('admin.game-configs.index') }}" class="font-semibold text-indigo-600 hover:text-indigo-500">游戏管理</a>
页面操作。
</p>
</div>
</div>
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<div>
<h3 class="{{ $adminListSectionTitleClass }}">🔎 题库筛选</h3>
<p class="mt-1 text-xs text-slate-500">支持按题型和关键词快速定位题目。</p>
</div>
</div>
<form action="{{ route('admin.riddles.index') }}" method="GET" class="p-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="{{ $adminListFilterLabelClass }}">题型</label>
<select name="type" class="w-full {{ $adminListFilterInputClass }}">
<option value="">全部题型</option>
@foreach ($quizTypes as $quizType => $quizLabel)
<option value="{{ $quizType }}" @selected($selectedType === $quizType)>{{ $quizLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">关键词</label>
<input type="text" name="keyword" value="{{ $keyword }}" placeholder="匹配答案或题面" class="w-full {{ $adminListFilterInputClass }}">
</div>
<div class="flex items-end gap-3">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">筛选</button>
<a href="{{ route('admin.riddles.index') }}" class="{{ $adminListSecondaryButtonClass }}">重置</a>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
@foreach ($quizTypes as $quizType => $quizLabel)
<span class="rounded-full bg-slate-100 px-3 py-1">
{{ $quizLabel }}{{ $typeStats[$quizType] ?? 0 }}
</span>
@endforeach
</div>
</form>
</div>
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<h3 class="{{ $adminListSectionTitleClass }}">📚 题目列表</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="px-4 py-3 text-left font-semibold">ID</th>
<th class="px-4 py-3 text-left font-semibold">题型</th>
<th class="px-4 py-3 text-left font-semibold">标准答案</th>
<th class="px-4 py-3 text-left font-semibold">题面 / 提示</th>
<th class="px-4 py-3 text-left font-semibold">排序</th>
<th class="px-4 py-3 text-left font-semibold">状态</th>
<th class="px-4 py-3 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
@forelse ($idioms as $item)
<tr class="hover:bg-slate-50/80">
<td class="px-4 py-3 text-slate-500">#{{ $item->id }}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold {{ $item->type === \App\Models\Riddle::TYPE_BRAIN_TEASER ? 'bg-amber-100 text-amber-700' : 'bg-indigo-100 text-indigo-700' }}">
{{ \App\Models\Riddle::labelForType($item->type) }}
</span>
</td>
<td class="px-4 py-3 font-semibold text-slate-800">{{ $item->answer }}</td>
<td class="px-4 py-3 text-slate-600">{{ $item->hint }}</td>
<td class="px-4 py-3 text-slate-500">{{ $item->sort }}</td>
<td class="px-4 py-3">
<button type="button"
data-idiom-toggle-url="{{ route('admin.riddles.toggle', $item->id) }}"
class="rounded-full px-3 py-1 text-xs font-semibold transition {{ $item->is_active ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-slate-200 text-slate-500 hover:bg-slate-300' }}">
{{ $item->is_active ? '已启用' : '已禁用' }}
</button>
</td>
<td class="px-4 py-3 text-right">
<button type="button" data-idiom-edit-id="{{ $item->id }}" class="{{ $adminListActionButtonClass }} bg-indigo-50 text-indigo-700 hover:bg-indigo-100">
编辑
</button>
<form action="{{ route('admin.riddles.destroy', $item->id) }}" method="POST" class="inline" data-idiom-delete-confirm="确定删除题目「{{ $item->answer }}」?">
@csrf
@method('DELETE')
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
<button type="submit" class="{{ $adminListActionButtonClass }} bg-red-50 text-red-600 hover:bg-red-100">
删除
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-sm text-slate-400">当前筛选条件下暂无题目。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="{{ $adminListCardClass }}">
<div class="{{ $adminListSectionHeadClass }}">
<h3 class="{{ $adminListSectionTitleClass }}"> 新增题目</h3>
</div>
<form action="{{ route('admin.riddles.store') }}" method="POST" class="p-5">
@csrf
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="{{ $adminListFilterLabelClass }}">题型</label>
<select name="type" class="w-full {{ $adminListFilterInputClass }}">
@foreach ($quizTypes as $quizType => $quizLabel)
<option value="{{ $quizType }}" @selected(old('type', $selectedType ?: \App\Models\Riddle::TYPE_IDIOM) === $quizType)>{{ $quizLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">排序</label>
<input type="number" name="sort" min="0" value="{{ old('sort', 0) }}" class="w-full {{ $adminListFilterInputClass }}">
</div>
<div>
<label class="{{ $adminListFilterLabelClass }}">标准答案</label>
<input type="text" name="answer" value="{{ old('answer') }}" required placeholder="请输入标准答案" class="w-full {{ $adminListFilterInputClass }}">
</div>
<div class="md:col-span-2">
<label class="{{ $adminListFilterLabelClass }}">题面 / 提示</label>
<input type="text" name="hint" value="{{ old('hint') }}" required placeholder="请输入题面或提示文案" class="w-full {{ $adminListFilterInputClass }}">
</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">💾 添加题目</button>
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="is_active" value="1" checked class="rounded border-slate-300">
立即启用
</label>
</div>
</form>
</div>
</div>
<div id="edit-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-lg rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-slate-100 p-5">
<h3 class="text-base font-bold text-slate-800">✏️ 编辑题目</h3>
<button type="button" data-idiom-edit-close class="text-xl text-slate-400 hover:text-slate-600"></button>
</div>
<form id="edit-form" method="POST" class="p-5">
@csrf
@method('PUT')
<input type="hidden" name="redirect_type" value="{{ $selectedType }}">
<input type="hidden" name="redirect_keyword" value="{{ $keyword }}">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-bold text-slate-600">题型</label>
<select name="type" id="edit-type" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
@foreach ($quizTypes as $quizType => $quizLabel)
<option value="{{ $quizType }}">{{ $quizLabel }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-slate-600">排序</label>
<input type="number" name="sort" id="edit-sort" min="0" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-bold text-slate-600">标准答案</label>
<input type="text" name="answer" id="edit-answer" required class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-bold text-slate-600">题面 / 提示</label>
<input type="text" name="hint" id="edit-hint" required class="w-full rounded-lg border border-slate-300 p-2 text-sm">
</div>
<div class="md:col-span-2">
<label class="flex cursor-pointer items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="is_active" id="edit-is-active" value="1" class="rounded border-slate-300">
启用此题目
</label>
</div>
</div>
<div class="mt-5 flex gap-3">
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">💾 保存修改</button>
<button type="button" data-idiom-edit-close class="{{ $adminListSecondaryButtonClass }}">取消</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const idiomsDataEl = document.getElementById('admin-idioms-data');
const editModal = document.getElementById('edit-modal');
const editForm = document.getElementById('edit-form');
if (!idiomsDataEl || !editModal || !editForm) {
return;
}
const idiomsData = JSON.parse(idiomsDataEl.textContent || '{}');
document.querySelectorAll('[data-idiom-toggle-url]').forEach((button) => {
button.addEventListener('click', async function () {
const url = this.getAttribute('data-idiom-toggle-url');
if (!url) {
return;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
});
const result = await response.json();
if (!result.ok) {
alert(result.message || '状态切换失败。');
return;
}
window.location.reload();
});
});
document.querySelectorAll('[data-idiom-delete-confirm]').forEach((form) => {
form.addEventListener('submit', function (event) {
const message = this.getAttribute('data-idiom-delete-confirm') || '确定删除这条题目吗?';
if (!window.confirm(message)) {
event.preventDefault();
}
});
});
document.querySelectorAll('[data-idiom-edit-id]').forEach((button) => {
button.addEventListener('click', function () {
const idiomId = this.getAttribute('data-idiom-edit-id') || '';
const payload = idiomsData[idiomId];
if (!payload) {
return;
}
document.getElementById('edit-type').value = payload.type;
document.getElementById('edit-answer').value = payload.answer;
document.getElementById('edit-hint').value = payload.hint;
document.getElementById('edit-sort').value = payload.sort;
document.getElementById('edit-is-active').checked = Boolean(payload.is_active);
editForm.action = payload.update_url;
editModal.classList.remove('hidden');
editModal.classList.add('flex');
});
});
document.querySelectorAll('[data-idiom-edit-close]').forEach((button) => {
button.addEventListener('click', function () {
editModal.classList.add('hidden');
editModal.classList.remove('flex');
});
});
});
</script>
@endpush
+32
View File
@@ -252,6 +252,38 @@
@include('chat.partials.system-events')
{{-- 初始历史消息、入场欢迎、进场特效、会员横幅和挂起婚姻事件已迁移到 resources/js/chat-room/initial-state.js --}}
{{-- 猜成语答题弹窗 --}}
<div id="idiom-answer-modal"
style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:99999;justify-content:center;align-items:center;backdrop-filter:blur(3px);">
<div style="background:#fff;border-radius:16px;width:min(90vw,460px);box-shadow:0 24px 64px rgba(0,0,0,.22);overflow:hidden;animation:gdSlideIn .2s ease;">
<div style="padding:18px 22px;background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div style="font-size:18px;font-weight:bold;">🧩 猜谜活动</div>
<span id="idiom-answer-type"
style="display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:rgba(255,255,255,.16);font-size:11px;font-weight:700;">成语题</span>
</div>
<div id="idiom-answer-reward" style="font-size:12px;margin-top:4px;opacity:.9;"></div>
</div>
<div style="padding:20px 22px;">
<p id="idiom-answer-hint" style="font-size:15px;color:#1e293b;line-height:1.7;margin-bottom:16px;"></p>
<input id="idiom-answer-input" type="text" autocomplete="off" placeholder="输入答案..."
style="width:100%;box-sizing:border-box;padding:12px 14px;border:2px solid #e5e7eb;border-radius:10px;font-size:15px;outline:none;transition:border-color .2s;"
onfocus="this.style.borderColor='#7c3aed'" onblur="this.style.borderColor='#e5e7eb'">
<p id="idiom-answer-feedback" style="margin-top:8px;font-size:13px;min-height:20px;"></p>
</div>
<div style="padding:0 22px 18px;display:flex;gap:10px;">
<button type="button" data-idiom-answer-close
style="flex:1;padding:11px;background:#f3f4f6;color:#555;border:none;border-radius:10px;font-size:14px;cursor:pointer;font-weight:bold;">
取消
</button>
<button type="button" data-idiom-answer-submit id="idiom-answer-submit"
style="flex:1;padding:11px;background:linear-gradient(135deg,#7c3aed,#a78bfa);color:#fff;border:none;border-radius:10px;font-size:14px;cursor:pointer;font-weight:bold;">
提交答案
</button>
</div>
</div>
</div>
</body>
</html>
@@ -6,7 +6,7 @@
@version 1.0.0
--}}
<div x-data="earnPanelData()"
@open-earn-panel.window="openPanel()"
@open-earn-panel.window="window.chatDialog?.alert('看视频赚钱功能已关闭,敬请期待后续活动。') || alert('看视频赚钱功能已关闭,敬请期待后续活动。')"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
x-show="isOpen"
x-transition.opacity
@@ -135,6 +135,11 @@ $welcomeMessages = [
<input type="checkbox" id="block-sender-fishing" data-chat-block-sender="钓鱼播报">
钓鱼播报
</label>
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
<input type="checkbox" id="block-sender-idiom" data-chat-block-sender="猜成语">
猜谜活动
</label>
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
<input type="checkbox" id="block-sender-doctor" data-chat-block-sender="星海小博士">
@@ -155,6 +160,21 @@ $welcomeMessages = [
<input type="checkbox" id="block-sender-mystery-box" data-chat-block-sender="神秘箱子">
神秘箱子
</label>
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
<input type="checkbox" id="block-sender-gomoku" data-chat-block-sender="五子棋">
五子棋
</label>
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
<input type="checkbox" id="block-sender-slot-machine" data-chat-block-sender="老虎机">
老虎机
</label>
<label
style="display:flex;align-items:center;gap:6px;font-size:12px;color:#1e293b;cursor:pointer;padding:4px 2px;">
<input type="checkbox" id="block-sender-lottery" data-chat-block-sender="双色球彩票">
双色球彩票
</label>
</div>
</div>
@@ -195,6 +215,10 @@ $welcomeMessages = [
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">👥 好友</button>
<button type="button" data-chat-feature-shortcut="settings"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">⚙️ 设置</button>
<button type="button" data-chat-feature-shortcut="guestbook"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">📝 留言</button>
<button type="button" data-chat-feature-shortcut="feedback"
style="font-size:11px;padding:6px 8px;background:#fff;color:#334155;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer;">💬 反馈</button>
</div>
</div>
</div>
@@ -25,20 +25,18 @@
</div>
</div>
{{-- 排序 + 搜索(原版风格 --}}
{{-- 排序 + 搜索(同一行展示,保留原有查询与排序绑定 --}}
<div style="padding:3px 4px; border-bottom:1px solid #cde; background:#f8fbff; font-size:11px;">
<div style="display:flex; align-items:center; gap:4px; margin-bottom:3px;">
<div style="display:flex; align-items:center; gap:4px;">
<span style="color:#666; flex-shrink:0;">排序:</span>
<select id="user-sort-select"
style="flex:1; font-size:11px; padding:1px; border:1px solid #aac; border-radius:2px;">
style="width:66px; flex-shrink:0; font-size:11px; padding:1px; border:1px solid #aac; border-radius:2px;">
<option value="default">默认</option>
<option value="name">按名称</option>
<option value="level">按等级</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:2px;">
<input type="text" id="user-search-input" placeholder="搜索用户"
style="width:100%; font-size:11px; padding:2px 4px; border:1px solid #aac; border-radius:2px; box-sizing:border-box;">
style="flex:1; min-width:0; font-size:11px; padding:2px 4px; border:1px solid #aac; border-radius:2px; box-sizing:border-box;">
</div>
</div>
@@ -19,7 +19,7 @@
<div class="tool-btn" data-toolbar-action="vip" title="会员中心">会员</div>
<div class="tool-btn" data-toolbar-action="save-exp" title="手动存经验点">存点</div>
<div class="tool-btn" data-toolbar-action="game" title="娱乐游戏大厅">娱乐</div>
<div class="tool-btn" data-toolbar-action="earn" title="看视频赚金币">赚钱</div>
{{-- <div class="tool-btn" data-toolbar-action="earn" title="看视频赚金币">赚钱</div> --}}
<div class="tool-btn" data-toolbar-action="bank" title="银行存取金币">银行</div>
<div class="tool-btn" data-toolbar-action="marriage" title="婚姻状态">婚姻</div>
<div class="tool-btn" data-toolbar-action="friend" title="好友列表">好友</div>
@@ -111,7 +111,11 @@
*/
function sendWelcomeTpl(tpl) {
const toUser = document.getElementById('to_user')?.value || '大家';
const name = toUser === '大家' ? '大家' : toUser;
if (toUser === '大家') {
window.chatDialog?.alert('欢迎语不能对「大家」发送,请先选择具体用户。', '🚫 提示', '#cc4444');
return;
}
const name = toUser;
const prefix = window.chatContext?.welcomePrefix || window.chatContext?.username || '';
const body = tpl.replace(/\{name\}/g, name);
const msg = `${prefix}${body}`;
@@ -160,7 +164,7 @@
const CHAT_SOUND_MUTED_KEY = window.ChatRoomTools?.CHAT_SOUND_MUTED_STORAGE_KEY || 'chat_sound_muted';
const BLOCKED_SENDERS_KEY = window.ChatRoomTools?.BLOCKED_SYSTEM_SENDERS_STORAGE_KEY || 'chat_blocked_system_senders';
const BLOCKABLE_SENDERS = window.ChatRoomTools?.BLOCKABLE_SYSTEM_SENDERS || ['钓鱼播报', '星海小博士', '百家乐', '跑马', '神秘箱子'];
const BLOCKABLE_SENDERS = window.ChatRoomTools?.BLOCKABLE_SYSTEM_SENDERS || ['钓鱼播报', '猜成语', '星海小博士', '百家乐', '跑马', '神秘箱子', '五子棋', '老虎机', '双色球彩票'];
const initialChatPreferences = window.ChatRoomTools?.normalizeChatPreferences
? window.ChatRoomTools.normalizeChatPreferences(window.chatContext?.chatPreferences || {}, BLOCKABLE_SENDERS)
@@ -184,6 +188,18 @@
// 恢复屏蔽发送者
if (shouldMigrate && storedBlockedSenders.length > 0) {
window.chatState.blockedSystemSenders = new Set(storedBlockedSenders);
} else if (initialChatPreferences.blocked_system_senders.length > 0) {
window.chatState.blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders);
}
// 恢复后立即隐藏已有消息
if (window.chatState.blockedSystemSenders.size > 0) {
const setRendered = window.setRenderedMessagesVisibilityBySender;
if (typeof setRendered === 'function') {
window.chatState.blockedSystemSenders.forEach(function (sender) {
setRendered(sender, true);
});
}
}
// 恢复禁音状态
+10 -9
View File
@@ -3,14 +3,14 @@
说明:展示军旅海报主视觉、战友登录表单与房间选择,并保留原有登录交互逻辑
--}}
@php
$systemName = \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '和平聊吧';
$systemNotice = \App\Models\SysParam::where('alias', 'sys_notice')->value('body') ?? '欢迎归队,第一次登录即为注册,请妥善保管密码。';
$smtpEnabled = \App\Models\SysParam::where('alias', 'smtp_enabled')->value('body') === '1';
$roomCount = count($rooms);
$defaultRoom = $rooms[0] ?? null;
$heroImage = asset('images/veteran-hero.jpg');
$flashMessage = session('status') ?? session('success') ?? session('error');
$flashType = session()->has('error') ? 'error' : ($flashMessage ? 'success' : null);
$systemName = \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '和平聊吧';
$systemNotice = \App\Models\SysParam::where('alias', 'sys_notice')->value('body') ?? '欢迎归队,第一次登录即为注册,请妥善保管密码。';
$smtpEnabled = \App\Models\SysParam::where('alias', 'smtp_enabled')->value('body') === '1';
$roomCount = count($rooms);
$defaultRoom = $rooms[0] ?? null;
$heroImage = asset('images/veteran-hero.jpg');
$flashMessage = session('status') ?? session('success') ?? session('error');
$flashType = session()->has('error') ? 'error' : ($flashMessage ? 'success' : null);
@endphp
<!DOCTYPE html>
<html lang="zh-CN">
@@ -917,5 +917,6 @@
</main>
</body>
<script charset="UTF-8" id="LA_COLLECT" src="//sdk.51.la/js-sdk-pro.min.js"></script>
<script>LA.init({ id: "LAxDuDMRT1AhCJI4", ck: "LAxDuDMRT1AhCJI4" })</script>
</html>
+5 -6
View File
@@ -259,11 +259,11 @@
{{-- ═══════════ 底部页脚(版权信息) ═══════════ --}}
@php
/* 取第一个房间的创建年份作为网站创建时间 */
$siteStartYear = \App\Models\Room::min('created_at')
? \Carbon\Carbon::parse(\App\Models\Room::min('created_at'))->year
: date('Y');
$copyrightYear = $siteStartYear == date('Y') ? $siteStartYear : $siteStartYear . ' ' . date('Y');
/* 取第一个房间的创建年份作为网站创建时间 */
$siteStartYear = \App\Models\Room::min('created_at')
? \Carbon\Carbon::parse(\App\Models\Room::min('created_at'))->year
: date('Y');
$copyrightYear = $siteStartYear == date('Y') ? $siteStartYear : $siteStartYear . ' ' . date('Y');
@endphp
<footer class="border-t border-gray-200 bg-white mt-auto">
<div
@@ -282,5 +282,4 @@
</body>
</html>
+91 -58
View File
@@ -68,16 +68,21 @@ Schedule::call(function () {
$config = \App\Models\GameConfig::forGame('baccarat')?->params ?? [];
$interval = (int) ($config['interval_minutes'] ?? 2);
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 检查距上一局触发时间是否已达到间隔
$lastRound = \App\Models\BaccaratRound::latest()->first();
if ($lastRound && $lastRound->created_at->diffInMinutes(now()) < $interval) {
return; // 还没到开局时间
}
foreach ($roomScopeService->getScopedRoomIdsForGame('baccarat') as $roomId) {
$lastRound = \App\Models\BaccaratRound::query()
->where('room_id', $roomId)
->latest()
->first();
// 无当前进行中的局才开新局
if (! \App\Models\BaccaratRound::currentRound()) {
\App\Jobs\OpenBaccaratRoundJob::dispatch();
if ($lastRound && $lastRound->created_at->diffInMinutes(now()) < $interval) {
continue;
}
if (! \App\Models\BaccaratRound::currentRound($roomId)) {
\App\Jobs\OpenBaccaratRoundJob::dispatch($roomId);
}
}
})->everyMinute()->name('baccarat:open-round')->withoutOverlapping();
@@ -96,30 +101,33 @@ Schedule::call(function () {
return;
}
// 当前已有可领取的箱子时跳过(一次只投放一个)
if (\App\Models\MysteryBox::currentOpenBox()) {
return;
}
$intervalHours = (float) ($config['auto_interval_hours'] ?? 2);
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 检查距上次投放时间
$lastBox = \App\Models\MysteryBox::latest()->first();
if ($lastBox && $lastBox->created_at->diffInHours(now()) < $intervalHours) {
return;
foreach ($roomScopeService->getScopedRoomIdsForGame('mystery_box') as $roomId) {
if (\App\Models\MysteryBox::currentOpenBox($roomId)) {
continue;
}
$lastBox = \App\Models\MysteryBox::query()
->where('room_id', $roomId)
->latest()
->first();
if ($lastBox && $lastBox->created_at->diffInHours(now()) < $intervalHours) {
continue;
}
$trapChance = (int) ($config['trap_chance_percent'] ?? 10);
$rand = random_int(1, 100);
$boxType = match (true) {
$rand <= $trapChance => 'trap',
$rand <= $trapChance + 15 => 'rare',
default => 'normal',
};
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $roomId);
}
// 按配置的陷阱概率决定箱子类型
$trapChance = (int) ($config['trap_chance_percent'] ?? 10);
$rand = random_int(1, 100);
$boxType = match (true) {
$rand <= $trapChance => 'trap',
$rand <= $trapChance + 15 => 'rare',
default => 'normal',
};
\App\Jobs\DropMysteryBoxJob::dispatch($boxType);
})->everyMinute()->name('mystery-box:auto-drop')->withoutOverlapping();
// ──────────── 赛马竞猜定时任务 ─────────────────────────────────
@@ -130,21 +138,25 @@ Schedule::call(function () {
return;
}
// 当前已有进行中的场次(押注中/跑马中),跳过
if (\App\Models\HorseRace::currentRace()) {
return;
}
$config = \App\Models\GameConfig::forGame('horse_racing')?->params ?? [];
$interval = (int) ($config['interval_minutes'] ?? 30);
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 检查距上一场触发时间是否已达到间隔
$lastRace = \App\Models\HorseRace::latest()->first();
if ($lastRace && $lastRace->created_at->diffInMinutes(now()) < $interval) {
return;
foreach ($roomScopeService->getScopedRoomIdsForGame('horse_racing') as $roomId) {
if (\App\Models\HorseRace::currentRace($roomId)) {
continue;
}
$lastRace = \App\Models\HorseRace::query()
->where('room_id', $roomId)
->latest()
->first();
if ($lastRace && $lastRace->created_at->diffInMinutes(now()) < $interval) {
continue;
}
\App\Jobs\OpenHorseRaceJob::dispatch($roomId)->delay(now()->addSeconds(30));
}
\App\Jobs\OpenHorseRaceJob::dispatch()->delay(now()->addSeconds(30));
})->everyMinute()->name('horse-race:open-race')->withoutOverlapping();
// ──────────── 双色球彩票定时任务 ─────────────────────────────────
@@ -155,26 +167,43 @@ Schedule::call(function () {
return;
}
$issue = \App\Models\LotteryIssue::query()->whereIn('status', ['open', 'closed'])->latest()->first();
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 无进行中期次则自动创建一期
if (! $issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch();
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
$issue = \App\Models\LotteryIssue::query()
->where('room_id', $roomId)
->whereIn('status', ['open', 'closed'])
->latest()
->first();
if (! $issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
continue;
}
if ($issue->status === 'open' && $issue->sell_closes_at && now()->gte($issue->sell_closes_at)) {
$issue->update(['status' => 'closed']);
$issue->refresh();
}
if ($issue->status === 'closed' && $issue->draw_at && now()->gte($issue->draw_at)) {
\App\Jobs\DrawLotteryJob::dispatch($issue);
}
}
})->everyMinute()->name('lottery:check')->withoutOverlapping();
// ──────────── 猜谜活动自动出题 ──────────────────────────────────
//
// 每分钟:按房间范围 + 题型维度独立检查是否应自动出题
Schedule::call(function () {
if (! \App\Models\GameConfig::isEnabled('idiom')) {
return;
}
// open 状态:检查是否已到停售时间
if ($issue->status === 'open' && $issue->sell_closes_at && now()->gte($issue->sell_closes_at)) {
$issue->update(['status' => 'closed']);
$issue->refresh();
}
// closed 状态:检查是否已到开奖时间
if ($issue->status === 'closed' && $issue->draw_at && now()->gte($issue->draw_at)) {
\App\Jobs\DrawLotteryJob::dispatch($issue);
}
})->everyMinute()->name('lottery:check')->withoutOverlapping();
// 出题、过期结算、房间范围与题型独立判定统一交给服务层处理。
app(\App\Services\RiddleGameService::class)->autoStartEligibleRounds();
})->everyMinute()->name('idiom:auto-start')->withoutOverlapping();
// 每日 18:00:超级期预热广播(若当前期次为超级期,提醒用户购票)
Schedule::call(function () {
@@ -182,8 +211,12 @@ Schedule::call(function () {
return;
}
$issue = \App\Models\LotteryIssue::currentIssue();
if ($issue && $issue->is_super_issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch(); // 触发广播
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
$issue = \App\Models\LotteryIssue::currentIssue($roomId);
if ($issue && $issue->is_super_issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId); // 触发广播
}
}
})->dailyAt('18:00')->name('lottery:super-reminder');
+38
View File
@@ -274,6 +274,7 @@ Route::middleware(['chat.auth'])->group(function () {
'fishing' => \App\Models\GameConfig::isEnabled('fishing'),
'lottery' => \App\Models\GameConfig::isEnabled('lottery'),
'gomoku' => \App\Models\GameConfig::isEnabled('gomoku'),
'idiom' => \App\Models\GameConfig::isEnabled('idiom'),
]);
})->name('games.enabled');
@@ -286,6 +287,25 @@ Route::middleware(['chat.auth'])->group(function () {
->middleware('throttle:chat-send')
->name('chat.send');
// 拍一拍
Route::post('/room/{id}/pat', [ChatController::class, 'pat'])
->middleware('throttle:chat-send')
->name('chat.pat');
// 猜谜活动游戏
Route::prefix('riddle-quiz')->name('riddle-quiz.')->group(function () {
Route::post('/start', [\App\Http\Controllers\RiddleQuizController::class, 'start'])->name('start');
Route::post('/answer', [\App\Http\Controllers\RiddleQuizController::class, 'answer'])->name('answer');
Route::get('/current', [\App\Http\Controllers\RiddleQuizController::class, 'current'])->name('current');
});
// 兼容旧前端与历史脚本,暂时保留旧的 idiom-quiz 路由别名。
Route::prefix('idiom-quiz')->name('idiom-quiz.')->group(function () {
Route::post('/start', [\App\Http\Controllers\RiddleQuizController::class, 'start'])->name('start');
Route::post('/answer', [\App\Http\Controllers\RiddleQuizController::class, 'answer'])->name('answer');
Route::get('/current', [\App\Http\Controllers\RiddleQuizController::class, 'current'])->name('current');
});
// 挂机心跳存点 (限制每分钟最多调用 6 次防止挂机脚本滥用)
Route::post('/room/{id}/heartbeat', [ChatController::class, 'heartbeat'])
->middleware('throttle:6,1')
@@ -576,6 +596,24 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
Route::post('/{fishing}/toggle', [\App\Http\Controllers\Admin\FishingEventController::class, 'toggle'])->name('toggle');
Route::delete('/{fishing}', [\App\Http\Controllers\Admin\FishingEventController::class, 'destroy'])->name('destroy');
});
// ── 猜谜活动题库 ──
Route::prefix('riddles')->name('riddles.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\RiddleController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Admin\RiddleController::class, 'store'])->name('store');
Route::put('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'update'])->name('update');
Route::post('/{idiom}/toggle', [\App\Http\Controllers\Admin\RiddleController::class, 'toggle'])->name('toggle');
Route::delete('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'destroy'])->name('destroy');
});
// 兼容旧后台路径与书签,先保留 idioms 别名入口。
Route::prefix('idioms')->name('idioms.')->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\RiddleController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Admin\RiddleController::class, 'store'])->name('store');
Route::put('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'update'])->name('update');
Route::post('/{idiom}/toggle', [\App\Http\Controllers\Admin\RiddleController::class, 'toggle'])->name('toggle');
Route::delete('/{idiom}', [\App\Http\Controllers\Admin\RiddleController::class, 'destroy'])->name('destroy');
});
});
// ──────────────────────────────────────────────────────────────
+108
View File
@@ -0,0 +1,108 @@
<?php
// 猜成语题库 - 谜语式线索(不直接描述成语含义)
// 需要玩家开动脑筋联想
return [
['answer' => '画蛇添足', 'hint' => '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语'],
['answer' => '守株待兔', 'hint' => '🧩 农夫不干活,天天蹲树桩旁等天上掉馅饼。猜一成语'],
['answer' => '掩耳盗铃', 'hint' => '🧩 小偷以为捂住自己耳朵,别人就听不见铃铛响了。猜一成语'],
['answer' => '亡羊补牢', 'hint' => '🧩 羊圈破了个洞,羊跑了几只才想起修。猜一成语'],
['answer' => '刻舟求剑', 'hint' => '🧩 船上做了个记号就能在江里找回剑?猜一成语'],
['answer' => '叶公好龙', 'hint' => '🧩 家里到处画龙雕龙,真龙来了却吓得屁滚尿流。猜一成语'],
['answer' => '狐假虎威', 'hint' => '🧩 狐狸走在老虎前面,小动物们到底怕谁?猜一成语'],
['answer' => '井底之蛙', 'hint' => '🧩 住在井里,却以为天空只有井口那么大。猜一成语'],
['answer' => '对牛弹琴', 'hint' => '🧩 弹了一首好曲子,听众却在低头吃草。猜一成语'],
['answer' => '杯弓蛇影', 'hint' => '🧩 酒杯里有个弯弯曲曲的东西在动,吓得大病一场。猜一成语'],
['answer' => '鹤立鸡群', 'hint' => '🧩 一只长腿白鸟混进了院子里的小黄鸡中。猜一成语'],
['answer' => '画龙点睛', 'hint' => '🧩 最后两笔点上后,墙上的龙竟然飞走了。猜一成语'],
['answer' => '鸡飞蛋打', 'hint' => '🧩 偷鸡不成,竹篮打水一场空。猜一成语'],
['answer' => '马到成功', 'hint' => '🧩 战旗一挥,马蹄刚踏出去就赢了。猜一成语'],
['answer' => '虎头蛇尾', 'hint' => '🧩 开头气势如虹,结尾却草草收场。猜一成语'],
['answer' => '龙飞凤舞', 'hint' => '🧩 王羲之喝醉了,笔下的字好像要飞起来。猜一成语'],
['answer' => '鸡犬不宁', 'hint' => '🧩 闹得天翻地覆,连院子里的小动物都不得安生。猜一成语'],
['answer' => '狼吞虎咽', 'hint' => '🧩 饿了三天的壮汉看到一碗面。猜一成语'],
['answer' => '鱼目混珠', 'hint' => '🧩 地摊上有人拿玻璃球当夜明珠卖。猜一成语'],
['answer' => '鼠目寸光', 'hint' => '🧩 只看得到眼前一寸的路,走远就迷路。猜一成语'],
['answer' => '九牛一毛', 'hint' => '🧩 亿万富翁丢了一分钱,连弯腰捡都懒得捡。猜一成语'],
['answer' => '如鱼得水', 'hint' => '🧩 刘备说:有了诸葛亮,就像什么回到了什么里?猜一成语'],
['answer' => '鸟语花香', 'hint' => '🧩 春天来了,你能听到什么、闻到什么?猜一成语'],
['answer' => '风花雪月', 'hint' => '🧩 才子佳人写的诗,看起来很美,其实没什么实际内容。猜一成语'],
['answer' => '山清水秀', 'hint' => '🧩 桂林漓江边上,你能看到什么颜色?猜一成语'],
['answer' => '水落石出', 'hint' => '🧩 水位下降后,河床下的东西藏不住了。猜一成语'],
['answer' => '火中取栗', 'hint' => '🧩 猫爪子被烫伤了,猴子却在旁边偷笑吃栗子。猜一成语'],
['answer' => '石破天惊', 'hint' => '🧩 一块石头裂开,伴随着一声巨响,所有人都惊呆了。猜一成语'],
['answer' => '翻天覆地', 'hint' => '🧩 孙悟空大闹天宫后,凌霄宝殿变成了什么样?猜一成语'],
['answer' => '开天辟地', 'hint' => '🧩 盘古拿着斧头,对着混沌用力一劈。猜一成语'],
['answer' => '惊天动地', 'hint' => '🧩 汶川大地震那天,连天上的云都在颤抖。猜一成语'],
['answer' => '花好月圆', 'hint' => '🧩 婚礼请柬上最常见的四个字祝福。猜一成语'],
['answer' => '冰清玉洁', 'hint' => '🧩 她的品格像冬天的什么和深山里的什么?猜一成语'],
['answer' => '海阔天空', 'hint' => '🧩 走出小县城,来到大都市,才发现世界有多大。猜一成语'],
['answer' => '雪中送炭', 'hint' => '🧩 大冬天你最需要什么?有人偏偏就送来了什么。猜一成语'],
['answer' => '锦上添花', 'hint' => '🧩 已经够漂亮了,还要再点缀一下。猜一成语'],
['answer' => '落井下石', 'hint' => '🧩 有人掉坑里了,你不救也就算了,还往下面扔砖头。猜一成语'],
['answer' => '纸上谈兵', 'hint' => '🧩 赵括说起兵法头头是道,上了战场却一败涂地。猜一成语'],
['answer' => '胸有成竹', 'hint' => '🧩 画家文同还没下笔,心里已经有了完整的竹子。猜一成语'],
['answer' => '一帆风顺', 'hint' => '🧩 出海前最想听到的一句祝福。猜一成语'],
['answer' => '水到渠成', 'hint' => '🧩 不用刻意挖渠,水自然会找到它的路。猜一成语'],
['answer' => '百发百中', 'hint' => '🧩 养由基站在百步之外射柳叶,箭无虚发。猜一成语'],
['answer' => '一鸣惊人', 'hint' => '🧩 齐威王三年不上朝不理政,一出手就震惊了各国。猜一成语'],
['answer' => '对答如流', 'hint' => '🧩 老师提问,他不用思考就说出答案,像江水一样不停。猜一成语'],
['answer' => '顺手牵羊', 'hint' => '🧩 路过别人家门口,看到一只羊没人看管……猜一成语'],
['answer' => '勇往直前', 'hint' => '🧩 前面是刀山火海,他眼睛都不眨一下继续走。猜一成语'],
['answer' => '百折不挠', 'hint' => '🧩 被摔倒一百次,第一百零一次依然站起来。猜一成语'],
['answer' => '持之以恒', 'hint' => '🧩 水滴不断地滴在石头上,千年后石头被滴穿了。猜一成语'],
['answer' => '知己知彼', 'hint' => '🧩 孙子兵法说:了解自己又了解对方,百战不殆。猜一成语'],
['answer' => '四面楚歌', 'hint' => '🧩 项羽被包围在垓下,四面八方都传来熟悉的歌声。猜一成语'],
['answer' => '草木皆兵', 'hint' => '🧩 淝水之战中,苻坚看到山上的草和树,都以为是敌军。猜一成语'],
['answer' => '一箭双雕', 'hint' => '🧩 长孙晟一箭射出去,两只大鸟应声落地。猜一成语'],
['answer' => '背水一战', 'hint' => '🧩 韩信把军队放在河边列阵,断了所有人的退路。猜一成语'],
['answer' => '声东击西', 'hint' => '🧩 明明要打左边,却装作全力进攻右边。猜一成语'],
['answer' => '调虎离山', 'hint' => '🧩 想占老虎的老窝,得先把老虎引出去。猜一成语'],
['answer' => '空城计', 'hint' => '🧩 诸葛亮大开城门,在城楼上弹琴,敌军反而不敢进城。猜一成语'],
['answer' => '缓兵之计', 'hint' => '🧩 打不过怎么办?先假装谈判争取时间。猜一成语'],
['answer' => '卧薪尝胆', 'hint' => '🧩 越王勾践每天睡在柴堆上,还要舔一口苦胆。猜一成语'],
['answer' => '三顾茅庐', 'hint' => '🧩 刘备为了请一个人出山,大冬天跑了三趟。猜一成语'],
['answer' => '望梅止渴', 'hint' => '🧩 曹操说前面有片梅林,士兵们嘴里都开始流口水了。猜一成语'],
['answer' => '七步成诗', 'hint' => '🧩 曹植被亲哥哥逼着在很短的时间里写诗保命。猜一成语'],
['answer' => '才高八斗', 'hint' => '🧩 谢灵运说:天下才华共一石,曹植独占八斗。猜一成语'],
['answer' => '入木三分', 'hint' => '🧩 王羲之在木板上写字,墨迹渗入木头三分深。猜一成语'],
['answer' => '一字千金', 'hint' => '🧩 吕不韦悬赏:谁能给《吕氏春秋》改动一个字,赏千金。猜一成语'],
['answer' => '一诺千金', 'hint' => '🧩 季布的一句话,比黄金千两还贵重。猜一成语'],
['answer' => '半途而废', 'hint' => '🧩 走到半山腰觉得累了,就转身下山了。猜一成语'],
['answer' => '实事求是', 'hint' => '🧩 不夸大不缩小,是什么就是什么。猜一成语'],
['answer' => '量力而行', 'hint' => '🧩 蚂蚁想搬走大象?先掂量掂量自己有几斤几两。猜一成语'],
['answer' => '自相矛盾', 'hint' => '🧩 楚国人夸自己的矛能刺穿任何盾,又夸自己的盾什么都刺不穿。猜一成语'],
['answer' => '买椟还珠', 'hint' => '🧩 花大价钱买了精美盒子,却把里面的珍珠还给了老板。猜一成语'],
['answer' => '杞人忧天', 'hint' => '🧩 有个怪人天天担心天会塌下来,地会陷下去。猜一成语'],
['answer' => '画饼充饥', 'hint' => '🧩 饿了看着墙上画的大饼,假装自己吃饱了。猜一成语'],
['answer' => '空中楼阁', 'hint' => '🧩 没有地基,没有支柱,一座房子浮在云端。猜一成语'],
['answer' => '异想天开', 'hint' => '🧩 有人想在天上种田,海里摘星星。猜一成语'],
['answer' => '千方百计', 'hint' => '🧩 为了达到目的,把所有能想到的办法都用上了。猜一成语'],
['answer' => '不计其数', 'hint' => '🧩 海边的沙子有多少?天上的星星有多少?猜一成语'],
['answer' => '成千上万', 'hint' => '🧩 体育场里坐满了人,放眼望去黑压压一片。猜一成语'],
['answer' => '独一无二', 'hint' => '🧩 世界上没有第二个一模一样的你。猜一成语'],
['answer' => '举世闻名', 'hint' => '🧩 地球上有几个人,就有几个人知道他。猜一成语'],
['answer' => '名不虚传', 'hint' => '🧩 听说很好吃,亲自尝了一口,果然名不虚传。不对,我说的就是~猜一成语'],
['answer' => '名副其实', 'hint' => '🧩 大家都叫他「神算子」,他算卦确实从没错过。猜一成语'],
['answer' => '引人入胜', 'hint' => '🧩 这本书太好看了,一翻开就停不下来,像被吸进去了一样。猜一成语'],
['answer' => '身临其境', 'hint' => '🧩 VR眼镜里的世界太真实了,好像自己真的在里面。猜一成语'],
['answer' => '迫不及待', 'hint' => '🧩 快递到了,鞋都没穿就跑下楼去拿。猜一成语'],
['answer' => '争先恐后', 'hint' => '🧩 超市大减价,大门一开所有人都在往前冲。猜一成语'],
['answer' => '如履薄冰', 'hint' => '🧩 每一步都小心翼翼,生怕脚下突然裂开。猜一成语'],
['answer' => '小心翼翼', 'hint' => '🧩 手里捧着一个装满水的气球,大气都不敢喘。猜一成语'],
['answer' => '心花怒放', 'hint' => '🧩 听到被录取的消息,心里像有千万朵花同时绽放。猜一成语'],
['answer' => '欢天喜地', 'hint' => '🧩 过年了,小孩拿到压岁钱在大街上又蹦又跳。猜一成语'],
['answer' => '眉开眼笑', 'hint' => '🧩 嘴角上扬,眼角弯弯,整张脸都在表达开心。猜一成语'],
['answer' => '手舞足蹈', 'hint' => '🧩 听到最喜欢的歌,身体不由自主地跟着节奏动起来。猜一成语'],
['answer' => '兴高采烈', 'hint' => '🧩 中彩票后,他整个人像打了鸡血一样。猜一成语'],
['answer' => '得意忘形', 'hint' => '🧩 考了第一名就开始翘尾巴,连走路姿势都不一样了。猜一成语'],
['answer' => '怒气冲天', 'hint' => '🧩 他气得头顶冒烟,火焰快要烧到天花板了。猜一成语'],
['answer' => '火冒三丈', 'hint' => '🧩 听到这个消息,他脑袋上的火苗蹿得比房子还高。猜一成语'],
['answer' => '心急如焚', 'hint' => '🧩 等结果的那几分钟,心脏像放在火上烤。猜一成语'],
['answer' => '愁眉苦脸', 'hint' => '🧩 眉头拧成麻花,嘴角向下弯,整张脸写满了不开心。猜一成语'],
['answer' => '泪如雨下', 'hint' => '🧩 他哭得比外面的倾盆大雨还要猛。猜一成语'],
['answer' => '目瞪口呆', 'hint' => '🧩 看到 UFO 从头顶飞过,他张大了嘴巴一句话也说不出来。猜一成语'],
['answer' => '惊弓之鸟', 'hint' => '🧩 被弓箭射过一次的鸟,听到弓弦声就吓得乱飞。猜一成语'],
['answer' => '千钧一发', 'hint' => '🧩 一万斤的重物吊在一根头发丝上,随时会断。猜一成语'],
['answer' => '愚公移山', 'hint' => '🧩 九十岁老头发誓要搬走门口的两座大山,子子孙孙无穷匮也。猜一成语'],
];
@@ -0,0 +1,183 @@
<?php
/**
* 文件功能:后台游戏参数保存测试
*
* 覆盖游戏管理页保存参数时的关键写库行为,
* 防止普通数值字段因为请求校验裁剪而出现“提示成功但未落库”的回归。
*/
namespace Tests\Feature\Feature;
use App\Models\GameConfig;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证后台游戏配置保存链路。
*/
class AdminGameConfigControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 方法功能:验证保存钓鱼冷却时间时会真正写入配置参数。
*/
public function test_admin_can_update_fishing_cooldown_param(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'user_level' => 100,
]);
Room::query()->create([
'id' => 1,
'room_name' => 'test-room',
'room_owner' => $siteOwner->username,
'room_des' => '用于后台游戏配置测试',
'room_time' => now(),
'build_time' => now(),
]);
$gameConfig = GameConfig::query()->create([
'game_key' => 'fishing',
'name' => '钓鱼',
'icon' => 'fish',
'description' => 'Fishing Game',
'enabled' => true,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5,
'fishing_wait_min' => 8,
'fishing_wait_max' => 15,
'fishing_cooldown' => 300,
],
]);
$response = $this->actingAs($siteOwner)->post(route('admin.game-configs.params', $gameConfig), [
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5,
'fishing_wait_min' => 8,
'fishing_wait_max' => 15,
'fishing_cooldown' => 120,
],
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->assertSame(120, (int) ($gameConfig->fresh()->params['fishing_cooldown'] ?? 0));
}
/**
* 方法功能:验证其他游戏的普通参数也会通过统一保存入口真正落库。
*/
public function test_admin_can_update_multiple_game_params_via_shared_config_endpoint(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'user_level' => 100,
]);
$cases = [
[
'game_key' => 'baccarat',
'name' => '百家乐',
'icon' => 'dice',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'interval_minutes' => 2,
'bet_window_seconds' => 60,
],
'updated_key' => 'bet_window_seconds',
'updated_value' => 90,
],
[
'game_key' => 'lottery',
'name' => '双色球彩票',
'icon' => 'lottery',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'ticket_price' => 100,
'draw_hour' => 20,
],
'updated_key' => 'ticket_price',
'updated_value' => 188,
],
[
'game_key' => 'mystery_box',
'name' => '神秘箱子',
'icon' => 'box',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'claim_window_seconds' => 60,
'normal_reward_min' => 500,
],
'updated_key' => 'claim_window_seconds',
'updated_value' => 75,
],
[
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => 'puzzle',
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'reward_gold' => 50,
'reward_exp' => 30,
],
'updated_key' => 'reward_gold',
'updated_value' => 88,
],
];
foreach ($cases as $index => $case) {
$room = Room::query()->create([
'room_name' => 'room-'.($index + 1),
'room_owner' => $siteOwner->username,
'room_des' => '用于后台游戏配置测试',
'room_time' => now(),
'build_time' => now(),
]);
$roomId = (int) $room->id;
$gameConfig = GameConfig::query()->create([
'game_key' => $case['game_key'],
'name' => $case['name'],
'icon' => $case['icon'],
'description' => 'shared config endpoint test',
'enabled' => true,
'params' => array_merge($case['params'], [
'room_ids' => [$roomId],
]),
]);
$requestParams = $case['params'];
$requestParams['room_ids'] = [$roomId];
$requestParams[$case['updated_key']] = $case['updated_value'];
$response = $this->actingAs($siteOwner)->post(route('admin.game-configs.params', $gameConfig), [
'params' => $requestParams,
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$this->assertSame(
$case['updated_value'],
(int) ($gameConfig->fresh()->params[$case['updated_key']] ?? 0),
"{$case['game_key']} 参数未写入数据库。",
);
}
}
}
+56 -6
View File
@@ -8,10 +8,16 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
* 类功能:覆盖钓鱼小游戏抛竿、收竿和令牌校验流程。
*/
class FishingControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 初始化钓鱼配置并清理 Redis 状态。
*/
protected function setUp(): void
{
parent::setUp();
@@ -32,7 +38,10 @@ class FishingControllerTest extends TestCase
);
}
public function test_can_cast_rod()
/**
* 方法功能:验证用户金币足够时可以正常抛竿并扣除费用。
*/
public function test_can_cast_rod(): void
{
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 10]);
@@ -48,7 +57,10 @@ class FishingControllerTest extends TestCase
$this->assertEquals(5, $user->fresh()->jjb);
}
public function test_cannot_cast_when_on_cooldown()
/**
* 方法功能:验证冷却期间不能重复抛竿。
*/
public function test_cannot_cast_when_on_cooldown(): void
{
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 10]);
@@ -62,7 +74,10 @@ class FishingControllerTest extends TestCase
]);
}
public function test_cannot_cast_without_enough_gold()
/**
* 方法功能:验证金币不足时不能抛竿。
*/
public function test_cannot_cast_without_enough_gold(): void
{
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 2]);
@@ -75,13 +90,15 @@ class FishingControllerTest extends TestCase
]);
}
public function test_can_reel_after_waiting()
/**
* 方法功能:验证等待完成后携带正确令牌可以成功收竿。
*/
public function test_can_reel_after_waiting(): void
{
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 10]);
$token = 'test-token';
$waitTime = 0; // Set to 0 so we can test immediately
Redis::set("fishing:token:{$user->id}", json_encode([
'token' => $token,
@@ -102,7 +119,10 @@ class FishingControllerTest extends TestCase
$this->assertTrue((bool) Redis::exists("fishing:cd:{$user->id}"));
}
public function test_cannot_reel_with_invalid_token()
/**
* 方法功能:验证错误令牌不能收竿。
*/
public function test_cannot_reel_with_invalid_token(): void
{
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 10]);
@@ -122,4 +142,34 @@ class FishingControllerTest extends TestCase
'status' => 'error',
]);
}
/**
* 方法功能:验证已有未收竿令牌时再次抛竿会恢复原会话且不会重复扣金币。
*/
public function test_cast_restores_active_token_without_charging_again(): void
{
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 10]);
$tokenKey = "fishing:token:{$user->id}";
$payload = json_encode([
'token' => 'active-token',
'cast_at' => time(),
'wait_time' => 10,
]);
Redis::setex($tokenKey, 20, $payload);
$response = $this->actingAs($user)->postJson(route('fishing.cast', ['id' => 1]));
$response->assertStatus(200);
$response->assertJson([
'status' => 'success',
'token' => 'active-token',
'cost' => 0,
'restored' => true,
]);
$this->assertSame(10, $user->fresh()->jjb);
$this->assertSame($payload, Redis::get($tokenKey));
}
}
+87
View File
@@ -11,6 +11,7 @@ namespace Tests\Feature;
use App\Events\HorseRaceSettled;
use App\Jobs\CloseHorseRaceJob;
use App\Jobs\RunHorseRaceJob;
use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\HorseBet;
@@ -170,6 +171,46 @@ class HorseRaceControllerTest extends TestCase
$this->assertSame(10000, $response->json('race.total_pool'));
}
/**
* 方法功能:验证超时未结算的 running 场次会在读取当前场次时自动收尾。
*/
public function test_current_race_auto_recovers_stale_running_race(): void
{
Queue::fake();
/** @var \App\Models\User $user */
$user = User::factory()->create(['jjb' => 500]);
$race = HorseRace::create([
'status' => 'running',
'bet_opens_at' => now()->subMinutes(3),
'bet_closes_at' => now()->subMinutes(2),
'race_starts_at' => now()->subMinutes(2),
'horses' => [
['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
],
'total_pool' => 0,
]);
HorseBet::create([
'race_id' => $race->id,
'user_id' => $user->id,
'horse_id' => 2,
'amount' => 100,
'status' => 'pending',
]);
$user->decrement('jjb', 100);
$response = $this->actingAs($user)->getJson(route('horse-race.current'));
$response->assertOk();
$response->assertJson(['race' => null]);
$this->assertSame('settled', $race->fresh()->status);
$this->assertSame(2, $race->fresh()->winner_horse_id);
}
/**
* 方法功能:验证用户可以成功下注。
*/
@@ -519,4 +560,50 @@ class HorseRaceControllerTest extends TestCase
// 公屏赛果消息 + 参与者私聊结算通知,都应进入落库队列。
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 方法功能:验证跑马任务会在同一条任务链内直接完成结算。
*/
public function test_run_horse_race_job_settles_race_without_follow_up_queue_hop(): void
{
Queue::fake();
$race = HorseRace::create([
'status' => 'betting',
'bet_opens_at' => now()->subMinutes(2),
'bet_closes_at' => now()->subMinute(),
'horses' => [
['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
],
'total_pool' => 10000,
]);
GameConfig::updateOrCreate(
['game_key' => 'horse_racing'],
[
'name' => 'Horse Racing',
'icon' => 'horse',
'description' => 'Horse Racing Game',
'enabled' => true,
'params' => [
'min_bet' => 100,
'max_bet' => 100000,
'house_take_percent' => 5,
'seed_pool' => 10000,
'race_duration' => 1,
],
]
);
(new RunHorseRaceJob($race))->handle(app(ChatStateService::class));
$race->refresh();
$this->assertSame('settled', $race->status);
$this->assertNotNull($race->winner_horse_id);
$this->assertNotNull($race->settled_at);
Queue::assertPushed(SaveMessageJob::class, 2);
Queue::assertNotPushed(CloseHorseRaceJob::class);
}
}
+568
View File
@@ -0,0 +1,568 @@
<?php
/**
* 文件功能:猜成语控制器与后台参数测试
*
* 覆盖猜成语后台过期时间配置、手动出题清理超时回合、
* 以及超时后禁止继续答题的关键行为。
*/
namespace Tests\Feature;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Models\Room;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\RiddleGameService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* 类功能:验证猜成语后台配置与回合过期逻辑。
*/
class RiddleQuizControllerTest extends TestCase
{
/**
* 方法功能:为本组测试准备隔离表结构,不触碰本地业务数据库。
*/
protected function setUp(): void
{
parent::setUp();
$this->rebuildTestingTables();
}
/**
* 方法功能:验证后台可以保存题目过期时间配置。
*/
public function test_service_uses_legacy_idiom_config_for_brain_teaser_scope_and_interval(): void
{
$room = Room::create(['room_name' => '测试房间']);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 88,
'reward_exp' => 66,
'auto_start_interval' => 9,
'expire_minutes' => 7,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
/** @var RiddleGameService $service */
$service = $this->app->make(RiddleGameService::class);
$this->assertSame(9, $service->getAutoStartInterval(Riddle::TYPE_BRAIN_TEASER));
$this->assertSame(7, $service->getExpireMinutes(Riddle::TYPE_BRAIN_TEASER));
$this->assertSame([$room->id], $service->getScopedRoomIds(Riddle::TYPE_BRAIN_TEASER));
}
/**
* 方法功能:验证 single 房间范围模式只取配置中的首个房间。
*/
public function test_service_only_uses_first_room_when_scope_mode_is_single(): void
{
$roomOne = Room::create(['room_name' => '测试房间1']);
$roomTwo = Room::create(['room_name' => '测试房间2']);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 3,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$roomOne->id, $roomTwo->id],
],
]);
/** @var RiddleGameService $service */
$service = $this->app->make(RiddleGameService::class);
$this->assertSame([$roomOne->id], $service->getScopedRoomIds(Riddle::TYPE_IDIOM));
}
/**
* 方法功能:验证手动出题前会清理已超时的旧回合,避免阻塞新题。
*/
public function test_start_clears_expired_round_before_creating_new_round(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '猜成语房间']);
$idiom = $this->createActiveIdiom();
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜成语',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$expiredRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 50,
'reward_exp' => 30,
'started_at' => now()->subMinutes(10),
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
]);
$response->assertOk()
->assertJsonPath('status', 'success');
$expiredRound->refresh();
$this->assertSame('ended', $expiredRound->status);
$this->assertNotNull($expiredRound->ended_at);
$this->assertDatabaseCount('idiom_game_rounds', 2);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
]);
}
/**
* 方法功能:验证旧路由在传入脑筋急转弯题型时也能正常手动开题。
*/
public function test_start_supports_brain_teaser_quiz_type_on_legacy_route(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '猜谜房间']);
$brainTeaser = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 66,
'reward_exp' => 44,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER)
->assertJsonPath('data.hint', $brainTeaser->hint);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $room->id,
'idiom_id' => $brainTeaser->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
]);
}
/**
* 方法功能:验证手动出题会结束当前同题型旧回合并直接开启新题。
*/
public function test_start_manually_ends_previous_active_round_of_same_quiz_type(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '手动出题房间']);
$questionOne = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
$questionTwo = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '回声',
hint: '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 66,
'reward_exp' => 44,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$oldRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $questionOne->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
'reward_gold' => 66,
'reward_exp' => 44,
'started_at' => now()->subMinute(),
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER);
$oldRound->refresh();
$this->assertSame('ended', $oldRound->status);
$this->assertNotNull($oldRound->ended_at);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
]);
$this->assertSame(2, RiddleGameRound::query()->where('room_id', $room->id)->count());
$this->assertSame(1, RiddleGameRound::query()
->where('room_id', $room->id)
->where('quiz_type', Riddle::TYPE_BRAIN_TEASER)
->where('status', 'active')
->count());
}
/**
* 方法功能:验证游戏总开关关闭时返回明确提示,而不是误报题库为空。
*/
public function test_start_returns_clear_message_when_riddle_activity_is_disabled(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '猜谜房间']);
$this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => false,
'params' => [
'reward_gold' => 66,
'reward_exp' => 44,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
]);
$response->assertStatus(400)
->assertJsonPath('status', 'error')
->assertJsonPath('message', '猜谜活动未开启,请先到游戏管理中开启后再出题。');
}
/**
* 方法功能:验证回合超时后不能继续答题,也不会发放奖励。
*/
public function test_answer_rejects_expired_round_and_marks_it_ended(): void
{
$this->mockChatStateService();
$player = User::factory()->create([
'username' => '答题用户',
'exp_num' => 10,
]);
$room = Room::create(['room_name' => '答题房间']);
$idiom = $this->createActiveIdiom();
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜成语',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 20,
'reward_exp' => 15,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$round = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 20,
'reward_exp' => 15,
'started_at' => now()->subMinutes(6),
]);
$response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [
'round_id' => $round->id,
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'answer' => '画蛇添足',
]);
$response->assertStatus(400)
->assertJsonPath('status', 'error')
->assertJsonPath('message', '该回合已超时结束');
$round->refresh();
$player->refresh();
$this->assertSame('ended', $round->status);
$this->assertNotNull($round->ended_at);
$this->assertNull($round->winner_id);
$this->assertSame(10, $player->exp_num);
}
/**
* 方法功能:验证自动出题会按房间和题型两个维度独立判断。
*/
public function test_auto_start_checks_room_and_quiz_type_independently(): void
{
$this->mockChatStateService();
$roomOne = Room::create(['room_name' => '一号房']);
$roomTwo = Room::create(['room_name' => '二号房']);
$idiom = $this->createQuestion(
type: Riddle::TYPE_IDIOM,
answer: '画蛇添足',
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
);
$brainTeaser = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 30,
'reward_exp' => 18,
'auto_start_interval' => 1,
'expire_minutes' => 5,
'room_scope_mode' => 'multiple',
'room_ids' => [$roomOne->id, $roomTwo->id],
],
]);
// 一号房已有进行中的成语题,只允许系统补开脑筋急转弯题。
RiddleGameRound::create([
'room_id' => $roomOne->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 30,
'reward_exp' => 18,
'started_at' => now()->subSeconds(30),
]);
/** @var RiddleGameService $service */
$service = $this->app->make(RiddleGameService::class);
$startedCount = $service->autoStartEligibleRounds();
$this->assertSame(3, $startedCount);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomOne->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'idiom_id' => $idiom->id,
'status' => 'active',
]);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomOne->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'idiom_id' => $brainTeaser->id,
'status' => 'active',
]);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomTwo->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'idiom_id' => $idiom->id,
'status' => 'active',
]);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomTwo->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'idiom_id' => $brainTeaser->id,
'status' => 'active',
]);
}
/**
* 方法功能:创建站长账号,满足后台权限与手动出题权限要求。
*/
private function createSiteOwner(): User
{
return User::factory()->create([
'id' => 1,
'user_level' => 100,
'username' => '站长',
]);
}
/**
* 方法功能:创建一条可用于猜成语测试的启用题目。
*/
private function createActiveIdiom(): Riddle
{
return $this->createQuestion(
type: Riddle::TYPE_IDIOM,
answer: '画蛇添足',
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
);
}
/**
* 方法功能:按指定题型创建一条启用中的测试题目。
*/
private function createQuestion(string $type, string $answer, string $hint): Riddle
{
return Riddle::create([
'type' => $type,
'answer' => $answer,
'hint' => $hint,
'sort' => 1,
'is_active' => true,
]);
}
/**
* 方法功能:重建本组测试所需的最小表结构。
*/
private function rebuildTestingTables(): void
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('idiom_game_rounds');
Schema::dropIfExists('idioms');
Schema::dropIfExists('game_configs');
Schema::dropIfExists('rooms');
Schema::dropIfExists('users');
Schema::enableForeignKeyConstraints();
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('username')->nullable();
$table->string('email')->nullable();
$table->string('password')->nullable();
$table->string('remember_token', 100)->nullable();
$table->unsignedTinyInteger('sex')->default(1);
$table->text('custom_join_message')->nullable();
$table->text('custom_leave_message')->nullable();
$table->integer('user_level')->default(1);
$table->integer('exp_num')->default(0);
$table->timestamps();
});
Schema::create('rooms', function (Blueprint $table): void {
$table->id();
$table->string('room_name')->nullable();
$table->string('room_owner')->nullable();
$table->timestamps();
});
Schema::create('game_configs', function (Blueprint $table): void {
$table->id();
$table->string('game_key')->unique();
$table->string('name')->nullable();
$table->string('icon')->nullable();
$table->text('description')->nullable();
$table->boolean('enabled')->default(false);
$table->json('params')->nullable();
$table->timestamps();
});
Schema::create('idioms', function (Blueprint $table): void {
$table->id();
$table->string('type', 30)->default(Riddle::TYPE_IDIOM);
$table->string('answer', 50);
$table->string('hint', 255);
$table->boolean('is_active')->default(true);
$table->integer('sort')->default(0);
$table->timestamps();
});
Schema::create('idiom_game_rounds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('room_id');
$table->unsignedBigInteger('idiom_id');
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM);
$table->string('status', 20)->default('pending');
$table->integer('reward_gold')->default(0);
$table->integer('reward_exp')->default(0);
$table->unsignedBigInteger('winner_id')->nullable();
$table->string('winner_username', 50)->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('ended_at')->nullable();
$table->timestamps();
});
}
/**
* 方法功能:替换聊天室状态服务,避免测试依赖真实 Redis。
*/
private function mockChatStateService(): void
{
$chatStateService = \Mockery::mock(ChatStateService::class);
$chatStateService->shouldReceive('nextMessageId')->andReturn(1);
$chatStateService->shouldReceive('pushMessage')->andReturnNull();
$this->app->instance(ChatStateService::class, $chatStateService);
}
}
+74 -2
View File
@@ -417,7 +417,7 @@ class UserControllerTest extends TestCase
}
/**
* 测试可以保存聊天室屏蔽禁音偏好。
* 测试可以保存聊天室屏蔽禁音与字号偏好。
*/
public function test_can_update_chat_preferences(): void
{
@@ -428,6 +428,7 @@ class UserControllerTest extends TestCase
$response = $this->actingAs($user)->putJson('/user/chat-preferences', [
'blocked_system_senders' => ['钓鱼播报', '神秘箱子', '跑马'],
'sound_muted' => true,
'font_size' => 22,
]);
$response->assertOk()
@@ -435,12 +436,83 @@ class UserControllerTest extends TestCase
->assertJsonPath('data.blocked_system_senders.0', '钓鱼播报')
->assertJsonPath('data.blocked_system_senders.1', '神秘箱子')
->assertJsonPath('data.blocked_system_senders.2', '跑马')
->assertJsonPath('data.sound_muted', true);
->assertJsonPath('data.sound_muted', true)
->assertJsonPath('data.font_size', 22);
$user->refresh();
$this->assertEquals([
'blocked_system_senders' => ['钓鱼播报', '神秘箱子', '跑马'],
'sound_muted' => true,
'font_size' => 22,
], $user->chat_preferences);
}
/**
* 测试保存屏蔽偏好时未提交字号不会清空已有字号。
*/
public function test_chat_preferences_keep_existing_font_size_when_missing_from_payload(): void
{
$user = User::factory()->create([
'chat_preferences' => [
'blocked_system_senders' => [],
'sound_muted' => false,
'font_size' => 18,
],
]);
$response = $this->actingAs($user)->putJson('/user/chat-preferences', [
'blocked_system_senders' => ['钓鱼播报'],
'sound_muted' => false,
]);
$response->assertOk()
->assertJsonPath('data.font_size', 18);
$user->refresh();
$this->assertSame(18, $user->chat_preferences['font_size']);
}
/**
* 测试非法聊天室字号会返回校验错误。
*/
public function test_invalid_chat_font_size_returns_validation_error(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->putJson('/user/chat-preferences', [
'blocked_system_senders' => [],
'sound_muted' => false,
'font_size' => 31,
]);
$response->assertStatus(422)
->assertJsonValidationErrors('font_size');
}
/**
* 测试猜谜活动新文案会兼容落回旧的屏蔽键。
*/
public function test_quiz_activity_block_preference_is_normalized_to_legacy_sender_key(): void
{
$user = User::factory()->create([
'chat_preferences' => null,
]);
$response = $this->actingAs($user)->putJson('/user/chat-preferences', [
'blocked_system_senders' => ['猜谜活动', '钓鱼播报'],
'sound_muted' => false,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.blocked_system_senders.0', '猜成语')
->assertJsonPath('data.blocked_system_senders.1', '钓鱼播报')
->assertJsonPath('data.sound_muted', false);
$user->refresh();
$this->assertEquals([
'blocked_system_senders' => ['猜成语', '钓鱼播报'],
'sound_muted' => false,
], $user->chat_preferences);
}