支持所有游戏按房间范围配置和运行

This commit is contained in:
pllx
2026-04-29 14:37:28 +08:00
parent 3672140987
commit 1607f57e3c
37 changed files with 1033 additions and 255 deletions
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐押注人数变化。
*/
class BaccaratPoolUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratPoolUpdated implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_count_big' => $this->round->bet_count_big,
'bet_count_small' => $this->round->bet_count_small,
'bet_count_triple' => $this->round->bet_count_triple,
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐开局事件。
*/
class BaccaratRoundOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratRoundOpened implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播百家乐结算结果。
*/
class BaccaratRoundSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->round->room_id)];
}
/**
@@ -58,6 +61,7 @@ class BaccaratRoundSettled implements ShouldBroadcastNow
{
return [
'round_id' => $this->round->id,
'room_id' => (int) $this->round->room_id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points,
'result' => $this->round->result,
+5 -1
View File
@@ -20,6 +20,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间广播赛马开局事件。
*/
class HorseRaceOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -38,7 +41,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -58,6 +61,7 @@ class HorseRaceOpened implements ShouldBroadcastNow
{
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'horses' => $this->race->horses,
'total_pool' => $this->race->total_pool,
'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(),
+6 -1
View File
@@ -19,6 +19,9 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* 类功能:向指定房间持续广播赛马进度。
*/
class HorseRaceProgress implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
@@ -31,6 +34,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function __construct(
public readonly int $raceId,
public readonly int $roomId,
public readonly array $positions,
public readonly bool $finished = false,
public readonly ?int $leaderId = null,
@@ -43,7 +47,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->roomId)];
}
/**
@@ -63,6 +67,7 @@ class HorseRaceProgress implements ShouldBroadcastNow
{
return [
'race_id' => $this->raceId,
'room_id' => $this->roomId,
'positions' => $this->positions,
'finished' => $this->finished,
'leader_id' => $this->leaderId,
+2 -1
View File
@@ -46,7 +46,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
*/
public function broadcastOn(): array
{
return [new PresenceChannel('room.1')];
return [new PresenceChannel('room.'.$this->race->room_id)];
}
/**
@@ -94,6 +94,7 @@ class HorseRaceSettled implements ShouldBroadcastNow
return [
'race_id' => $this->race->id,
'room_id' => (int) $this->race->room_id,
'winner_horse_id' => $this->race->winner_horse_id,
'winner_name' => $winnerName,
'total_pool' => (int) $this->race->total_pool,
@@ -14,14 +14,20 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateGameConfigParamsRequest;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\Room;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 类功能:统一处理后台游戏开关、参数保存与手动操作入口。
*/
class GameConfigController extends Controller
{
/**
@@ -30,8 +36,9 @@ class GameConfigController extends Controller
public function index(): View
{
$games = GameConfig::orderBy('id')->get();
$availableRooms = Room::query()->orderBy('id')->get();
return view('admin.game-configs.index', compact('games'));
return view('admin.game-configs.index', compact('games', 'availableRooms'));
}
/**
@@ -56,15 +63,16 @@ class GameConfigController extends Controller
*
* 接收前端提交的 params JSON 对象并合并至现有配置。
*/
public function updateParams(Request $request, GameConfig $gameConfig): RedirectResponse
public function updateParams(UpdateGameConfigParamsRequest $request, GameConfig $gameConfig, GameRoomScopeService $roomScopeService): RedirectResponse
{
$request->validate([
'params' => 'required|array',
]);
// 合并参数,保留已有键,只更新传入的键
$current = $gameConfig->params ?? [];
$updated = array_merge($current, $request->input('params'));
$validatedParams = $request->validated('params');
$updated = array_merge($current, $validatedParams);
$scopeConfig = $roomScopeService->getScopeConfigForParams($validatedParams);
$updated['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$updated['room_ids'] = $scopeConfig['room_ids'];
if ($gameConfig->game_key === 'mystery_box') {
$legacyMap = [
@@ -107,17 +115,19 @@ class GameConfigController extends Controller
}
// 检查是否有正在开放的箱子(避免同时多个)
if (\App\Models\MysteryBox::currentOpenBox()) {
$targetRoomId = app(GameRoomScopeService::class)->getPrimaryRoomIdForGame('mystery_box');
if (\App\Models\MysteryBox::currentOpenBox($targetRoomId)) {
return response()->json(['ok' => false, 'message' => '当前已有一个神秘箱子正在等待领取,请等它结束后再投放。']);
}
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, 1, null, (int) auth()->id());
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $targetRoomId, null, (int) auth()->id());
$typeNames = ['normal' => '普通箱', 'rare' => '稀有箱', 'trap' => '黑化箱'];
return response()->json([
'ok' => true,
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #1 房间,暗号将实时发送到公屏!",
'message' => "✅ 已投放「{$typeNames[$boxType]}」到 #{$targetRoomId} 房间,暗号将实时发送到公屏!",
]);
}
@@ -126,19 +136,31 @@ class GameConfigController extends Controller
*
* 仅在当前无进行中期次时生效,防止重复开期。
*/
public function openLotteryIssue(): JsonResponse
public function openLotteryIssue(GameRoomScopeService $roomScopeService): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['ok' => false, 'message' => '双色球彩票未开启,请先开启游戏。']);
}
if (LotteryIssue::currentIssue()) {
return response()->json(['ok' => false, 'message' => '当前已有进行中的期次,无需重复开期。']);
$openedRoomIds = [];
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
if (LotteryIssue::currentIssue($roomId)) {
continue;
}
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
$openedRoomIds[] = $roomId;
}
\App\Jobs\OpenLotteryIssueJob::dispatch();
if ($openedRoomIds === []) {
return response()->json(['ok' => false, 'message' => '目标房间当前已有进行中的期次,无需重复开期。']);
}
return response()->json(['ok' => true, 'message' => '✅ 已排队开期任务,新期次将就绪建立!']);
return response()->json([
'ok' => true,
'message' => '✅ 已排队开期任务,目标房间:#'.implode('、#', $openedRoomIds),
]);
}
/**
+20 -2
View File
@@ -20,16 +20,21 @@ use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\BaccaratLossCoverService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供百家乐当前局查询、下注与历史接口。
*/
class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly BaccaratLossCoverService $lossCoverService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -38,7 +43,13 @@ class BaccaratController extends Controller
public function currentRound(Request $request): JsonResponse
{
$user = $request->user();
$round = BaccaratRound::currentRound();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['round' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$round = BaccaratRound::currentRound($roomId);
if (! $round) {
return response()->json([
@@ -98,6 +109,11 @@ class BaccaratController extends Controller
'bet_type' => 'required|in:big,small,triple',
'amount' => 'required|integer|min:1',
]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('baccarat', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启百家乐。'], 403);
}
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -109,7 +125,7 @@ class BaccaratController extends Controller
$round = BaccaratRound::find($data['round_id']);
if (! $round || ! $round->isBettingOpen()) {
if (! $round || (int) $round->room_id !== $roomId || ! $round->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
@@ -212,7 +228,9 @@ class BaccaratController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$rounds = BaccaratRound::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
@@ -21,6 +21,7 @@ use App\Models\GameConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\FishingService;
use App\Services\GameRoomScopeService;
use App\Services\ShopService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
@@ -30,6 +31,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
/**
* 类功能:处理钓鱼小游戏的抛竿与收竿流程。
*/
class FishingController extends Controller
{
public function __construct(
@@ -38,6 +42,7 @@ class FishingController extends Controller
private readonly UserCurrencyService $currencyService,
private readonly ShopService $shopService,
private readonly FishingService $fishingService,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -63,6 +68,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '钓鱼功能暂未开放。'], 403);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 检查冷却时间(Redis TTL
$cooldownKey = "fishing:cd:{$user->id}";
if (Redis::exists($cooldownKey)) {
@@ -142,6 +151,10 @@ class FishingController extends Controller
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
if (! $this->roomScopeService->isRoomAllowedForGame('fishing', $id)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启钓鱼小游戏。'], 403);
}
// 1. 验证 token + 服务端时间校验(防止前端篡改 wait_time 跳过等待)
$tokenKey = "fishing:token:{$user->id}";
$storedJson = Redis::get($tokenKey);
@@ -18,14 +18,19 @@ namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\FortuneLog;
use App\Models\GameConfig;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 类功能:提供神秘占卜状态、抽签和历史接口。
*/
class FortuneTellingController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -37,6 +42,11 @@ class FortuneTellingController extends Controller
return response()->json(['enabled' => false]);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['enabled' => false]);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -81,6 +91,11 @@ class FortuneTellingController extends Controller
return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘占卜。'], 403);
}
$user = $request->user();
$config = GameConfig::forGame('fortune_telling')?->params ?? [];
@@ -145,6 +160,11 @@ class FortuneTellingController extends Controller
*/
public function history(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('fortune_telling', $roomId)) {
return response()->json(['history' => []]);
}
$logs = FortuneLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -24,17 +24,22 @@ use App\Events\GomokuInviteEvent;
use App\Events\GomokuMovedEvent;
use App\Models\GameConfig;
use App\Models\GomokuGame;
use App\Services\GameRoomScopeService;
use App\Services\GomokuAiService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:处理五子棋创建、加入与对局过程接口。
*/
class GomokuController extends Controller
{
public function __construct(
private readonly GomokuAiService $ai,
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -58,6 +63,10 @@ class GomokuController extends Controller
$user = $request->user();
if (! $this->roomScopeService->isRoomAllowedForGame('gomoku', (int) $data['room_id'])) {
return response()->json(['ok' => false, 'message' => '当前房间未开启五子棋。'], 403);
}
// PvP:检查是否已在等待/对局中(一次只能参与一场)
$activeGame = GomokuGame::query()
->where(function ($q) use ($user) {
+21 -7
View File
@@ -23,6 +23,7 @@ use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -38,6 +39,7 @@ class HorseRaceController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -50,7 +52,12 @@ class HorseRaceController extends Controller
return response()->json(['message' => '未登录', 'status' => 'error'], 401);
}
$race = $this->resolveCurrentRaceState(HorseRace::currentRace());
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['race' => null, 'jjb' => (int) ($user->jjb ?? 0)]);
}
$race = $this->resolveCurrentRaceState(HorseRace::currentRace($roomId));
if (! $race) {
return response()->json([
@@ -145,6 +152,11 @@ class HorseRaceController extends Controller
'horse_id' => 'required|integer|min:1',
'amount' => 'required|integer|min:1',
]);
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('horse_racing', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启赛马竞猜。'], 403);
}
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
@@ -156,7 +168,7 @@ class HorseRaceController extends Controller
$race = HorseRace::find($data['race_id']);
if (! $race || ! $race->isBettingOpen()) {
if (! $race || (int) $race->room_id !== $roomId || ! $race->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
@@ -213,8 +225,8 @@ class HorseRaceController extends Controller
$formattedAmount = number_format($data['amount']);
$content = "🐎 <b>【赛马】【{$user->username}】</b> 押注了 <b>{$formattedAmount}</b> 金币({$horseName})!✨";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -223,8 +235,8 @@ class HorseRaceController extends Controller
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
event(new MessageSent(1, $msg));
$chatState->pushMessage((int) $race->room_id, $msg);
event(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
return response()->json([
@@ -241,7 +253,9 @@ class HorseRaceController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(auth()->user());
$races = HorseRace::query()
->where('room_id', $roomId)
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
@@ -291,7 +305,7 @@ class HorseRaceController extends Controller
// 线上若漏消费 CloseHorseRaceJob,这里同步补做一次结算,避免界面一直显示“跑马中”。
app()->call([new \App\Jobs\CloseHorseRaceJob($race), 'handle']);
return HorseRace::currentRace();
return HorseRace::currentRace((int) $race->room_id);
}
/**
+20 -2
View File
@@ -19,27 +19,38 @@ namespace App\Http\Controllers;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Services\GameRoomScopeService;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* 类功能:提供双色球当前期、购票和历史记录接口。
*/
class LotteryController extends Controller
{
public function __construct(
private readonly LotteryService $lottery,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/
public function current(): JsonResponse
public function current(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->json(['enabled' => false]);
}
$issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue();
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['enabled' => false, 'message' => '当前房间未开启双色球彩票。'], 403);
}
$issue = LotteryIssue::currentIssue($roomId) ?? LotteryIssue::latestIssue($roomId);
if (! $issue) {
return response()->json(['enabled' => true, 'issue' => null]);
@@ -90,6 +101,11 @@ class LotteryController extends Controller
*/
public function buy(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('lottery', $roomId)) {
return response()->json(['status' => 'error', 'message' => '当前房间未开启双色球彩票。'], 403);
}
$request->validate([
'numbers' => 'required|array|min:1',
'numbers.*.reds' => 'required|array|size:3',
@@ -132,7 +148,9 @@ class LotteryController extends Controller
*/
public function history(): JsonResponse
{
$roomId = $this->roomScopeService->resolveUserRoomId(Auth::user());
$issues = LotteryIssue::query()
->where('room_id', $roomId)
->where('status', 'settled')
->latest()
->limit(20)
+25 -11
View File
@@ -28,28 +28,38 @@ use App\Models\GameConfig;
use App\Models\MysteryBox;
use App\Models\MysteryBoxClaim;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供神秘箱子状态查询与暗号开箱接口。
*/
class MysteryBoxController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
* 查询当前可领取的箱子状态(给前端轮询/显示用)。
*/
public function status(): JsonResponse
public function status(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('mystery_box')) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox();
$roomId = $this->roomScopeService->resolveRequestRoomId($request);
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['active' => false]);
}
$box = MysteryBox::currentOpenBox($roomId);
if (! $box) {
return response()->json(['active' => false]);
@@ -85,10 +95,16 @@ class MysteryBoxController extends Controller
}
$user = $request->user();
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $user);
return DB::transaction(function () use ($user, $passcode): JsonResponse {
if (! $this->roomScopeService->isRoomAllowedForGame('mystery_box', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启神秘箱子。'], 403);
}
return DB::transaction(function () use ($user, $passcode, $roomId): JsonResponse {
// 查找匹配暗号的可领取箱子(加锁防并发)
$box = MysteryBox::query()
->where('room_id', $roomId)
->where('passcode', $passcode)
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
@@ -147,18 +163,16 @@ class MysteryBoxController extends Controller
$typeName = $box->typeName();
if ($reward >= 0) {
$content = "{$emoji}【神秘箱子】开箱播报:恭喜{$username} 抢到了神秘{$typeName}"
.'获得 💰'.number_format($reward).' 金币!';
$content = "{$emoji}{$username}】抢到{$typeName},获得 💰".number_format($reward).' 金币!';
$color = $box->box_type === 'rare' ? '#c4b5fd' : '#34d399';
} else {
$content = "☠️【神秘箱子】《黑化陷阱》haha{$username} 中了神秘黑化箱的陷阱!"
.'被扣除 💰'.number_format(abs($reward)).' 金币!点背~';
$content = "☠️ {$username}踩中黑化陷阱,扣除 💰".number_format(abs($reward)).' 金币!';
$color = '#f87171';
}
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -168,8 +182,8 @@ class MysteryBoxController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
+33 -13
View File
@@ -23,16 +23,21 @@ use App\Jobs\SaveMessageJob;
use App\Models\GameConfig;
use App\Models\SlotMachineLog;
use App\Services\ChatStateService;
use App\Services\GameRoomScopeService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 类功能:提供老虎机信息查询、转动和个人记录接口。
*/
class SlotMachineController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
/**
@@ -44,6 +49,11 @@ class SlotMachineController extends Controller
return response()->json(['enabled' => false]);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['enabled' => false]);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$user = $request->user();
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -77,6 +87,11 @@ class SlotMachineController extends Controller
return response()->json(['ok' => false, 'message' => '老虎机未开放。']);
}
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['ok' => false, 'message' => '当前房间未开启老虎机。'], 403);
}
$config = GameConfig::forGame('slot_machine')?->params ?? [];
$cost = (int) ($config['cost_per_spin'] ?? 100);
$dailyLimit = (int) ($config['daily_limit'] ?? 0);
@@ -100,7 +115,7 @@ class SlotMachineController extends Controller
}
}
return DB::transaction(function () use ($user, $cost, $config): JsonResponse {
return DB::transaction(function () use ($user, $cost, $config, $roomId): JsonResponse {
// ① 扣费
$this->currency->change(
$user,
@@ -164,16 +179,16 @@ class SlotMachineController extends Controller
if ($resultType === 'jackpot') {
// 三个7:全服公屏广播
$this->broadcastJackpot($user->username, $payout, $cost);
$this->broadcastJackpot($user->username, $payout, $cost, $roomId);
} elseif (in_array($resultType, ['triple_gem', 'triple', 'pair'], true)) {
// 普通中奖:仅向本人发送聊天室系统通知
$net = $payout - $cost;
$content = "🎰 {$resultLabel}{$e1}{$e2}{$e3} 赢得 +💰".number_format($net).' 金币';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
} elseif ($resultType === 'curse') {
// 诅咒:通知本人
$content = "☠️ 三骷髅诅咒!{$e1}{$e2}{$e3} 额外扣除 💰".number_format($cost).' 金币!';
$this->broadcastPersonal($user->username, $content);
$this->broadcastPersonal($user->username, $content, $roomId);
}
$user->refresh();
@@ -200,6 +215,11 @@ class SlotMachineController extends Controller
*/
public function history(Request $request): JsonResponse
{
$roomId = $this->roomScopeService->resolveRequestRoomId($request, $request->user());
if (! $this->roomScopeService->isRoomAllowedForGame('slot_machine', $roomId)) {
return response()->json(['history' => []]);
}
$logs = SlotMachineLog::query()
->where('user_id', $request->user()->id)
->orderByDesc('id')
@@ -239,15 +259,15 @@ class SlotMachineController extends Controller
/**
* 三个7全服公屏广播。
*/
private function broadcastJackpot(string $username, int $payout, int $cost): void
private function broadcastJackpot(string $username, int $payout, int $cost, int $roomId): void
{
$net = $payout - $cost;
$content = "🎰🎉【老虎机大奖】恭喜 【{$username}】 转出三个7️⃣!"
.'狂揽 💰'.number_format($net).' 金币!全服见证奇迹!';
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -257,8 +277,8 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
@@ -268,11 +288,11 @@ class SlotMachineController extends Controller
* @param string $toUsername 接收用户名
* @param string $content 消息内容
*/
private function broadcastPersonal(string $toUsername, string $content): void
private function broadcastPersonal(string $toUsername, string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => $toUsername,
'content' => $content,
@@ -282,7 +302,7 @@ class SlotMachineController extends Controller
'sent_at' => now()->toDateTimeString(),
];
broadcast(new MessageSent(1, $msg));
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
}
}
@@ -0,0 +1,97 @@
<?php
/**
* 文件功能:保存游戏参数请求校验
*
* 统一校验后台“游戏管理”页提交的 params 结构,
* 并在所有游戏共用的房间范围字段上执行归一化。
*/
namespace App\Http\Requests;
use App\Services\GameRoomScopeService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:约束后台游戏参数保存请求的公共结构。
*/
class UpdateGameConfigParamsRequest extends FormRequest
{
/**
* 判断当前请求是否允许执行。
*/
public function authorize(): bool
{
return true;
}
/**
* 校验规则。
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'params' => ['required', 'array'],
'params.room_scope_mode' => ['nullable', 'in:all,single,multiple'],
'params.room_ids' => ['nullable', 'array'],
'params.room_ids.*' => ['integer', 'exists:rooms,id'],
];
}
/**
* 自定义错误消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'params.required' => '缺少游戏参数数据。',
'params.array' => '游戏参数格式无效。',
'params.room_scope_mode.in' => '参与房间模式无效。',
'params.room_ids.array' => '参与房间列表格式无效。',
'params.room_ids.*.integer' => '参与房间编号格式无效。',
'params.room_ids.*.exists' => '所选房间不存在,请刷新页面后重试。',
];
}
/**
* 在校验前先把房间范围字段归一化,兼容单值与旧字段。
*/
protected function prepareForValidation(): void
{
$params = (array) $this->input('params', []);
$roomScopeService = app(GameRoomScopeService::class);
$scopeConfig = $roomScopeService->getScopeConfigForParams($params);
$params['room_scope_mode'] = $scopeConfig['room_scope_mode'];
$params['room_ids'] = $scopeConfig['room_ids'];
$this->merge([
'params' => $params,
]);
}
/**
* 校验通过后补充“单选/多选至少选择一个房间”的约束。
*/
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$params = (array) $this->input('params', []);
$roomMode = (string) ($params['room_scope_mode'] ?? GameRoomScopeService::MODE_SINGLE);
$roomIds = (array) ($params['room_ids'] ?? []);
if (in_array($roomMode, [GameRoomScopeService::MODE_SINGLE, GameRoomScopeService::MODE_MULTIPLE], true) && $roomIds === []) {
$validator->errors()->add('params.room_ids', '单选/多选房间模式下,请至少选择一个房间。');
}
if ($roomMode === GameRoomScopeService::MODE_SINGLE && count($roomIds) > 1) {
$validator->errors()->add('params.room_ids', '单选房间模式下只能选择一个房间。');
}
});
}
}
+9 -6
View File
@@ -28,6 +28,9 @@ use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:完成一局百家乐的开奖、派奖与通知。
*/
class CloseBaccaratRoundJob implements ShouldQueue
{
use Queueable;
@@ -227,7 +230,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
return;
}
$roomId = 1;
$roomId = (int) $round->room_id;
$roundResultLabel = $round->resultLabel();
foreach ($participantSettlements as $settlement) {
@@ -309,11 +312,11 @@ class CloseBaccaratRoundJob implements ShouldQueue
$detail = $detailParts ? ' '.implode(' ', $detailParts) : '';
$content = "🎲 【百家乐】第 #{$round->id} 局开奖!{$diceStr} 总点 {$round->total_points} {$resultText}{$payoutText}{$detail}";
$content = "🎲 {$diceStr} {$round->total_points} 点,{$resultText}{$payoutText}{$detail}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $round->room_id),
'room_id' => (int) $round->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -322,8 +325,8 @@ class CloseBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $round->room_id, $msg);
broadcast(new MessageSent((int) $round->room_id, $msg));
SaveMessageJob::dispatch($msg);
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
+9 -6
View File
@@ -26,6 +26,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
/**
* 类功能:完成一场赛马竞猜的派奖与结果广播。
*/
class CloseHorseRaceJob implements ShouldQueue
{
use Queueable;
@@ -181,7 +184,7 @@ class CloseHorseRaceJob implements ShouldQueue
return;
}
$roomId = 1;
$roomId = (int) $race->room_id;
$winnerName = $this->resolveWinnerHorseName($race);
foreach ($participantSettlements as $settlement) {
@@ -243,11 +246,11 @@ class CloseHorseRaceJob implements ShouldQueue
? '共派发 💰'.number_format($totalPayout).' 金币'
: '本场无人获奖';
$content = "🏆 【赛马】第 #{$race->id} 场结束!冠军:{$winnerName}{$payoutText}";
$content = "🏆 冠军:{$winnerName}{$payoutText}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -256,8 +259,8 @@ class CloseHorseRaceJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $race->room_id, $msg);
broadcast(new MessageSent((int) $race->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
+5 -2
View File
@@ -23,6 +23,9 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
/**
* 类功能:按房间投放神秘箱子并广播暗号。
*/
class DropMysteryBoxJob implements ShouldQueue
{
use Queueable;
@@ -80,6 +83,7 @@ class DropMysteryBoxJob implements ShouldQueue
// 创建箱子记录
$box = MysteryBox::create([
'room_id' => $targetRoom,
'box_type' => $this->boxType,
'passcode' => $passcode,
'reward_min' => $rewardMin,
@@ -94,8 +98,7 @@ class DropMysteryBoxJob implements ShouldQueue
$typeName = $box->typeName();
$source = $this->droppedBy ? '管理员' : '系统';
$content = "{$emoji}【神秘箱子】{$typeName}{$source}投放了一个神秘箱子!"
."发送暗号「{$passcode}」即可开箱!限时 {$claimWindow} 秒,先到先得!";
$content = "{$emoji} {$typeName}{$source}投放,暗号「{$passcode}」,限时 {$claimWindow} 秒。";
$msg = [
'id' => $chatState->nextMessageId($targetRoom),
+7 -4
View File
@@ -18,6 +18,9 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:关闭已超时的神秘箱子并广播过期提醒。
*/
class ExpireMysteryBoxJob implements ShouldQueue
{
use Queueable;
@@ -49,8 +52,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
// 公屏广播过期通知
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $box->room_id),
'room_id' => (int) $box->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "⏰ 神秘箱子(暗号:{$box->passcode})已超时,箱子消失了!下次要快哦~",
@@ -60,8 +63,8 @@ class ExpireMysteryBoxJob implements ShouldQueue
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage((int) $box->room_id, $msg);
broadcast(new MessageSent((int) $box->room_id, $msg));
SaveMessageJob::dispatch($msg);
}
}
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一局新的百家乐押注回合。
*/
class OpenBaccaratRoundJob implements ShouldQueue
{
use Queueable;
/**
* 构造开局任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -44,7 +56,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
// 防止重复开局(如果上一局还在押注中则跳过)
if (BaccaratRound::currentRound()) {
if (BaccaratRound::currentRound($this->roomId)) {
return;
}
@@ -53,6 +65,7 @@ class OpenBaccaratRoundJob implements ShouldQueue
// 创建新局次
$round = BaccaratRound::create([
'room_id' => $this->roomId,
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
@@ -77,10 +90,10 @@ class OpenBaccaratRoundJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'baccarat-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #7c3aed; border-radius:999px; background:#fff; color:#7c3aed; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与</button>';
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,押注范围 {$minBet}~{$maxBet} 金币。赔率:🔵大/🟡 1:{$bigRate} · 💥豹子 1:{$tripleRate}☠️ {$killText}庄家收割".$quickOpenButton;
$content = "🎲 开局:{$betSeconds}下注{$minBet}~{$maxBet} 金币,🔵/🟡 1:{$bigRate},💥 1:{$tripleRate}☠️ {$killText} 点收割".$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -89,8 +102,8 @@ class OpenBaccaratRoundJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg);
// 如果允许 AI 参与,延迟一定时间派发 AI 下注任务
+19 -6
View File
@@ -21,10 +21,22 @@ use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间开启一场新的赛马竞猜回合。
*/
class OpenHorseRaceJob implements ShouldQueue
{
use Queueable;
/**
* 构造开赛任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -41,7 +53,7 @@ class OpenHorseRaceJob implements ShouldQueue
}
// 防止重复开赛(上一场还在进行中)
if (HorseRace::currentRace()) {
if (HorseRace::currentRace($this->roomId)) {
return;
}
@@ -60,6 +72,7 @@ class OpenHorseRaceJob implements ShouldQueue
// 创建新场次
$race = HorseRace::create([
'room_id' => $this->roomId,
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
@@ -79,11 +92,11 @@ class OpenHorseRaceJob implements ShouldQueue
.'onclick="event.preventDefault(); Alpine.$data(document.getElementById(\'horse-race-panel\')).openFromHall();" '
.'style="margin-left:8px; padding:2px 8px; border:1px solid #d97706; border-radius:999px; background:#fff7ed; color:#b45309; font-size:12px; font-weight:bold; cursor:pointer;">'
.'快速参与赌马</button>';
$content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$content = "🐎 开赛:{$horseList}{$betSeconds} 秒下注,".number_format($minBet).'~'.number_format($maxBet).' 金币'.$quickOpenButton;
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId($this->roomId),
'room_id' => $this->roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -92,8 +105,8 @@ class OpenHorseRaceJob implements ShouldQueue
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$chatState->pushMessage($this->roomId, $msg);
broadcast(new MessageSent($this->roomId, $msg));
SaveMessageJob::dispatch($msg);
// 押注截止后触发跑马 & 结算任务
+15 -2
View File
@@ -19,10 +19,22 @@ use App\Models\LotteryIssue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* 类功能:按房间创建一条新的双色球期次。
*/
class OpenLotteryIssueJob implements ShouldQueue
{
use Queueable;
/**
* 构造开期任务。
*
* @param int $roomId 目标房间
*/
public function __construct(
public readonly int $roomId = 1,
) {}
/**
* 最大重试次数。
*/
@@ -38,7 +50,7 @@ class OpenLotteryIssueJob implements ShouldQueue
}
// 已有进行中的期次则跳过
if (LotteryIssue::currentIssue()) {
if (LotteryIssue::currentIssue($this->roomId)) {
return;
}
@@ -56,7 +68,8 @@ class OpenLotteryIssueJob implements ShouldQueue
$closeAt = $drawAt->copy()->subMinutes($stopMinutes);
LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(),
'room_id' => $this->roomId,
'issue_no' => LotteryIssue::nextIssueNo($this->roomId),
'status' => 'open',
'pool_amount' => 0,
'carry_amount' => 0,
+6 -6
View File
@@ -78,18 +78,18 @@ class RunHorseRaceJob implements ShouldQueue
));
$startMsg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'id' => $chatState->nextMessageId((int) $race->room_id),
'room_id' => (int) $race->room_id,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容{$horseList}",
'content' => "🏇 比赛开始{$horseList}",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $startMsg);
broadcast(new MessageSent(1, $startMsg));
$chatState->pushMessage((int) $race->room_id, $startMsg);
broadcast(new MessageSent((int) $race->room_id, $startMsg));
SaveMessageJob::dispatch($startMsg);
$config = GameConfig::forGame('horse_racing')?->params ?? [];
@@ -132,7 +132,7 @@ class RunHorseRaceJob implements ShouldQueue
}
// 广播当前帧进度
broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
broadcast(new HorseRaceProgress($race->id, (int) $race->room_id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions)));
if ($finished) {
break;
+14 -5
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存百家乐局次数据并提供当前局查询能力。
*/
class BaccaratRound extends Model
{
protected $fillable = [
'room_id',
'dice1', 'dice2', 'dice3',
'total_points', 'result', 'status',
'bet_opens_at', 'bet_closes_at', 'settled_at',
@@ -36,6 +40,7 @@ class BaccaratRound extends Model
'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime',
'settled_at' => 'datetime',
'room_id' => 'integer',
'dice1' => 'integer',
'dice2' => 'integer',
'dice3' => 'integer',
@@ -104,12 +109,16 @@ class BaccaratRound extends Model
/**
* 查询当前正在进行的局次(状态为 betting 且未截止)。
*/
public static function currentRound(): ?static
public static function currentRound(?int $roomId = null): ?static
{
return static::query()
$query = static::query()
->where('status', 'betting')
->where('bet_closes_at', '>', now())
->latest()
->first();
->where('bet_closes_at', '>', now());
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
}
+10 -5
View File
@@ -25,6 +25,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class HorseRace extends Model
{
protected $fillable = [
'room_id',
'status',
'bet_opens_at',
'bet_closes_at',
@@ -48,6 +49,7 @@ class HorseRace extends Model
'race_starts_at' => 'datetime',
'race_ends_at' => 'datetime',
'settled_at' => 'datetime',
'room_id' => 'integer',
'horses' => 'array',
'winner_horse_id' => 'integer',
'total_bets' => 'integer',
@@ -75,12 +77,15 @@ class HorseRace extends Model
/**
* 查询当前正在进行的场次(状态为 betting 且押注未截止)。
*/
public static function currentRace(): ?static
public static function currentRace(?int $roomId = null): ?static
{
return static::query()
->whereIn('status', ['betting', 'running'])
->latest()
->first();
$query = static::query()->whereIn('status', ['betting', 'running']);
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
+29 -9
View File
@@ -16,9 +16,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 类功能:保存双色球期次数据并提供按房间查询能力。
*/
class LotteryIssue extends Model
{
protected $fillable = [
'room_id',
'issue_no',
'status',
'red1', 'red2', 'red3', 'blue',
@@ -38,6 +42,7 @@ class LotteryIssue extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'is_super_issue' => 'boolean',
'pool_amount' => 'integer',
'carry_amount' => 'integer',
@@ -71,29 +76,44 @@ class LotteryIssue extends Model
/**
* 获取当前正在购票的期次(status=open)。
*/
public static function currentIssue(): ?static
public static function currentIssue(?int $roomId = null): ?static
{
return static::query()->where('status', 'open')->latest()->first();
$query = static::query()->where('status', 'open');
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
* 获取最新一期(不论状态)。
*/
public static function latestIssue(): ?static
public static function latestIssue(?int $roomId = null): ?static
{
return static::query()->latest()->first();
$query = static::query();
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
/**
* 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。
*/
public static function nextIssueNo(): string
public static function nextIssueNo(?int $roomId = null): string
{
$year = now()->year;
$last = static::query()
->whereYear('created_at', $year)
->latest()
->first();
$query = static::query()->whereYear('created_at', $year);
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
$last = $query->latest()->first();
$seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1;
+14 -5
View File
@@ -17,9 +17,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* 类功能:保存神秘箱子投放记录并提供当前箱子查询能力。
*/
class MysteryBox extends Model
{
protected $fillable = [
'room_id',
'box_type',
'passcode',
'reward_min',
@@ -35,6 +39,7 @@ class MysteryBox extends Model
protected function casts(): array
{
return [
'room_id' => 'integer',
'reward_min' => 'integer',
'reward_max' => 'integer',
'expires_at' => 'datetime',
@@ -64,13 +69,17 @@ class MysteryBox extends Model
/**
* 当前可领取(open 状态 + 未过期)的箱子。
*/
public static function currentOpenBox(): ?static
public static function currentOpenBox(?int $roomId = null): ?static
{
return static::query()
$query = static::query()
->where('status', 'open')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->latest()
->first();
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
if ($roomId !== null) {
$query->where('room_id', $roomId);
}
return $query->latest()->first();
}
// ─── 工具方法 ────────────────────────────────────────────────────
+233
View File
@@ -0,0 +1,233 @@
<?php
/**
* 文件功能:游戏房间范围配置服务
*
* 统一解析所有游戏的 room_scope_mode room_ids 配置,
* 供后台保存、调度任务、前台准入校验和公共回合查询复用。
*/
namespace App\Services;
use App\Models\GameConfig;
use App\Models\User;
use Illuminate\Http\Request;
/**
* 类功能:统一管理所有游戏的房间范围读取与房间判定。
*/
class GameRoomScopeService
{
/**
* 房间模式常量:全部房间。
*/
public const MODE_ALL = 'all';
/**
* 房间模式常量:单选房间。
*/
public const MODE_SINGLE = 'single';
/**
* 房间模式常量:多选房间。
*/
public const MODE_MULTIPLE = 'multiple';
/**
* 支持的房间模式列表。
*
* @var array<int, string>
*/
public const SUPPORTED_MODES = [
self::MODE_ALL,
self::MODE_SINGLE,
self::MODE_MULTIPLE,
];
/**
* 构造房间范围服务。
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 归一化房间模式。
*/
public function normalizeRoomScopeMode(?string $mode, string $default = self::MODE_SINGLE): string
{
$normalizedMode = (string) $mode;
if (! in_array($normalizedMode, self::SUPPORTED_MODES, true)) {
return $default;
}
return $normalizedMode;
}
/**
* 把原始房间数组归一化为去重后的整型数组。
*
* @return array<int, int>
*/
public function normalizeRoomIds(mixed $roomIds, array $default = [1]): array
{
$items = is_array($roomIds)
? $roomIds
: preg_split('/[\s,]+/u', (string) $roomIds, -1, PREG_SPLIT_NO_EMPTY);
$normalizedRoomIds = collect($items)
->map(fn (mixed $roomId): int => (int) $roomId)
->filter(fn (int $roomId): bool => $roomId > 0)
->unique()
->values()
->all();
if ($normalizedRoomIds === []) {
return $default;
}
return $normalizedRoomIds;
}
/**
* params 数组中解析房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForParams(array $params, array $defaultRoomIds = [1]): array
{
if (
! array_key_exists('room_scope_mode', $params)
&& ! array_key_exists('room_ids', $params)
&& ! array_key_exists('room_id', $params)
) {
return [
'room_scope_mode' => self::MODE_ALL,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
$roomScopeMode = $this->normalizeRoomScopeMode(
mode: (string) ($params['room_scope_mode'] ?? self::MODE_SINGLE),
default: self::MODE_SINGLE,
);
$roomIds = $this->normalizeRoomIds(
roomIds: $params['room_ids'] ?? (($params['room_id'] ?? null) !== null ? [$params['room_id']] : []),
default: $defaultRoomIds,
);
return [
'room_scope_mode' => $roomScopeMode,
'room_ids' => $roomIds,
];
}
/**
* 读取指定游戏当前配置中的房间范围。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function getScopeConfigForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$params = GameConfig::forGame($gameKey)?->params ?? [];
return $this->getScopeConfigForParams($params, $defaultRoomIds);
}
/**
* 获取指定游戏真正生效的房间 ID 列表。
*
* @return array<int, int>
*/
public function getScopedRoomIdsForGame(string $gameKey, array $defaultRoomIds = [1]): array
{
$scopeConfig = $this->getScopeConfigForGame($gameKey, $defaultRoomIds);
if ($scopeConfig['room_scope_mode'] === self::MODE_ALL) {
return $this->resolveAllAvailableRoomIds($defaultRoomIds);
}
return $scopeConfig['room_ids'];
}
/**
* 获取指定游戏的首选房间。
*/
public function getPrimaryRoomIdForGame(string $gameKey, int $fallback = 1): int
{
$roomIds = $this->getScopedRoomIdsForGame($gameKey, [$fallback]);
return $roomIds[0] ?? $fallback;
}
/**
* 判断某个房间是否在指定游戏允许范围内。
*/
public function isRoomAllowedForGame(string $gameKey, int $roomId, array $defaultRoomIds = [1]): bool
{
return in_array($roomId, $this->getScopedRoomIdsForGame($gameKey, $defaultRoomIds), true);
}
/**
* 从请求或在线状态解析当前操作房间。
*/
public function resolveRequestRoomId(Request $request, ?User $user = null, int $fallback = 1): int
{
$requestedRoomId = (int) $request->integer('room_id', 0);
if ($requestedRoomId > 0) {
return $requestedRoomId;
}
return $this->resolveUserRoomId($user ?? $request->user(), $fallback);
}
/**
* 从用户在线房间或用户资料中推断当前房间。
*/
public function resolveUserRoomId(?User $user, int $fallback = 1): int
{
if (! $user) {
return $fallback;
}
$activeRoomIds = $this->chatState->getUserRooms($user->username);
if ($activeRoomIds !== []) {
return (int) $activeRoomIds[0];
}
$profileRoomId = (int) ($user->room_id ?? 0);
return $profileRoomId > 0 ? $profileRoomId : $fallback;
}
/**
* 返回通用后台复用的默认房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
public function defaultScopeConfig(array $defaultRoomIds = [1]): array
{
return [
'room_scope_mode' => self::MODE_SINGLE,
'room_ids' => $this->normalizeRoomIds($defaultRoomIds, [1]),
];
}
/**
* 在“全部房间”模式下解析当前可用房间。
*
* @return array<int, int>
*/
private function resolveAllAvailableRoomIds(array $defaultRoomIds = [1]): array
{
$roomIds = \App\Models\Room::query()
->orderBy('id')
->pluck('id')
->map(fn (mixed $roomId): int => (int) $roomId)
->all();
return $roomIds !== [] ? $roomIds : $defaultRoomIds;
}
}
+18 -13
View File
@@ -22,11 +22,15 @@ use App\Models\LotteryTicket;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* 类功能:负责双色球购票、开奖、滚存与房间广播。
*/
class LotteryService
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
) {}
// ─── 购票 ─────────────────────────────────────────────────────────
@@ -49,7 +53,8 @@ class LotteryService
throw new \RuntimeException('双色球彩票游戏未开启');
}
$issue = LotteryIssue::currentIssue();
$roomId = $this->roomScopeService->resolveUserRoomId($user);
$issue = LotteryIssue::currentIssue($roomId);
if (! $issue || ! $issue->isOpen()) {
throw new \RuntimeException('当前无正在进行的期次,或已停售');
}
@@ -135,7 +140,7 @@ class LotteryService
$firstTicket = $tickets[0];
$numsStr = $firstTicket->numbersLabel();
$moreStr = $buyCount > 1 ? "{$buyCount} 注号码" : '';
$this->pushSystemMessage("🎟️ 【双色球彩票】财神爷保佑!玩家【{$user->username}豪掷千金,购买了当前 #{$issue->issue_no}双色球 {$numsStr} {$moreStr},祝 Ta 中大奖!");
$this->pushSystemMessage("🎟️ 【{$user->username}购买 {$issue->issue_no}{$numsStr} {$moreStr}", (int) $issue->room_id);
return $tickets;
}
@@ -364,7 +369,8 @@ class LotteryService
}
$newIssue = LotteryIssue::create([
'issue_no' => LotteryIssue::nextIssueNo(),
'room_id' => (int) $prevIssue->room_id,
'issue_no' => LotteryIssue::nextIssueNo((int) $prevIssue->room_id),
'status' => 'open',
'pool_amount' => $carryAmount + $injectAmount,
'carry_amount' => $carryAmount,
@@ -444,9 +450,9 @@ class LotteryService
$detailStr = $details ? ' '.implode(' | ', $details) : '';
$content = "🎟️ 【双色球 第{$issue->issue_no}期 开奖】{$drawNums} {$line1}{$detailStr}";
$content = "🎟️ {$issue->issue_no} 期:{$drawNums} {$line1}{$detailStr}";
$this->pushSystemMessage($content);
$this->pushSystemMessage($content, (int) $issue->room_id);
// 触发微信机器人消息推送 (彩票开奖)
try {
@@ -463,20 +469,19 @@ class LotteryService
private function broadcastSuperIssue(LotteryIssue $issue): void
{
$pool = number_format($issue->pool_amount);
$content = "🎊🎟️ 【双色球超级期预警】第 {$issue->issue_no}已连续 {$issue->no_winner_streak} 期无一等奖"
."当前奖池 💰 {$pool} 金币,系统已追加注入!今日 {$issue->draw_at?->format('H:i')} 开奖,赶紧购票!";
$content = "🎊 超级期 {$issue->issue_no}已连续 {$issue->no_winner_streak} 期无一等奖,奖池 💰 {$pool}{$issue->draw_at?->format('H:i')} 开奖。";
$this->pushSystemMessage($content);
$this->pushSystemMessage($content, (int) $issue->room_id);
}
/**
* 向公屏发送系统消息。
*/
private function pushSystemMessage(string $content): void
private function pushSystemMessage(string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
@@ -485,8 +490,8 @@ class LotteryService
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
}
}
@@ -0,0 +1,62 @@
<?php
/**
* 文件功能:为公共回合型游戏补充 room_id 字段
*
* 让百家乐、赛马、彩票和神秘箱子可以按房间独立开局、查询与广播。
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 执行迁移。
*/
public function up(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('horse_races', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->unsignedBigInteger('room_id')->default(1)->after('id')->index();
});
}
/**
* 回滚迁移。
*/
public function down(): void
{
Schema::table('baccarat_rounds', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('horse_races', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('lottery_issues', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
Schema::table('mystery_boxes', function (Blueprint $table) {
$table->dropIndex(['room_id']);
$table->dropColumn('room_id');
});
}
};
+16
View File
@@ -32,6 +32,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统每隔一段时间自动开一局,玩家在倒计时内押注大/小/豹子,骰子结果决定胜负。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single', // 参与房间模式
'room_ids' => [1], // 参与房间列表
'interval_minutes' => 2, // 多少分钟开一局
'bet_window_seconds' => 60, // 每局押注窗口(秒)
'min_bet' => 100, // 最低押注金币
@@ -51,6 +53,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币转动老虎机,三列图案匹配可获得不同倍率奖励,三个7大奖全服广播。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'cost_per_spin' => 100, // 每次旋转消耗
'house_edge_percent' => 15, // 庄家边际(%
'daily_limit' => 100, // 每日最多转动次数(0=不限)
@@ -70,6 +74,8 @@ class GameConfigSeeder extends Seeder
'description' => '管理员随时投放或系统定时自动投放神秘箱,最快发送暗号的用户开箱获得奖励。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'auto_drop_enabled' => false, // 是否自动定时投放
'auto_interval_hours' => 2, // 自动投放间隔(小时)
'claim_window_seconds' => 60, // 领取窗口(秒)
@@ -91,6 +97,8 @@ class GameConfigSeeder extends Seeder
'description' => '系统定期举办赛马,用户在倒计时内下注,按注池赔率结算,跑马过程 WebSocket 实时播报。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'interval_minutes' => 30, // 多少分钟一场
'bet_window_seconds' => 90, // 押注窗口(秒)
'race_duration' => 30, // 跑马动画时长(秒)
@@ -110,6 +118,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一次免费占卜,系统生成玄学签文并赋予当日加成效果(幸运/倒霉)。额外占卜消耗金币。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'free_count_per_day' => 1, // 每日免费次数
'extra_cost' => 500, // 额外次数消耗金币
'buff_duration_hours' => 24, // 加成效果持续时间
@@ -128,6 +138,8 @@ class GameConfigSeeder extends Seeder
'description' => '消耗金币抛竿,等待浮漂下沉后点击收竿,随机获得奖励或惩罚。持有自动钓鱼卡可自动循环。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'fishing_cost' => 5, // 每次抛竿消耗金币
'fishing_wait_min' => 8, // 浮漂等待最短秒数
'fishing_wait_max' => 15, // 浮漂等待最长秒数
@@ -143,6 +155,8 @@ class GameConfigSeeder extends Seeder
'description' => '每日一期,选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
// ── 开奖时间 ──
'draw_hour' => 20, // 每天几点开奖(24小时制)
'draw_minute' => 0, // 几分开奖
@@ -178,6 +192,8 @@ class GameConfigSeeder extends Seeder
'description' => 'PvP 对战/人机对战,房间内随时发起邀请,超时或认输均自动结算。',
'enabled' => false,
'params' => [
'room_scope_mode' => 'single',
'room_ids' => [1],
'pvp_reward' => 200, // PvP 胜利奖励金币
'pvp_invite_timeout' => 30, // PvP 邀请超时(秒)
'pvp_move_timeout' => 45, // 每步落子超时(秒)
+30 -1
View File
@@ -9,6 +9,7 @@ let fishToken = null;
let autoFishing = false;
let autoFishCooldownTimer = null;
let autoFishCooldownCountdown = null;
let fishingCastPending = false;
/**
* 读取 CSRF Token。
@@ -199,6 +200,25 @@ function setFishingButton(text, disabled) {
button.disabled = disabled;
}
/**
* 判断当前是否已有进行中的钓鱼会话。
*
* 说明:
* - 手动点击抛竿后,在等待浮漂、等待点击、等待自动收竿期间都视为会话未结束。
* - 购买自动钓鱼卡后的自动接管,也必须避开这些中间态,避免重复抛竿。
*
* @returns {boolean}
*/
function hasActiveFishingSession() {
return Boolean(
fishingCastPending ||
fishToken ||
fishingTimer ||
fishingReelTimeout ||
document.getElementById("fishing-bobber"),
);
}
/**
* 启动自动钓鱼冷却倒计时(基于时间戳,不受浏览器后台节流影响)。
*
@@ -363,6 +383,11 @@ function hideAutoFishStopButton() {
* @returns {Promise<void>}
*/
export async function startFishing() {
if (hasActiveFishingSession()) {
return;
}
fishingCastPending = true;
setFishingButton("🎣 抛竿中...", true);
try {
@@ -381,6 +406,7 @@ export async function startFishing() {
return;
}
// 抛竿成功后进入正式钓鱼会话,由 token / timer 接管后续状态。
fishToken = data.token;
autoFishing = Boolean(data.auto_fishing);
appendFishingMessage(`<span style="color:#2563eb;font-weight:bold;">🎣【钓鱼】</span>${escapeHtml(data.message)}<span class="msg-time">(${timeText()})</span>`);
@@ -426,6 +452,8 @@ export async function startFishing() {
window.chatDialog?.alert?.(`网络错误:${error.message}`, "网络异常", "#cc4444");
removeBobber();
setFishingButton("🎣 钓鱼", false);
} finally {
fishingCastPending = false;
}
}
@@ -547,6 +575,7 @@ function clearAutoFishingTimers() {
*/
export function resetFishingBtn() {
autoFishing = false;
fishingCastPending = false;
clearAutoFishingTimers();
hideAutoFishStopButton();
@@ -573,7 +602,7 @@ export function checkAndAutoStartFishing() {
const minutesLeft = Number(window.chatContext?.autoFishingMinutesLeft || 0);
const initialCooldown = Number(window.chatContext?.fishingCooldownSeconds || 0);
if (minutesLeft <= 0 || autoFishing) {
if (minutesLeft <= 0 || autoFishing || hasActiveFishingSession()) {
return;
}
@@ -5,7 +5,6 @@
@section('content')
@php
$riddleTypeOptions = \App\Models\Riddle::typeOptions();
$availableRooms = \App\Models\Room::orderBy('id')->get();
@endphp
<div class="space-y-6">
@@ -88,6 +87,7 @@
{{-- 参数配置区域 --}}
<div class="p-5">
<form action="{{ route('admin.game-configs.params', $game) }}" method="POST"
data-game-room-form
@if ($game->game_key === 'idiom') data-idiom-config-form @endif>
@csrf
@@ -97,8 +97,9 @@
$hiddenLegacyKeys = $game->game_key === 'mystery_box'
? ['min_reward', 'max_reward', 'rare_min_reward', 'rare_max_reward']
: [];
$hiddenConfigKeys = ['room_scope_mode', 'room_ids'];
$paramKeys = array_values(array_unique(array_merge(array_keys($labels), array_keys($params))));
$paramKeys = array_values(array_filter($paramKeys, fn ($key) => ! in_array($key, $hiddenLegacyKeys, true)));
$paramKeys = array_values(array_filter($paramKeys, fn ($key) => ! in_array($key, array_merge($hiddenLegacyKeys, $hiddenConfigKeys), true)));
@endphp
@if ($game->game_key === 'idiom')
@@ -108,6 +109,9 @@
'riddleTypeOptions' => $riddleTypeOptions,
])
@else
@php
$roomScopeConfig = gameRoomScopeConfig($params);
@endphp
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
@foreach ($paramKeys as $paramKey)
@php
@@ -155,6 +159,14 @@
</div>
@endforeach
</div>
<div class="mt-4 border-t border-gray-100 pt-4">
@include('admin.game-configs.partials.common-room-scope', [
'availableRooms' => $availableRooms,
'roomScopeConfig' => $roomScopeConfig,
'roomScopeTitle' => '参与房间',
])
</div>
@endif
<div class="mt-4 flex items-center gap-3">
@@ -189,7 +201,7 @@
style="padding:8px 16px; background:linear-gradient(135deg,#7f1d1d,#ef4444); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:700; cursor:pointer; transition:opacity .15s;">
☠️ 投放黑化箱
</button>
<span class="text-xs text-gray-400">直接向 #1 房间投放,立即广播暗号</span>
<span class="text-xs text-gray-400">投放到当前配置的首选房间,立即广播暗号。</span>
</div>
</div>
@endif
@@ -461,4 +473,16 @@
->all(),
];
}
/**
* 解析通用游戏的房间范围配置。
*
* @return array{room_scope_mode:string,room_ids:array<int, int>}
*/
function gameRoomScopeConfig(array $params): array
{
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
return $roomScopeService->getScopeConfigForParams($params);
}
@endphp
@@ -0,0 +1,79 @@
@php
$roomScopeConfig = $roomScopeConfig ?? ['room_scope_mode' => 'single', 'room_ids' => [1]];
$roomScopeDataKey = $roomScopeDataKey ?? 'game';
$roomScopeTitle = $roomScopeTitle ?? '参与房间';
$roomScopeCheckedRoomIds = collect($roomScopeConfig['room_ids'] ?? [1])->map(fn ($roomId) => (int) $roomId)->all();
@endphp
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">参与房间模式</label>
<select name="params[room_scope_mode]"
data-game-room-mode
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="all" @selected(($roomScopeConfig['room_scope_mode'] ?? 'single') === 'all')>全部房间</option>
<option value="single" @selected(($roomScopeConfig['room_scope_mode'] ?? 'single') === 'single')>单选房间</option>
<option value="multiple" @selected(($roomScopeConfig['room_scope_mode'] ?? 'single') === 'multiple')>多选房间</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-2 block text-xs font-bold text-gray-600">{{ $roomScopeTitle }}</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-dashed border-slate-200 bg-white p-3 lg:grid-cols-3">
@foreach ($availableRooms as $room)
<label class="flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm text-slate-600">
<input type="checkbox"
name="params[room_ids][]"
value="{{ $room->id }}"
@checked(in_array((int) $room->id, $roomScopeCheckedRoomIds, true))
data-game-room-checkbox
class="rounded border-slate-300 text-indigo-600">
<span>#{{ $room->id }} {{ $room->name }}</span>
</label>
@endforeach
</div>
<p class="mt-2 text-xs text-gray-400">单选模式下只保留一个房间,多选模式可同时勾选多个房间。</p>
</div>
</div>
@once
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
function showAdminAlert(message, title = '提示', icon = '️') {
if (window.adminDialog?.alert) {
window.adminDialog.alert(message, title, icon);
return;
}
window.alert(message);
}
document.querySelectorAll('[data-game-room-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
const modeSelect = this.querySelector('[data-game-room-mode]');
const roomCheckboxes = Array.from(this.querySelectorAll('[data-game-room-checkbox]'));
const checkedRooms = roomCheckboxes.filter(function (checkbox) {
return checkbox.checked;
});
if (!modeSelect) {
return;
}
if (modeSelect.value === 'single' && checkedRooms.length > 1) {
event.preventDefault();
showAdminAlert('单选房间模式下只能选择一个房间。', '房间选择有误', '⚠️');
return;
}
if (modeSelect.value === 'multiple' && checkedRooms.length === 0) {
event.preventDefault();
showAdminAlert('多选房间模式下,请至少选择一个房间。', '房间选择有误', '⚠️');
}
});
});
});
</script>
@endpush
@endonce
@@ -1,6 +1,5 @@
@php
$sharedConfig = gameRiddleSharedConfig($params);
$checkedRoomIds = collect($sharedConfig['room_ids'])->map(fn ($roomId) => (int) $roomId)->all();
@endphp
<div class="space-y-4">
@@ -53,33 +52,17 @@
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<p class="mt-1 text-xs text-gray-400">分钟,0 表示仅手动出题。</p>
</div>
<div>
<label class="mb-1 block text-xs font-bold text-gray-600">参与房间模式</label>
<select name="params[room_scope_mode]"
data-idiom-room-mode
class="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-indigo-400">
<option value="all" @selected($sharedConfig['room_mode'] === 'all')>全部房间</option>
<option value="single" @selected($sharedConfig['room_mode'] === 'single')>单选房间</option>
<option value="multiple" @selected($sharedConfig['room_mode'] === 'multiple')>多选房间</option>
</select>
</div>
<div class="xl:col-span-2">
<label class="mb-2 block text-xs font-bold text-gray-600" data-idiom-room-label>参与房间</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-dashed border-slate-200 bg-white p-3 lg:grid-cols-3">
@foreach ($availableRooms as $room)
<label class="flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm text-slate-600">
<input type="checkbox"
name="params[room_ids][]"
value="{{ $room->id }}"
@checked(in_array((int) $room->id, $checkedRoomIds, true))
data-idiom-room-checkbox
class="rounded border-slate-300 text-indigo-600">
<span>#{{ $room->id }} {{ $room->name }}</span>
</label>
@endforeach
</div>
<p class="mt-2 text-xs text-gray-400">单选模式下只保留一个房间,多选模式可同时勾选多个房间。</p>
</div>
</div>
<div class="mt-4 border-t border-gray-100 pt-4">
@include('admin.game-configs.partials.common-room-scope', [
'availableRooms' => $availableRooms,
'roomScopeConfig' => [
'room_scope_mode' => $sharedConfig['room_mode'],
'room_ids' => $sharedConfig['room_ids'],
],
'roomScopeTitle' => '参与房间',
])
</div>
</div>
</div>
@@ -121,41 +104,6 @@
window.alert(message);
}
document.querySelectorAll('[data-idiom-config-form]').forEach(function (form) {
form.addEventListener('submit', function (event) {
let hasValidationError = false;
const modeSelect = this.querySelector('[data-idiom-room-mode]');
const roomCheckboxes = Array.from(this.querySelectorAll('[data-idiom-room-checkbox]'));
const checkedRooms = roomCheckboxes.filter(function (checkbox) {
return checkbox.checked;
});
if (modeSelect) {
if (modeSelect.value === 'single' && checkedRooms.length > 1) {
showAdminAlert('猜谜活动处于单选房间模式时,只能勾选一个房间。', '房间选择有误', '⚠️');
hasValidationError = true;
}
if (modeSelect.value === 'single' && checkedRooms.length === 0) {
const firstRoomCheckbox = roomCheckboxes[0];
if (firstRoomCheckbox) {
firstRoomCheckbox.checked = true;
}
}
if (modeSelect.value === 'multiple' && checkedRooms.length === 0) {
showAdminAlert('猜谜活动处于多选房间模式时,请至少选择一个房间。', '房间选择有误', '⚠️');
hasValidationError = true;
}
}
if (hasValidationError) {
event.preventDefault();
}
});
});
const manualStartButton = document.getElementById('idiom-manual-start-btn');
if (!manualStartButton) {
return;
+78 -57
View File
@@ -68,16 +68,21 @@ Schedule::call(function () {
$config = \App\Models\GameConfig::forGame('baccarat')?->params ?? [];
$interval = (int) ($config['interval_minutes'] ?? 2);
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 检查距上一局触发时间是否已达到间隔
$lastRound = \App\Models\BaccaratRound::latest()->first();
if ($lastRound && $lastRound->created_at->diffInMinutes(now()) < $interval) {
return; // 还没到开局时间
}
foreach ($roomScopeService->getScopedRoomIdsForGame('baccarat') as $roomId) {
$lastRound = \App\Models\BaccaratRound::query()
->where('room_id', $roomId)
->latest()
->first();
// 无当前进行中的局才开新局
if (! \App\Models\BaccaratRound::currentRound()) {
\App\Jobs\OpenBaccaratRoundJob::dispatch();
if ($lastRound && $lastRound->created_at->diffInMinutes(now()) < $interval) {
continue;
}
if (! \App\Models\BaccaratRound::currentRound($roomId)) {
\App\Jobs\OpenBaccaratRoundJob::dispatch($roomId);
}
}
})->everyMinute()->name('baccarat:open-round')->withoutOverlapping();
@@ -96,30 +101,33 @@ Schedule::call(function () {
return;
}
// 当前已有可领取的箱子时跳过(一次只投放一个)
if (\App\Models\MysteryBox::currentOpenBox()) {
return;
}
$intervalHours = (float) ($config['auto_interval_hours'] ?? 2);
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 检查距上次投放时间
$lastBox = \App\Models\MysteryBox::latest()->first();
if ($lastBox && $lastBox->created_at->diffInHours(now()) < $intervalHours) {
return;
foreach ($roomScopeService->getScopedRoomIdsForGame('mystery_box') as $roomId) {
if (\App\Models\MysteryBox::currentOpenBox($roomId)) {
continue;
}
$lastBox = \App\Models\MysteryBox::query()
->where('room_id', $roomId)
->latest()
->first();
if ($lastBox && $lastBox->created_at->diffInHours(now()) < $intervalHours) {
continue;
}
$trapChance = (int) ($config['trap_chance_percent'] ?? 10);
$rand = random_int(1, 100);
$boxType = match (true) {
$rand <= $trapChance => 'trap',
$rand <= $trapChance + 15 => 'rare',
default => 'normal',
};
\App\Jobs\DropMysteryBoxJob::dispatch($boxType, $roomId);
}
// 按配置的陷阱概率决定箱子类型
$trapChance = (int) ($config['trap_chance_percent'] ?? 10);
$rand = random_int(1, 100);
$boxType = match (true) {
$rand <= $trapChance => 'trap',
$rand <= $trapChance + 15 => 'rare',
default => 'normal',
};
\App\Jobs\DropMysteryBoxJob::dispatch($boxType);
})->everyMinute()->name('mystery-box:auto-drop')->withoutOverlapping();
// ──────────── 赛马竞猜定时任务 ─────────────────────────────────
@@ -130,21 +138,25 @@ Schedule::call(function () {
return;
}
// 当前已有进行中的场次(押注中/跑马中),跳过
if (\App\Models\HorseRace::currentRace()) {
return;
}
$config = \App\Models\GameConfig::forGame('horse_racing')?->params ?? [];
$interval = (int) ($config['interval_minutes'] ?? 30);
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 检查距上一场触发时间是否已达到间隔
$lastRace = \App\Models\HorseRace::latest()->first();
if ($lastRace && $lastRace->created_at->diffInMinutes(now()) < $interval) {
return;
foreach ($roomScopeService->getScopedRoomIdsForGame('horse_racing') as $roomId) {
if (\App\Models\HorseRace::currentRace($roomId)) {
continue;
}
$lastRace = \App\Models\HorseRace::query()
->where('room_id', $roomId)
->latest()
->first();
if ($lastRace && $lastRace->created_at->diffInMinutes(now()) < $interval) {
continue;
}
\App\Jobs\OpenHorseRaceJob::dispatch($roomId)->delay(now()->addSeconds(30));
}
\App\Jobs\OpenHorseRaceJob::dispatch()->delay(now()->addSeconds(30));
})->everyMinute()->name('horse-race:open-race')->withoutOverlapping();
// ──────────── 双色球彩票定时任务 ─────────────────────────────────
@@ -155,24 +167,29 @@ Schedule::call(function () {
return;
}
$issue = \App\Models\LotteryIssue::query()->whereIn('status', ['open', 'closed'])->latest()->first();
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
// 无进行中期次则自动创建一期
if (! $issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch();
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
$issue = \App\Models\LotteryIssue::query()
->where('room_id', $roomId)
->whereIn('status', ['open', 'closed'])
->latest()
->first();
return;
}
if (! $issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId);
// open 状态:检查是否已到停售时间
if ($issue->status === 'open' && $issue->sell_closes_at && now()->gte($issue->sell_closes_at)) {
$issue->update(['status' => 'closed']);
$issue->refresh();
}
continue;
}
// closed 状态:检查是否已到开奖时间
if ($issue->status === 'closed' && $issue->draw_at && now()->gte($issue->draw_at)) {
\App\Jobs\DrawLotteryJob::dispatch($issue);
if ($issue->status === 'open' && $issue->sell_closes_at && now()->gte($issue->sell_closes_at)) {
$issue->update(['status' => 'closed']);
$issue->refresh();
}
if ($issue->status === 'closed' && $issue->draw_at && now()->gte($issue->draw_at)) {
\App\Jobs\DrawLotteryJob::dispatch($issue);
}
}
})->everyMinute()->name('lottery:check')->withoutOverlapping();
@@ -194,8 +211,12 @@ Schedule::call(function () {
return;
}
$issue = \App\Models\LotteryIssue::currentIssue();
if ($issue && $issue->is_super_issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch(); // 触发广播
$roomScopeService = app(\App\Services\GameRoomScopeService::class);
foreach ($roomScopeService->getScopedRoomIdsForGame('lottery') as $roomId) {
$issue = \App\Models\LotteryIssue::currentIssue($roomId);
if ($issue && $issue->is_super_issue) {
\App\Jobs\OpenLotteryIssueJob::dispatch($roomId); // 触发广播
}
}
})->dailyAt('18:00')->name('lottery:super-reminder');