Files
chatroom/app/Services/RiddleGameService.php
T

494 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}