rebuildTestingTables(); } /** * 方法功能:验证后台可以保存题目过期时间配置。 */ public function test_service_uses_legacy_idiom_config_for_brain_teaser_scope_and_interval(): void { $room = Room::create(['room_name' => '测试房间']); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 88, 'reward_exp' => 66, 'auto_start_interval' => 9, 'expire_minutes' => 7, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); /** @var RiddleGameService $service */ $service = $this->app->make(RiddleGameService::class); $this->assertSame(9, $service->getAutoStartInterval(Riddle::TYPE_BRAIN_TEASER)); $this->assertSame(7, $service->getExpireMinutes(Riddle::TYPE_BRAIN_TEASER)); $this->assertSame([$room->id], $service->getScopedRoomIds(Riddle::TYPE_BRAIN_TEASER)); } /** * 方法功能:验证 single 房间范围模式只取配置中的首个房间。 */ public function test_service_only_uses_first_room_when_scope_mode_is_single(): void { $roomOne = Room::create(['room_name' => '测试房间1']); $roomTwo = Room::create(['room_name' => '测试房间2']); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 50, 'reward_exp' => 30, 'auto_start_interval' => 3, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$roomOne->id, $roomTwo->id], ], ]); /** @var RiddleGameService $service */ $service = $this->app->make(RiddleGameService::class); $this->assertSame([$roomOne->id], $service->getScopedRoomIds(Riddle::TYPE_IDIOM)); } /** * 方法功能:验证手动出题前会清理已超时的旧回合,避免阻塞新题。 */ public function test_start_clears_expired_round_before_creating_new_round(): void { $this->mockChatStateService(); $admin = $this->createSiteOwner(); $room = Room::create(['room_name' => '猜成语房间']); $idiom = $this->createActiveIdiom(); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜成语', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 50, 'reward_exp' => 30, 'auto_start_interval' => 0, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); $expiredRound = RiddleGameRound::create([ 'room_id' => $room->id, 'idiom_id' => $idiom->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'status' => 'active', 'reward_gold' => 50, 'reward_exp' => 30, 'started_at' => now()->subMinutes(10), ]); $response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [ 'room_id' => $room->id, ]); $response->assertOk() ->assertJsonPath('status', 'success'); $expiredRound->refresh(); $this->assertSame('ended', $expiredRound->status); $this->assertNotNull($expiredRound->ended_at); $this->assertDatabaseCount('idiom_game_rounds', 2); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'status' => 'active', ]); } /** * 方法功能:验证旧路由在传入脑筋急转弯题型时也能正常手动开题。 */ public function test_start_supports_brain_teaser_quiz_type_on_legacy_route(): void { $this->mockChatStateService(); $admin = $this->createSiteOwner(); $room = Room::create(['room_name' => '猜谜房间']); $brainTeaser = $this->createQuestion( type: Riddle::TYPE_BRAIN_TEASER, answer: '影子', hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?', ); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 66, 'reward_exp' => 44, 'auto_start_interval' => 0, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); $response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [ 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, ]); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER) ->assertJsonPath('data.hint', $brainTeaser->hint); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $room->id, 'idiom_id' => $brainTeaser->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, 'status' => 'active', ]); } /** * 方法功能:验证手动出题会结束当前同题型旧回合并直接开启新题。 */ public function test_start_manually_ends_previous_active_round_of_same_quiz_type(): void { $this->mockChatStateService(); $admin = $this->createSiteOwner(); $room = Room::create(['room_name' => '手动出题房间']); $questionOne = $this->createQuestion( type: Riddle::TYPE_BRAIN_TEASER, answer: '影子', hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?', ); $questionTwo = $this->createQuestion( type: Riddle::TYPE_BRAIN_TEASER, answer: '回声', hint: '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?', ); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 66, 'reward_exp' => 44, 'auto_start_interval' => 0, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); $oldRound = RiddleGameRound::create([ 'room_id' => $room->id, 'idiom_id' => $questionOne->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, 'status' => 'active', 'reward_gold' => 66, 'reward_exp' => 44, 'started_at' => now()->subMinute(), ]); $response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [ 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, ]); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER); $oldRound->refresh(); $this->assertSame('ended', $oldRound->status); $this->assertNotNull($oldRound->ended_at); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, 'status' => 'active', ]); $this->assertSame(2, RiddleGameRound::query()->where('room_id', $room->id)->count()); $this->assertSame(1, RiddleGameRound::query() ->where('room_id', $room->id) ->where('quiz_type', Riddle::TYPE_BRAIN_TEASER) ->where('status', 'active') ->count()); } /** * 方法功能:验证游戏总开关关闭时返回明确提示,而不是误报题库为空。 */ public function test_start_returns_clear_message_when_riddle_activity_is_disabled(): void { $this->mockChatStateService(); $admin = $this->createSiteOwner(); $room = Room::create(['room_name' => '猜谜房间']); $this->createQuestion( type: Riddle::TYPE_BRAIN_TEASER, answer: '影子', hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?', ); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => false, 'params' => [ 'reward_gold' => 66, 'reward_exp' => 44, 'auto_start_interval' => 0, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); $response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [ 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, ]); $response->assertStatus(400) ->assertJsonPath('status', 'error') ->assertJsonPath('message', '猜谜活动未开启,请先到游戏管理中开启后再出题。'); } /** * 方法功能:验证回合超时后不能继续答题,也不会发放奖励。 */ public function test_answer_rejects_expired_round_and_marks_it_ended(): void { $this->mockChatStateService(); $player = User::factory()->create([ 'username' => '答题用户', 'exp_num' => 10, ]); $room = Room::create(['room_name' => '答题房间']); $idiom = $this->createActiveIdiom(); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜成语', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 20, 'reward_exp' => 15, 'auto_start_interval' => 0, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); $round = RiddleGameRound::create([ 'room_id' => $room->id, 'idiom_id' => $idiom->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'status' => 'active', 'reward_gold' => 20, 'reward_exp' => 15, 'started_at' => now()->subMinutes(6), ]); $response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [ 'round_id' => $round->id, 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'answer' => '画蛇添足', ]); $response->assertStatus(400) ->assertJsonPath('status', 'error') ->assertJsonPath('message', '该回合已超时结束'); $round->refresh(); $player->refresh(); $this->assertSame('ended', $round->status); $this->assertNotNull($round->ended_at); $this->assertNull($round->winner_id); $this->assertSame(10, $player->exp_num); } /** * 方法功能:验证同一房间两种题型同时存在时,答对一题不会结算另一题。 */ public function test_answer_only_ends_matching_round_when_two_quiz_types_are_active(): void { $this->mockChatStateService(); $player = User::factory()->create([ 'username' => '破题用户', 'exp_num' => 10, ]); $room = Room::create(['room_name' => '双题房间']); $idiom = $this->createQuestion( type: Riddle::TYPE_IDIOM, answer: '画蛇添足', hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语', ); $brainTeaser = $this->createQuestion( type: Riddle::TYPE_BRAIN_TEASER, answer: '影子', hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?', ); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 20, 'reward_exp' => 15, 'auto_start_interval' => 0, 'expire_minutes' => 5, 'room_scope_mode' => 'single', 'room_ids' => [$room->id], ], ]); $idiomRound = RiddleGameRound::create([ 'room_id' => $room->id, 'idiom_id' => $idiom->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'status' => 'active', 'reward_gold' => 20, 'reward_exp' => 15, 'started_at' => now(), ]); $brainTeaserRound = RiddleGameRound::create([ 'room_id' => $room->id, 'idiom_id' => $brainTeaser->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, 'status' => 'active', 'reward_gold' => 20, 'reward_exp' => 15, 'started_at' => now(), ]); $response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [ 'round_id' => $idiomRound->id, 'room_id' => $room->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'answer' => '画蛇添足', ]); $response->assertOk() ->assertJsonPath('status', 'success') ->assertJsonPath('data.quiz_round_id', $idiomRound->id); $idiomRound->refresh(); $brainTeaserRound->refresh(); $this->assertSame('answered', $idiomRound->status); $this->assertSame('破题用户', $idiomRound->winner_username); $this->assertSame('active', $brainTeaserRound->status); $this->assertNull($brainTeaserRound->winner_username); } /** * 方法功能:验证自动出题会按房间和题型两个维度独立判断。 */ public function test_auto_start_checks_room_and_quiz_type_independently(): void { $this->mockChatStateService(); $roomOne = Room::create(['room_name' => '一号房']); $roomTwo = Room::create(['room_name' => '二号房']); $idiom = $this->createQuestion( type: Riddle::TYPE_IDIOM, answer: '画蛇添足', hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语', ); $brainTeaser = $this->createQuestion( type: Riddle::TYPE_BRAIN_TEASER, answer: '影子', hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?', ); GameConfig::create([ 'game_key' => 'idiom', 'name' => '猜谜活动', 'icon' => '🧩', 'enabled' => true, 'params' => [ 'reward_gold' => 30, 'reward_exp' => 18, 'auto_start_interval' => 1, 'expire_minutes' => 5, 'room_scope_mode' => 'multiple', 'room_ids' => [$roomOne->id, $roomTwo->id], ], ]); // 一号房已有进行中的成语题,只允许系统补开脑筋急转弯题。 RiddleGameRound::create([ 'room_id' => $roomOne->id, 'idiom_id' => $idiom->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'status' => 'active', 'reward_gold' => 30, 'reward_exp' => 18, 'started_at' => now()->subSeconds(30), ]); /** @var RiddleGameService $service */ $service = $this->app->make(RiddleGameService::class); $startedCount = $service->autoStartEligibleRounds(); $this->assertSame(3, $startedCount); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $roomOne->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'idiom_id' => $idiom->id, 'status' => 'active', ]); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $roomOne->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, 'idiom_id' => $brainTeaser->id, 'status' => 'active', ]); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $roomTwo->id, 'quiz_type' => Riddle::TYPE_IDIOM, 'idiom_id' => $idiom->id, 'status' => 'active', ]); $this->assertDatabaseHas('idiom_game_rounds', [ 'room_id' => $roomTwo->id, 'quiz_type' => Riddle::TYPE_BRAIN_TEASER, 'idiom_id' => $brainTeaser->id, 'status' => 'active', ]); } /** * 方法功能:创建站长账号,满足后台权限与手动出题权限要求。 */ private function createSiteOwner(): User { return User::factory()->create([ 'id' => 1, 'user_level' => 100, 'username' => '站长', ]); } /** * 方法功能:创建一条可用于猜成语测试的启用题目。 */ private function createActiveIdiom(): Riddle { return $this->createQuestion( type: Riddle::TYPE_IDIOM, answer: '画蛇添足', hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语', ); } /** * 方法功能:按指定题型创建一条启用中的测试题目。 */ private function createQuestion(string $type, string $answer, string $hint): Riddle { return Riddle::create([ 'type' => $type, 'answer' => $answer, 'hint' => $hint, 'sort' => 1, 'is_active' => true, ]); } /** * 方法功能:重建本组测试所需的最小表结构。 */ private function rebuildTestingTables(): void { Schema::disableForeignKeyConstraints(); Schema::dropIfExists('idiom_game_rounds'); Schema::dropIfExists('idioms'); Schema::dropIfExists('user_currency_logs'); 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->integer('jjb')->default(0); $table->integer('meili')->default(0); $table->timestamps(); }); Schema::create('rooms', function (Blueprint $table): void { $table->id(); $table->string('room_name')->nullable(); $table->string('room_owner')->nullable(); $table->timestamps(); }); Schema::create('game_configs', function (Blueprint $table): void { $table->id(); $table->string('game_key')->unique(); $table->string('name')->nullable(); $table->string('icon')->nullable(); $table->text('description')->nullable(); $table->boolean('enabled')->default(false); $table->json('params')->nullable(); $table->timestamps(); }); Schema::create('idioms', function (Blueprint $table): void { $table->id(); $table->string('type', 30)->default(Riddle::TYPE_IDIOM); $table->string('answer', 50); $table->string('hint', 255); $table->boolean('is_active')->default(true); $table->integer('sort')->default(0); $table->timestamps(); }); Schema::create('idiom_game_rounds', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('room_id'); $table->unsignedBigInteger('idiom_id'); $table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM); $table->string('status', 20)->default('pending'); $table->integer('reward_gold')->default(0); $table->integer('reward_exp')->default(0); $table->unsignedBigInteger('winner_id')->nullable(); $table->string('winner_username', 50)->nullable(); $table->timestamp('started_at')->nullable(); $table->timestamp('ended_at')->nullable(); $table->timestamps(); }); Schema::create('user_currency_logs', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('user_id')->index(); $table->string('username', 50); $table->string('currency', 10); $table->integer('amount'); $table->integer('balance_after'); $table->string('source', 30)->index(); $table->string('remark', 200)->nullable(); $table->unsignedBigInteger('room_id')->nullable(); $table->timestamp('created_at')->nullable()->index(); }); } /** * 方法功能:替换聊天室状态服务,避免测试依赖真实 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); } }