494 lines
16 KiB
PHP
494 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|