From 4fe4155ec0862c484e4177de1646d8c62eec0917 Mon Sep 17 00:00:00 2001 From: pllx Date: Wed, 29 Apr 2026 15:23:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=92=93=E9=B1=BC=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B8=8E=E6=B8=B8=E6=88=8F=E9=85=8D=E7=BD=AE=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/GameConfigController.php | 5 +- app/Services/FishingService.php | 43 ++-- resources/js/chat-room/fishing.js | 17 ++ resources/js/chat-room/message-renderer.js | 53 ++++- .../Feature/AdminGameConfigControllerTest.php | 183 ++++++++++++++++++ 5 files changed, 275 insertions(+), 26 deletions(-) create mode 100644 tests/Feature/Feature/AdminGameConfigControllerTest.php diff --git a/app/Http/Controllers/Admin/GameConfigController.php b/app/Http/Controllers/Admin/GameConfigController.php index c724f8f..eb057a4 100644 --- a/app/Http/Controllers/Admin/GameConfigController.php +++ b/app/Http/Controllers/Admin/GameConfigController.php @@ -67,7 +67,10 @@ class GameConfigController extends Controller { // 合并参数,保留已有键,只更新传入的键 $current = $gameConfig->params ?? []; - $validatedParams = $request->validated('params'); + // 这里不能只读取 validated('params')。 + // 当前请求类只对公共房间字段做了显式规则约束,像 fishing_cooldown 这类普通游戏参数 + // 在 validated 数据中会被裁掉,导致后台提示成功但实际没有写入数据库。 + $validatedParams = (array) $request->input('params', []); $updated = array_merge($current, $validatedParams); $scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams); diff --git a/app/Services/FishingService.php b/app/Services/FishingService.php index d6a1007..3b72759 100644 --- a/app/Services/FishingService.php +++ b/app/Services/FishingService.php @@ -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,18 @@ class FishingService // 8. 广播钓鱼结果到聊天室 $promoTag = ''; - if (!$isAi) { + if (! $isAi) { $autoFishingMinutesLeft = $this->shopService->getActiveAutoFishingMinutesLeft($user); $promoTag = $autoFishingMinutesLeft > 0 ? ' 🎣 自动钓鱼卡' + .'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="点击购买自动钓鱼卡">🎣 自动钓鱼卡' : ''; } + // 广播结果时额外带上统一动作标记和钓鱼者用户名, + // 方便前端把“钓鱼者本人”的公屏结果折叠到包厢窗口,避免重复显示。 $sysMsg = [ 'id' => $this->chatState->nextMessageId($roomId), 'room_id' => $roomId, @@ -110,7 +112,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 +157,7 @@ class FishingService return $baseMessage; } - return $baseMessage . '(' . $user->vipName() . '追加:' . implode(',', $bonusParts) . ')'; + return $baseMessage.'('.$user->vipName().'追加:'.implode(',', $bonusParts).')'; } /** @@ -168,7 +171,7 @@ class FishingService { $event = FishingEvent::rollOne(); - if (!$event) { + if (! $event) { return [ 'emoji' => '🐟', 'message' => '钓到一条小鱼,获得金币10', @@ -180,8 +183,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, ]; } } diff --git a/resources/js/chat-room/fishing.js b/resources/js/chat-room/fishing.js index e8a1590..746c473 100644 --- a/resources/js/chat-room/fishing.js +++ b/resources/js/chat-room/fishing.js @@ -241,6 +241,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); @@ -299,6 +300,22 @@ function showAutoFishStopButton(cooldown) { document.body.appendChild(button); } +/** + * 同步更新停止自动钓鱼浮层上的冷却秒数,避免与主按钮倒计时不一致。 + * + * @param {number} cooldown + * @returns {void} + */ +function updateAutoFishStopButtonCountdown(cooldown) { + const hint = document.querySelector("#auto-fish-stop-btn .drag-hint"); + + if (!(hint instanceof HTMLElement)) { + return; + } + + hint.textContent = `冷却 ${Number(cooldown) || 0}s · 可拖动`; +} + /** * 给停止自动钓鱼按钮绑定拖拽和点击停止事件。 * diff --git a/resources/js/chat-room/message-renderer.js b/resources/js/chat-room/message-renderer.js index 1250652..1d1ddac 100644 --- a/resources/js/chat-room/message-renderer.js +++ b/resources/js/chat-room/message-renderer.js @@ -81,7 +81,26 @@ function isRedPacketAnnouncementMessage(msg) { function buildRedPacketAnnouncementHtml(msg, timeStr) { const rawContent = String(msg?.content || ""); const isExpPacket = rawContent.includes("经验的礼包"); - const accentColor = isExpPacket ? "#7c3aed" : "#dc2626"; + 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(/]*)>([\s\S]*?)<\/button>/iu); @@ -99,12 +118,12 @@ function buildRedPacketAnnouncementHtml(msg, timeStr) { const actionButtonHtml = ``; return ` -
-
${icon}
-
+
+
${icon}
+
${buildGameLabelChipHtml("礼包红包", accentColor)} - ${escapeHtml(typeLabel)} + ${escapeHtml(typeLabel)}
${summary} @@ -130,6 +149,26 @@ function buildQuizBadgeHtml(msg, accentColor = "#7c3aed") { `; } +/** + * 判断当前公屏消息是否属于“我自己”的钓鱼结果广播。 + * + * 说明: + * - 收竿后,钓鱼者本人已经会在包厢窗口收到本地结果提示; + * - 这里需要把同一条公屏广播对本人隐藏,避免自己同时看到两条。 + */ +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; +} + /** * 判断当前消息是否应该使用统一的游戏通知卡片。 */ @@ -549,6 +588,10 @@ 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) diff --git a/tests/Feature/Feature/AdminGameConfigControllerTest.php b/tests/Feature/Feature/AdminGameConfigControllerTest.php new file mode 100644 index 0000000..880e327 --- /dev/null +++ b/tests/Feature/Feature/AdminGameConfigControllerTest.php @@ -0,0 +1,183 @@ +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']} 参数未写入数据库。", + ); + } + } +}