重构猜谜活动并统一聊天室答题通知

This commit is contained in:
pllx
2026-04-29 13:35:20 +08:00
parent 192259f0a4
commit fe3e74b5f8
34 changed files with 3369 additions and 1833 deletions
+2 -2
View File
@@ -158,7 +158,7 @@ enum CurrencySource: string
/** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/** 猜成语游戏奖励 */
/** 猜谜活动奖励 */
case GAME_REWARD = 'game_reward';
/**
@@ -213,7 +213,7 @@ enum CurrencySource: string
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜成语奖励',
self::GAME_REWARD => '猜谜活动奖励',
};
}
}
@@ -1,13 +1,9 @@
<?php
/**
* 文件功能:猜成语答题结果广播事件
* 文件功能:猜谜活动答题结果广播事件
*
* 用户答对成语时广播,通知房间内所有用户结果。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* 用户答对题目时广播,通知房间内所有用户结果。
*/
namespace App\Events;
@@ -18,27 +14,29 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class IdiomGameAnswered implements ShouldBroadcastNow
/**
* 类功能:向指定房间广播猜谜活动答题结果。
*/
class RiddleGameAnswered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $roomId 房间 ID
* @param int $roundId 游戏回合 ID
* @param string $answer 正确答案
* @param string $winnerUsername 答对的用户名
* @param int $rewardGold 获得的金币
* @param int $rewardExp 获得的经验
* 方法功能:构造答题成功广播事件载荷。
*/
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 [
@@ -46,10 +44,22 @@ class IdiomGameAnswered implements ShouldBroadcastNow
];
}
/**
* 方法功能:声明广播数据。
*/
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,
@@ -1,13 +1,9 @@
<?php
/**
* 文件功能:猜成语游戏开始广播事件
* 文件功能:猜谜活动开始广播事件
*
* 管理员手动出题时触发,广播成语提示到聊天室,前端显示提示+答题按钮
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* 管理员手动出题或系统自动出题时触发,广播提示到聊天室。
*/
namespace App\Events;
@@ -18,19 +14,19 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class IdiomGameStarted implements ShouldBroadcastNow
/**
* 类功能:向指定房间广播新一轮猜谜活动题目。
*/
class RiddleGameStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $roomId 房间 ID
* @param string $hint 谜语提示
* @param int $roundId 游戏回合 ID(前端提交答案时带上)
* @param int $rewardGold 答对奖励金币
* @param int $rewardExp 答对奖励经验
* 方法功能:构造新回合广播事件载荷。
*/
public function __construct(
public readonly int $roomId,
public readonly string $quizType,
public readonly string $hint,
public readonly int $roundId,
public readonly int $rewardGold = 0,
@@ -38,7 +34,7 @@ class IdiomGameStarted implements ShouldBroadcastNow
) {}
/**
* 广播频道
* 方法功能:声明广播频道
*/
public function broadcastOn(): array
{
@@ -48,16 +44,24 @@ class IdiomGameStarted implements ShouldBroadcastNow
}
/**
* 广播数据
* 方法功能:声明广播数据
*/
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' => "🧩 猜成语时间!{$this->hint}",
'message' => "📣 【猜谜活动·{$quizTypeLabel}】第 #{$this->roundId} 题开始!题面:{$this->hint}",
];
}
}
@@ -1,128 +0,0 @@
<?php
/**
* 文件功能:猜成语题库后台管理控制器
* 提供成语题目的列表展示、创建、编辑、删除、启用/禁用功能
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\GameConfig;
use App\Models\Idiom;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:负责猜成语题库与后台参数管理。
*/
class IdiomController extends Controller
{
/**
* 方法功能:显示所有成语题目列表。
*/
public function index(): View
{
$idioms = Idiom::orderBy('sort')->orderBy('id')->get();
return view('admin.idioms.index', compact('idioms'));
}
/**
* 方法功能:创建新的成语题目。
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'answer' => 'required|string|max:50',
'hint' => 'required|string|max:255',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active', true);
Idiom::create($data);
return redirect()->route('admin.idioms.index')->with('success', '成语题目已添加!');
}
/**
* 方法功能:更新已有成语题目。
*/
public function update(Request $request, Idiom $idiom): RedirectResponse
{
$data = $request->validate([
'answer' => 'required|string|max:50',
'hint' => 'required|string|max:255',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active');
$idiom->update($data);
return redirect()->route('admin.idioms.index')->with('success', "题目「{$idiom->answer}」已更新!");
}
/**
* 方法功能:通过 AJAX 切换题目的启用状态。
*/
public function toggle(Idiom $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(Idiom $idiom): RedirectResponse
{
$answer = $idiom->answer;
$idiom->delete();
return redirect()->route('admin.idioms.index')->with('success', "题目「{$answer}」已删除!");
}
/**
* 方法功能:保存猜成语游戏参数而不覆盖其他游戏配置字段。
*/
public function saveSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'reward_gold' => 'required|integer|min:0',
'reward_exp' => 'required|integer|min:0',
'auto_start_interval' => 'required|integer|min:0',
'expire_minutes' => 'required|integer|min:0',
]);
$config = GameConfig::firstOrCreate(
['game_key' => 'idiom'],
['name' => '猜成语', 'icon' => '🧩', 'enabled' => false],
);
// 合并现有 params,只覆盖本次后台提交的字段,避免误删其他游戏参数。
$existingParams = $config->params ?? [];
$config->params = array_merge($existingParams, [
'reward_gold' => (int) $data['reward_gold'],
'reward_exp' => (int) $data['reward_exp'],
'auto_start_interval' => (int) $data['auto_start_interval'],
'expire_minutes' => (int) $data['expire_minutes'],
]);
$config->save();
$config->clearCache();
return redirect()->route('admin.idioms.index')->with('success', '游戏参数已保存!');
}
}
@@ -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;
}
}
@@ -1,290 +0,0 @@
<?php
/**
* 文件功能:猜成语游戏控制器
*
* 负责出题、答题、查询当前回合状态等游戏核心逻辑。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\IdiomGameAnswered;
use App\Events\IdiomGameStarted;
use App\Models\GameConfig;
use App\Models\Idiom;
use App\Models\IdiomGameRound;
use App\Services\ChatStateService;
use App\Services\IdiomGameService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:处理猜成语出题、答题和当前回合查询。
*/
class IdiomQuizController extends Controller
{
/**
* 方法功能:注入猜成语所需的状态、结算与奖励服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly IdiomGameService $idiomGameService,
private readonly UserCurrencyService $currencyService,
) {}
/**
* 管理员手动出题(POST
*/
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);
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
// 先清理该房间已超时但未结算的旧回合,避免它们长期卡住新题。
$this->idiomGameService->expireActiveRoundsForRoom($roomId);
// 清理后再检查是否还有真正进行中的回合。
$activeRound = IdiomGameRound::where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->first();
if ($activeRound) {
return response()->json([
'status' => 'error',
'message' => '当前房间已有进行中的猜成语题目,请先结束当前回合。',
], 400);
}
// 随机选一道启用的题目
$idiom = Idiom::where('is_active', true)->inRandomOrder()->first();
if (! $idiom) {
return response()->json(['status' => 'error', 'message' => '题库中没有可用的题目,请先在后台添加。'], 400);
}
// 读取游戏配置,保证手动出题和自动出题使用同一组奖励参数。
$config = GameConfig::forGame('idiom');
$params = $config?->params ?? [];
$rewardGold = (int) ($params['reward_gold'] ?? 50);
$rewardExp = (int) ($params['reward_exp'] ?? 30);
// 创建新回合,并以 started_at 作为后续过期计时的起点。
$round = IdiomGameRound::create([
'room_id' => $roomId,
'idiom_id' => $idiom->id,
'status' => 'active',
'reward_gold' => $rewardGold,
'reward_exp' => $rewardExp,
'started_at' => now(),
]);
// 广播到聊天室,让前端即时展示题目提示与答题按钮。
broadcast(new IdiomGameStarted(
roomId: $roomId,
hint: $idiom->hint,
roundId: $round->id,
rewardGold: $rewardGold,
rewardExp: $rewardExp,
));
// 同时推一条公屏消息,兼容现有聊天窗口的消息渲染链路。
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "🧩 猜成语时间!{$idiom->hint}",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'idiom_game_round_id' => $round->id,
'idiom_reward_gold' => $rewardGold,
'idiom_reward_exp' => $rewardExp,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new \App\Events\MessageSent($roomId, $msg));
return response()->json([
'status' => 'success',
'data' => [
'round_id' => $round->id,
'hint' => $idiom->hint,
'reward_gold' => $rewardGold,
'reward_exp' => $rewardExp,
],
]);
}
/**
* 方法功能:提交当前猜成语回合的答案。
*/
public function answer(Request $request): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$roundId = (int) $request->input('round_id');
$userAnswer = trim((string) $request->input('answer', ''));
$roomId = (int) $request->input('room_id');
if ($roundId <= 0 || $userAnswer === '' || $roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
}
// 查找回合
$round = IdiomGameRound::with('idiom')->find($roundId);
if (! $round || $round->room_id !== $roomId) {
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
}
// 判题前先处理超时,避免用户在无效回合上继续抢答。
if ($this->idiomGameService->expireRound($round)) {
return response()->json(['status' => 'error', 'message' => '该回合已超时结束'], 400);
}
if ($round->status !== 'active') {
if ($round->status === 'answered') {
return response()->json([
'status' => 'error',
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
], 400);
}
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
}
// 校验答案时忽略空格与大小写差异,降低正常输入误判率。
$normalizedAnswer = str_replace(' ', '', $userAnswer);
$normalizedCorrect = str_replace(' ', '', $round->idiom->answer);
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
return response()->json([
'status' => 'error',
'message' => '答案不正确,再想想!',
], 200);
}
// 答对后立即加 Redis 锁,防止多人并发提交造成重复领奖。
$lockKey = "idiom:answer_lock:{$roundId}";
if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) {
return response()->json([
'status' => 'error',
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
], 400);
}
\Illuminate\Support\Facades\Redis::expire($lockKey, 10);
// 抢答成功后立刻封盘,确保后续请求统一看到 answered 状态。
$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,
"猜成语答对「{$round->idiom->answer}」奖励",
$roomId,
);
}
if ($round->reward_exp > 0) {
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
$user->save();
}
// 广播结果,让房间内用户立即看到答题成功公告。
broadcast(new IdiomGameAnswered(
roomId: $roomId,
roundId: $round->id,
answer: $round->idiom->answer,
winnerUsername: $user->username,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
// 存聊天记录但不再次广播,避免和上面的实时事件重复刷屏。
$resultMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "🎉 【{$user->username}】率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => 'idiom_result',
'winner_username' => $user->username,
'idiom_answer' => $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(),
];
$this->chatState->pushMessage($roomId, $resultMsg);
\Illuminate\Support\Facades\Redis::del($lockKey);
return response()->json([
'status' => 'success',
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'data' => [
'answer' => $round->idiom->answer,
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
],
]);
}
/**
* 方法功能:查询当前房间的进行中回合。
*/
public function current(Request $request): JsonResponse
{
$roomId = (int) $request->input('room_id', 0);
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
$round = IdiomGameRound::with('idiom')
->where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->first();
if (! $round) {
return response()->json(['status' => 'success', 'data' => null]);
}
// 当前接口不再暴露已过期回合,避免前端继续显示无效答题入口。
if ($this->idiomGameService->expireRound($round)) {
return response()->json(['status' => 'success', 'data' => null]);
}
return response()->json([
'status' => 'success',
'data' => [
'round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
],
]);
}
}
@@ -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,
],
]);
}
}
+9 -1
View File
@@ -309,10 +309,18 @@ class UserController extends Controller
{
$user = Auth::user();
$data = $request->validated();
$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'],
];
@@ -35,7 +35,7 @@ class UpdateChatPreferencesRequest extends FormRequest
'blocked_system_senders' => ['nullable', 'array'],
'blocked_system_senders.*' => [
'string',
Rule::in(['钓鱼播报', '星海小博士', '百家乐', '跑马','神秘箱子']),
Rule::in(['钓鱼播报', '猜成语', '猜谜活动', '星海小博士', '百家乐', '跑马', '神秘箱子']),
],
'sound_muted' => ['required', 'boolean'],
];
-33
View File
@@ -1,33 +0,0 @@
<?php
/**
* 文件功能:猜成语题目模型
*
* 对应 idioms 表,每条记录是一道成语题目 + 谜语提示。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Idiom extends Model
{
protected $fillable = [
'answer',
'hint',
'is_active',
'sort',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort' => 'integer',
];
}
}
+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);
}
}
@@ -1,13 +1,9 @@
<?php
/**
* 文件功能:猜成语游戏回合模型
* 文件功能:猜谜活动回合模型
*
* 每次出题对应一个回合,记录题目、状态、奖励和获胜者。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
* 每次出题对应一个回合,记录题型、题目、状态、奖励和获胜者。
*/
namespace App\Models;
@@ -16,10 +12,17 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 类功能:记录猜成语每一轮的题、奖励与结算状态。
* 类功能:记录猜谜活动每一轮的题、奖励与结算状态。
*/
class IdiomGameRound extends Model
class RiddleGameRound extends Model
{
/**
* 属性功能:显式绑定历史回合表名,避免类名重命名后推导到错误表。
*
* @var string
*/
protected $table = 'idiom_game_rounds';
/**
* 方法功能:声明可批量赋值的回合字段。
*
@@ -28,6 +31,7 @@ class IdiomGameRound extends Model
protected $fillable = [
'room_id',
'idiom_id',
'quiz_type',
'status',
'reward_gold',
'reward_exp',
@@ -45,6 +49,8 @@ class IdiomGameRound extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'idiom_id' => 'integer',
'reward_gold' => 'integer',
'reward_exp' => 'integer',
'started_at' => 'datetime',
@@ -53,10 +59,10 @@ class IdiomGameRound extends Model
}
/**
* 方法功能:关联本回合对应的成语题目。
* 方法功能:关联本回合对应的猜谜题目。
*/
public function idiom(): BelongsTo
{
return $this->belongsTo(Idiom::class);
return $this->belongsTo(Riddle::class);
}
}
-124
View File
@@ -1,124 +0,0 @@
<?php
/**
* 文件功能:猜成语游戏回合服务
*
* 统一处理猜成语回合的过期判定、超时结算和系统消息广播,
* 避免控制器与定时任务各自维护一套回合状态逻辑。
*/
namespace App\Services;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\IdiomGameRound;
/**
* 类功能:提供猜成语回合过期与结算能力。
*/
class IdiomGameService
{
/**
* 方法功能:注入聊天室状态服务,复用现有公屏消息推送链路。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 方法功能:读取猜成语题目的有效时长配置,单位分钟。
*/
public function getExpireMinutes(): int
{
return max(0, (int) GameConfig::param('idiom', 'expire_minutes', 5));
}
/**
* 方法功能:判断指定回合是否已经超过有效时长。
*/
public function isRoundExpired(IdiomGameRound $round): bool
{
$expireMinutes = $this->getExpireMinutes();
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(IdiomGameRound $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): int
{
$expiredCount = 0;
IdiomGameRound::with('idiom')
->where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->orderBy('id')
->get()
->each(function (IdiomGameRound $round) use ($announce, &$expiredCount): void {
if ($this->expireRound($round, $announce)) {
$expiredCount++;
}
});
return $expiredCount;
}
/**
* 方法功能:向公屏推送猜成语超时公告。
*/
public function pushExpiredRoundMessage(IdiomGameRound $round): void
{
$answer = $round->idiom?->answer ?? '未知答案';
$message = [
'id' => $this->chatState->nextMessageId($round->room_id),
'room_id' => $round->room_id,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "⌛ 本轮猜成语已超时结束,正确答案是「{$answer}」。",
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '',
'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));
}
}
+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;
}
}