完善猜成语过期与答题记录逻辑
This commit is contained in:
@@ -12,16 +12,20 @@
|
||||
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
|
||||
{
|
||||
@@ -31,7 +35,7 @@ class IdiomController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新题目
|
||||
* 方法功能:创建新的成语题目。
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
@@ -49,7 +53,7 @@ class IdiomController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新题目
|
||||
* 方法功能:更新已有成语题目。
|
||||
*/
|
||||
public function update(Request $request, Idiom $idiom): RedirectResponse
|
||||
{
|
||||
@@ -67,7 +71,7 @@ class IdiomController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换启用/禁用(AJAX)
|
||||
* 方法功能:通过 AJAX 切换题目的启用状态。
|
||||
*/
|
||||
public function toggle(Idiom $idiom): JsonResponse
|
||||
{
|
||||
@@ -81,7 +85,7 @@ class IdiomController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除题目
|
||||
* 方法功能:删除指定成语题目。
|
||||
*/
|
||||
public function destroy(Idiom $idiom): RedirectResponse
|
||||
{
|
||||
@@ -92,7 +96,7 @@ class IdiomController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存猜成语游戏参数(仅更新 GameConfig params,不影响其他字段)
|
||||
* 方法功能:保存猜成语游戏参数而不覆盖其他游戏配置字段。
|
||||
*/
|
||||
public function saveSettings(Request $request): RedirectResponse
|
||||
{
|
||||
@@ -100,19 +104,21 @@ class IdiomController extends Controller
|
||||
'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 = \App\Models\GameConfig::firstOrCreate(
|
||||
$config = GameConfig::firstOrCreate(
|
||||
['game_key' => 'idiom'],
|
||||
['name' => '猜成语', 'icon' => '🧩', 'enabled' => false],
|
||||
);
|
||||
|
||||
// 合并现有 params,只覆盖提交的字段,不影响其他已有参数
|
||||
// 合并现有 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();
|
||||
|
||||
@@ -18,15 +18,23 @@ 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,
|
||||
) {}
|
||||
|
||||
@@ -37,7 +45,7 @@ class IdiomQuizController extends Controller
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// 权限校验:仅站长或 superlevel
|
||||
// 权限校验:仅站长或具备后台身份的管理用户可手动出题。
|
||||
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
|
||||
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
|
||||
}
|
||||
@@ -47,7 +55,10 @@ class IdiomQuizController extends Controller
|
||||
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
|
||||
}
|
||||
|
||||
// 检查是否有进行中的回合
|
||||
// 先清理该房间已超时但未结算的旧回合,避免它们长期卡住新题。
|
||||
$this->idiomGameService->expireActiveRoundsForRoom($roomId);
|
||||
|
||||
// 清理后再检查是否还有真正进行中的回合。
|
||||
$activeRound = IdiomGameRound::where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
@@ -64,13 +75,13 @@ class IdiomQuizController extends Controller
|
||||
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,
|
||||
@@ -80,7 +91,7 @@ class IdiomQuizController extends Controller
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
// 广播到聊天室
|
||||
// 广播到聊天室,让前端即时展示题目提示与答题按钮。
|
||||
broadcast(new IdiomGameStarted(
|
||||
roomId: $roomId,
|
||||
hint: $idiom->hint,
|
||||
@@ -89,7 +100,7 @@ class IdiomQuizController extends Controller
|
||||
rewardExp: $rewardExp,
|
||||
));
|
||||
|
||||
// 同时也推一条 MessageSent 消息(显示在聊天窗口)
|
||||
// 同时推一条公屏消息,兼容现有聊天窗口的消息渲染链路。
|
||||
$msg = [
|
||||
'id' => $this->chatState->nextMessageId($roomId),
|
||||
'room_id' => $roomId,
|
||||
@@ -119,10 +130,7 @@ class IdiomQuizController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交答案(POST)
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
* 方法功能:提交当前猜成语回合的答案。
|
||||
*/
|
||||
public function answer(Request $request): JsonResponse
|
||||
{
|
||||
@@ -145,6 +153,11 @@ class IdiomQuizController extends Controller
|
||||
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([
|
||||
@@ -152,10 +165,11 @@ class IdiomQuizController extends Controller
|
||||
'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)) {
|
||||
@@ -165,7 +179,7 @@ class IdiomQuizController extends Controller
|
||||
], 200);
|
||||
}
|
||||
|
||||
// 答对了!加锁防并发(Redis)
|
||||
// 答对后立即加 Redis 锁,防止多人并发提交造成重复领奖。
|
||||
$lockKey = "idiom:answer_lock:{$roundId}";
|
||||
if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) {
|
||||
return response()->json([
|
||||
@@ -175,7 +189,7 @@ class IdiomQuizController extends Controller
|
||||
}
|
||||
\Illuminate\Support\Facades\Redis::expire($lockKey, 10);
|
||||
|
||||
// 更新回合状态
|
||||
// 抢答成功后立刻封盘,确保后续请求统一看到 answered 状态。
|
||||
$round->update([
|
||||
'status' => 'answered',
|
||||
'winner_id' => $user->id,
|
||||
@@ -183,7 +197,7 @@ class IdiomQuizController extends Controller
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
// 发放奖励
|
||||
// 奖励仍沿用现有金币与经验发放逻辑,避免行为回归。
|
||||
if ($round->reward_gold > 0) {
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', $round->reward_gold,
|
||||
@@ -197,7 +211,7 @@ class IdiomQuizController extends Controller
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// 广播结果(前端通过 IdiomGameAnswered 事件做分屏显示)
|
||||
// 广播结果,让房间内用户立即看到答题成功公告。
|
||||
broadcast(new IdiomGameAnswered(
|
||||
roomId: $roomId,
|
||||
roundId: $round->id,
|
||||
@@ -207,16 +221,21 @@ class IdiomQuizController extends Controller
|
||||
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} 经验!",
|
||||
'content' => "🎉 【{$user->username}】率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
|
||||
'is_secret' => false,
|
||||
'font_color' => '#16a34a',
|
||||
'action' => '',
|
||||
'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);
|
||||
@@ -235,7 +254,7 @@ class IdiomQuizController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前进行中的回合
|
||||
* 方法功能:查询当前房间的进行中回合。
|
||||
*/
|
||||
public function current(Request $request): JsonResponse
|
||||
{
|
||||
@@ -253,6 +272,11 @@ class IdiomQuizController extends Controller
|
||||
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' => [
|
||||
|
||||
@@ -15,8 +15,16 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 类功能:记录猜成语每一轮的题目、奖励与结算状态。
|
||||
*/
|
||||
class IdiomGameRound extends Model
|
||||
{
|
||||
/**
|
||||
* 方法功能:声明可批量赋值的回合字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'room_id',
|
||||
'idiom_id',
|
||||
@@ -29,6 +37,11 @@ class IdiomGameRound extends Model
|
||||
'ended_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 方法功能:定义回合字段的类型转换规则。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -39,6 +52,9 @@ class IdiomGameRound extends Model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:关联本回合对应的成语题目。
|
||||
*/
|
||||
public function idiom(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Idiom::class);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* 使用 updateOrCreate 确保重复执行不影响已有数据。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
@@ -35,6 +36,7 @@ class IdiomSeeder extends Seeder
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'auto_start_interval' => 0,
|
||||
'expire_minutes' => 5,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -366,41 +366,6 @@ export function bindChatEvents() {
|
||||
}
|
||||
enqueueChatMessage(msg);
|
||||
|
||||
// 猜成语消息:追加【答题】按钮
|
||||
if (msg.idom_game_round_id || msg.idiom_game_round_id) {
|
||||
const roundId = msg.idom_game_round_id || msg.idiom_game_round_id;
|
||||
const hint = msg.content || "";
|
||||
const rewardGold = msg.idiom_reward_gold || 0;
|
||||
const rewardExp = msg.idiom_reward_exp || 0;
|
||||
|
||||
// 延迟等消息渲染完成再追加按钮
|
||||
setTimeout(() => {
|
||||
const containers = [
|
||||
document.getElementById("chat-messages-container"),
|
||||
document.getElementById("chat-messages-container2"),
|
||||
];
|
||||
containers.forEach((container) => {
|
||||
if (!container) return;
|
||||
const lastMsg = container.lastElementChild;
|
||||
if (!lastMsg || lastMsg.querySelector("[data-idiom-answer-btn]")) return;
|
||||
if (lastMsg.dataset.fromUser !== "星海小博士") return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.idiomAnswerBtn = String(roundId);
|
||||
btn.dataset.idiomHint = hint;
|
||||
btn.dataset.idiomGold = String(rewardGold);
|
||||
btn.dataset.idiomExp = String(rewardExp);
|
||||
btn.textContent = "🎯 答题";
|
||||
btn.style.cssText =
|
||||
"margin-left:8px;padding:2px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
|
||||
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
|
||||
"font-weight:bold;vertical-align:middle;";
|
||||
lastMsg.appendChild(btn);
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (msg.action === "vip_presence" && typeof window.showVipPresenceBanner === "function") {
|
||||
window.showVipPresenceBanner(msg);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,115 @@ function csrf() {
|
||||
let currentRoundId = 0;
|
||||
let currentRoomId = 0;
|
||||
|
||||
/**
|
||||
* 为指定回合创建统一样式的答题按钮。
|
||||
*/
|
||||
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.idiomAnswerBtn = String(roundId);
|
||||
btn.dataset.idiomHint = hint;
|
||||
btn.dataset.idiomGold = String(rewardGold);
|
||||
btn.dataset.idiomExp = String(rewardExp);
|
||||
btn.textContent = "🎯 答题";
|
||||
btn.style.cssText =
|
||||
"margin-left:8px;padding:2px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
|
||||
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
|
||||
"font-weight:bold;vertical-align:middle;";
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定回合的所有答题按钮。
|
||||
*/
|
||||
export function removeIdiomAnswerButtons(roundId = 0) {
|
||||
const selector = roundId > 0
|
||||
? `[data-idiom-answer-btn="${roundId}"]`
|
||||
: "[data-idiom-answer-btn]";
|
||||
|
||||
document.querySelectorAll(selector).forEach((button) => button.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
|
||||
*/
|
||||
export function attachIdiomAnswerButton(messageNode, message) {
|
||||
if (!messageNode || !message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roundId = Number.parseInt(
|
||||
String(message.idiom_game_round_id || message.idom_game_round_id || "0"),
|
||||
10,
|
||||
);
|
||||
if (roundId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.parseInt(String(message.idiom_game_round_ended_id || "0"), 10) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.from_user !== "星海小博士") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageNode.querySelector(`[data-idiom-answer-btn="${roundId}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hint = String(message.content || "");
|
||||
const rewardGold = Number.parseInt(String(message.idiom_reward_gold || "0"), 10);
|
||||
const rewardExp = Number.parseInt(String(message.idiom_reward_exp || "0"), 10);
|
||||
const button = buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp);
|
||||
const timeNode = messageNode.querySelector(".msg-time");
|
||||
|
||||
if (timeNode?.parentNode) {
|
||||
timeNode.parentNode.insertBefore(button, timeNode.nextSibling);
|
||||
return;
|
||||
}
|
||||
|
||||
messageNode.appendChild(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
|
||||
*/
|
||||
async function syncCurrentIdiomRound() {
|
||||
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
|
||||
if (roomId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentRoomId = roomId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/idiom-quiz/current?room_id=${roomId}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const activeRoundId = Number.parseInt(String(data?.data?.round_id || "0"), 10);
|
||||
|
||||
currentRoundId = activeRoundId;
|
||||
|
||||
if (activeRoundId <= 0) {
|
||||
removeIdiomAnswerButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-idiom-answer-btn]").forEach((button) => {
|
||||
if (button.dataset.idiomAnswerBtn !== String(activeRoundId)) {
|
||||
button.remove();
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
|
||||
*/
|
||||
@@ -31,6 +140,7 @@ function handleIdiomGameAnswered(e) {
|
||||
if (!answer) return;
|
||||
|
||||
currentRoundId = 0;
|
||||
removeIdiomAnswerButtons(round_id);
|
||||
|
||||
// 关闭当前用户的答题弹窗(如果开着的话)
|
||||
const answerModal = document.getElementById("idiom-answer-modal");
|
||||
@@ -38,35 +148,25 @@ function handleIdiomGameAnswered(e) {
|
||||
answerModal.style.display = "none";
|
||||
}
|
||||
|
||||
// ── 分屏文字提示 ──
|
||||
// 回答者 → 包厢(chat-messages-container2)
|
||||
// 其他人 → 公屏(chat-messages-container)
|
||||
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
|
||||
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 div = document.createElement("div");
|
||||
div.className = "msg-line";
|
||||
|
||||
div.innerHTML = `<span style="color:#16a34a;font-weight:bold;">🎉 恭喜 <span class="msg-user" data-chat-message-user data-u="${winner_username}" style="color:#16a34a;cursor:pointer;border-bottom:1px dashed #16a34a;">${winner_username}</span> 率先答对成语「${answer}」,获得 ${reward_gold} 金币、${reward_exp} 经验!</span><span class="msg-time">(${timeStr})</span>`;
|
||||
|
||||
const isWinner = winner_username === (window.chatContext?.username || "");
|
||||
if (isWinner) {
|
||||
// 回答者 → 包厢
|
||||
const say2 = document.getElementById("chat-messages-container2");
|
||||
if (say2) {
|
||||
say2.appendChild(div.cloneNode(true));
|
||||
say2.scrollTop = say2.scrollHeight;
|
||||
}
|
||||
} else {
|
||||
// 其他人 → 公屏
|
||||
const say1 = document.getElementById("chat-messages-container");
|
||||
if (say1) {
|
||||
say1.appendChild(div);
|
||||
say1.scrollTop = say1.scrollHeight;
|
||||
}
|
||||
}
|
||||
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: `idiom-result-live-${round_id}-${Date.now()}`,
|
||||
room_id: currentRoomId || window.chatContext?.roomId || 0,
|
||||
from_user: "星海小博士",
|
||||
to_user: "大家",
|
||||
content: `🎉 【${winner_username}】率先答对成语「${answer}」,获得 ${reward_gold} 金币、${reward_exp} 经验!`,
|
||||
is_secret: false,
|
||||
font_color: "#16a34a",
|
||||
action: "idiom_result",
|
||||
winner_username,
|
||||
idiom_answer: answer,
|
||||
idiom_result_reward_gold: reward_gold,
|
||||
idiom_result_reward_exp: reward_exp,
|
||||
idiom_game_round_ended_id: round_id,
|
||||
sent_at: timeStr,
|
||||
});
|
||||
|
||||
// ── Toast 通知(所有用户都能看到) ──
|
||||
window.chatToast?.show({
|
||||
@@ -76,15 +176,6 @@ function handleIdiomGameAnswered(e) {
|
||||
color: "#16a34a",
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
// ── 标记所有对应 round_id 的【答题】按钮为已答 ──
|
||||
document.querySelectorAll(`[data-idiom-answer-btn="${round_id}"]`).forEach((btn) => {
|
||||
btn.dataset.idiomAnswered = "1";
|
||||
btn.textContent = "✅ 已答";
|
||||
btn.style.background = "#9ca3af";
|
||||
btn.style.cursor = "default";
|
||||
btn.style.opacity = "0.6";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,18 +328,6 @@ export function bindIdiomQuizControls() {
|
||||
const btn = e.target.closest("[data-idiom-answer-btn]");
|
||||
if (!btn) return;
|
||||
|
||||
// 已答完的按钮不可点击
|
||||
if (btn.dataset.idiomAnswered === "1") {
|
||||
window.chatToast?.show({
|
||||
title: "🧩 猜成语",
|
||||
message: "这道题已被答过了,等下一题吧!",
|
||||
icon: "😅",
|
||||
color: "#9ca3af",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const roundId = parseInt(btn.dataset.idiomAnswerBtn || "0", 10);
|
||||
const hint = btn.dataset.idiomHint || "";
|
||||
const rewardGold = parseInt(btn.dataset.idiomGold || "0", 10);
|
||||
@@ -261,7 +340,10 @@ export function bindIdiomQuizControls() {
|
||||
|
||||
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
|
||||
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
|
||||
|
||||
|
||||
window.setTimeout(() => {
|
||||
syncCurrentIdiomRound();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ── 挂载到 window ──
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
|
||||
|
||||
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
|
||||
import { attachIdiomAnswerButton, removeIdiomAnswerButtons } from "./idiom-quiz.js";
|
||||
import { isExpiredChatImageMessage } from "./message-utils.js";
|
||||
import { normalizeDailyStatus, resolveBlockedSystemSenderKey } from "./preferences-status.js";
|
||||
import { escapePresenceText } from "./vip-presence.js";
|
||||
@@ -49,6 +50,21 @@ function parseBracketUsers(content, color = "#000099") {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留包厢窗口最近几条猜成语答题记录,避免答题历史无限堆积。
|
||||
*/
|
||||
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。
|
||||
*/
|
||||
@@ -172,6 +188,14 @@ 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";
|
||||
const winnerUsername = String(msg.winner_username || "");
|
||||
const winnerHtml = clickableUser(winnerUsername, "#16a34a");
|
||||
const answerText = escapeHtml(String(msg.idiom_answer || ""));
|
||||
const rewardGold = Number.parseInt(String(msg.idiom_result_reward_gold ?? msg.reward_gold ?? 0), 10);
|
||||
const rewardExp = Number.parseInt(String(msg.idiom_result_reward_exp ?? msg.reward_exp ?? 0), 10);
|
||||
html = `<span style="color:#16a34a;font-weight:bold;">🎉 【${winnerHtml}】率先答对成语「${answerText}」,获得 ${rewardGold} 金币、${rewardExp} 经验!</span>`;
|
||||
} else if (msg.action === "vip_presence") {
|
||||
const accent = msg.presence_color || "#f59e0b";
|
||||
div.style.cssText =
|
||||
@@ -300,6 +324,12 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
html += ` <span class="msg-time">(${timeStr})</span>`;
|
||||
}
|
||||
div.innerHTML = html;
|
||||
attachIdiomAnswerButton(div, msg);
|
||||
|
||||
// 历史消息恢复或实时结算时,都立即移除对应回合的旧答题按钮。
|
||||
if (Number.parseInt(String(msg.idiom_game_round_ended_id || "0"), 10) > 0) {
|
||||
removeIdiomAnswerButtons(Number.parseInt(String(msg.idiom_game_round_ended_id), 10));
|
||||
}
|
||||
|
||||
// 命中屏蔽规则时,消息仍保留在 DOM 中,便于取消屏蔽后立即恢复显示。
|
||||
if (shouldHideByBlock) {
|
||||
@@ -325,7 +355,8 @@ export function appendMessage(msg, renderBatch = null) {
|
||||
}
|
||||
|
||||
// 路由规则:公众窗口(say1) — 别人的公聊消息;包厢窗口(say2) — 自己发的 + 悄悄话 + 对自己说的
|
||||
const isRelatedToMe = isMe || msg.is_secret || msg.to_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 isAutoSave = (msg.from_user === "系统" || msg.from_user === "") &&
|
||||
@@ -343,12 +374,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 +435,7 @@ export function createChatMessageRenderBatch() {
|
||||
privateFragment: document.createDocumentFragment(),
|
||||
shouldPrunePublic: false,
|
||||
shouldPrunePrivate: false,
|
||||
shouldPrunePrivateIdiomResults: false,
|
||||
shouldScrollPublic: false,
|
||||
shouldScrollPrivate: false,
|
||||
};
|
||||
@@ -429,6 +467,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;
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
<p class="text-xs text-gray-400 mt-1">0=仅手动出题</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $adminListFilterLabelClass }}">题目过期时间(分钟)</label>
|
||||
<input type="number" name="expire_minutes"
|
||||
value="{{ old('expire_minutes', $idiomParams['expire_minutes'] ?? 5) }}" min="0"
|
||||
class="w-full {{ $adminListFilterInputClass }}">
|
||||
<p class="text-xs text-gray-400 mt-1">0=不过期;大于 0 时超时会自动公布答案并结束回合</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button type="submit" class="{{ $adminListPrimaryButtonClass }}">
|
||||
|
||||
+5
-2
@@ -184,6 +184,10 @@ Schedule::call(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先统一结算超时回合,避免旧题长期占用进行中状态。
|
||||
$roomId = 1;
|
||||
app(\App\Services\IdiomGameService::class)->expireActiveRoundsForRoom($roomId);
|
||||
|
||||
$config = \App\Models\GameConfig::forGame('idiom')?->params ?? [];
|
||||
$interval = (int) ($config['auto_start_interval'] ?? 0);
|
||||
if ($interval <= 0) {
|
||||
@@ -191,7 +195,6 @@ Schedule::call(function () {
|
||||
}
|
||||
|
||||
// 检查每个房间是否有进行中的回合(先只处理 1 号房间)
|
||||
$roomId = 1;
|
||||
$activeRound = \App\Models\IdiomGameRound::where('room_id', $roomId)
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->first();
|
||||
@@ -219,7 +222,7 @@ Schedule::call(function () {
|
||||
$rewardGold = (int) ($config['reward_gold'] ?? 50);
|
||||
$rewardExp = (int) ($config['reward_exp'] ?? 30);
|
||||
|
||||
// 创建新回合
|
||||
// 创建新回合,并以 started_at 作为后续过期判断起点。
|
||||
$round = \App\Models\IdiomGameRound::create([
|
||||
'room_id' => $roomId,
|
||||
'idiom_id' => $idiom->id,
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:猜成语控制器与后台参数测试
|
||||
*
|
||||
* 覆盖猜成语后台过期时间配置、手动出题清理超时回合、
|
||||
* 以及超时后禁止继续答题的关键行为。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\Idiom;
|
||||
use App\Models\IdiomGameRound;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证猜成语后台配置与回合过期逻辑。
|
||||
*/
|
||||
class IdiomQuizControllerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 方法功能:为本组测试准备隔离表结构,不触碰本地业务数据库。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->rebuildTestingTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证后台可以保存题目过期时间配置。
|
||||
*/
|
||||
public function test_admin_can_save_idiom_expire_minutes_setting(): void
|
||||
{
|
||||
$this->withoutMiddleware();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
Room::create(['room_name' => '测试房间']);
|
||||
|
||||
$response = $this->actingAs($admin)->post(route('admin.idioms.settings.save'), [
|
||||
'reward_gold' => 88,
|
||||
'reward_exp' => 66,
|
||||
'auto_start_interval' => 9,
|
||||
'expire_minutes' => 7,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.idioms.index'));
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
$config = GameConfig::query()->where('game_key', 'idiom')->firstOrFail();
|
||||
|
||||
$this->assertSame(88, $config->params['reward_gold']);
|
||||
$this->assertSame(66, $config->params['reward_exp']);
|
||||
$this->assertSame(9, $config->params['auto_start_interval']);
|
||||
$this->assertSame(7, $config->params['expire_minutes']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证后台会拦截负数的题目过期时间。
|
||||
*/
|
||||
public function test_admin_cannot_save_negative_idiom_expire_minutes_setting(): void
|
||||
{
|
||||
$this->withoutMiddleware();
|
||||
|
||||
$admin = $this->createSiteOwner();
|
||||
Room::create(['room_name' => '测试房间']);
|
||||
|
||||
$response = $this->from(route('admin.idioms.index'))
|
||||
->actingAs($admin)
|
||||
->post(route('admin.idioms.settings.save'), [
|
||||
'reward_gold' => 88,
|
||||
'reward_exp' => 66,
|
||||
'auto_start_interval' => 9,
|
||||
'expire_minutes' => -1,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.idioms.index'));
|
||||
$response->assertSessionHasErrors('expire_minutes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证手动出题前会清理已超时的旧回合,避免阻塞新题。
|
||||
*/
|
||||
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,
|
||||
],
|
||||
]);
|
||||
|
||||
$expiredRound = IdiomGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 50,
|
||||
'reward_exp' => 30,
|
||||
'started_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('idiom-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,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证回合超时后不能继续答题,也不会发放奖励。
|
||||
*/
|
||||
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,
|
||||
],
|
||||
]);
|
||||
|
||||
$round = IdiomGameRound::create([
|
||||
'room_id' => $room->id,
|
||||
'idiom_id' => $idiom->id,
|
||||
'status' => 'active',
|
||||
'reward_gold' => 20,
|
||||
'reward_exp' => 15,
|
||||
'started_at' => now()->subMinutes(6),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($player)->postJson(route('idiom-quiz.answer'), [
|
||||
'round_id' => $round->id,
|
||||
'room_id' => $room->id,
|
||||
'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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建站长账号,满足后台权限与手动出题权限要求。
|
||||
*/
|
||||
private function createSiteOwner(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'id' => 1,
|
||||
'user_level' => 100,
|
||||
'username' => '站长',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建一条可用于猜成语测试的启用题目。
|
||||
*/
|
||||
private function createActiveIdiom(): Idiom
|
||||
{
|
||||
return Idiom::create([
|
||||
'answer' => '画蛇添足',
|
||||
'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('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('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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user