diff --git a/GAMES_TODO.md b/GAMES_TODO.md index a2639b5..8117dd2 100644 --- a/GAMES_TODO.md +++ b/GAMES_TODO.md @@ -43,43 +43,34 @@ - **货币来源**:`CurrencySource::MYSTERY_BOX` / `MYSTERY_BOX_TRAP`(含 `room_id` 流水记录) - **后台配置**:`game_configs` 表,可配置开关/自动投放间隔/各奖励范围/陷阱概率;支持手动投放三种类型 ---- - -## 🕐 待开发 - ### 🐎 赛马竞猜(Horse Racing) -**核心玩法**:定时举办赛马,用户押注马匹,按注池赔率结算,跑马过程 WebSocket 实时播报 - -**待开发清单:** - -- [ ] 数据库:`horse_races`(场次)+ `horse_bets`(下注记录) -- [ ] 模型:`HorseRace` / `HorseBet` -- [ ] 队列 Job:`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度)+ `CloseHorseRaceJob`(结算) -- [ ] 事件:`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`(PresenceChannel) -- [ ] 控制器:`HorseRaceController`(当前场次/下注/历史) -- [ ] 调度器:按配置间隔开赛 -- [ ] 前端:`chat/partials/horse-race.blade.php`(马匹赛道动画/实时进度条/注池赔率显示) -- [ ] 货币来源:`CurrencySource::HORSE_BET` / `HORSE_WIN` -- [ ] 配置参数:`interval_minutes` / `bet_window_seconds` / `race_duration` / `horse_count` / `min_bet` / `max_bet` / `house_take_percent` - ---- +- **类型**:定时自动开局(调度器每分钟检查,间隔可配置) +- **数据库**:`horse_races` + `horse_bets` +- **模型**:`HorseRace` / `HorseBet` +- **队列 Job**:`OpenHorseRaceJob`(开赛广播)+ `RunHorseRaceJob`(每秒播报马匹进度 + 确定胜者)+ `CloseHorseRaceJob`(结算) +- **事件**:`HorseRaceOpened` / `HorseRaceProgress` / `HorseRaceSettled`(PresenceChannel 广播) +- **控制器**:`HorseRaceController`(`/horse-race/current` / `/horse-race/bet` / `/horse-race/history`) +- **广播**:`horse.opened` / `horse.progress` / `horse.settled` +- **前端**:`chat/partials/horse-race-panel.blade.php`(倒计时/赛马道动画/实时赔率/可拖动FAB) +- **货币来源**:`CurrencySource::HORSE_BET` / `HORSE_WIN` +- **后台配置**:`game_configs` 表,马匹数量/押注窗口/跨马时长/庄家抓水比例均可配置 ### 🔮 神秘占卜(Fortune Telling) -**核心玩法**:每日免费占卜,系统生成玄学签文并给予当日加成;付费可多次 +- **类型**:玩家主动使用(每日免费 N 次,额外次数消耗金币) +- **数据库**:`fortune_logs` +- **模型**:`FortuneLog`(55+ 条签文内嵌在模型中) +- **控制器**:`FortuneTellingController`(`/fortune/today` 查今日 / `/fortune/tell` 占卜 / `/fortune/history` 历史) +- **前端**:`chat/partials/fortune-panel.blade.php`(卦象摇动动画/签文卡片/当日加成状态/可拖动FAB) +- **每日限制**:免费 N 次(可配置),额外次数消耗金币 +- **广播**:暂无实时广播(占卜结果仅展示给本人) +- **货币来源**:`CurrencySource::FORTUNE_COST` +- **后台配置**:`game_configs` 表,免费次数/额外消耗/各签概率均可配置 -**待开发清单:** +--- -- [ ] 数据库:`fortune_logs`(占卜记录,含签文和当日 buff 效果) -- [ ] 模型:`FortuneLog` -- [ ] 占卜库:预设 50+ 条签文(上上签/上签/中签/下签/大凶签),带对应加成描述 -- [ ] 控制器:`FortuneTellingController`(`/fortune/today` 查今日 / `/fortune/tell` 占卜) -- [ ] 前端:`chat/partials/fortune-panel.blade.php`(卦象动画/签文卡片/今日加成状态) -- [ ] 每日限制:免费1次,额外次数扣金币 -- [ ] Buff 系统(可选扩展):占卜结果影响当日经验/金币获取倍率(需修改自动存点逻辑) -- [ ] 货币来源:`CurrencySource::FORTUNE_COST` -- [ ] 配置参数:`free_count_per_day` / `extra_cost` / 各签概率 +## 🕐 待开发 --- @@ -87,7 +78,7 @@ - [ ] 后台游戏管理页面(`/admin/game-configs`)显示各游戏实时统计数据 - [ ] 各游戏历史记录在后台可查(管理员视角) -- [ ] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) +- [ ] 生产环境部署:`php artisan db:seed --class=GameConfigSeeder`(初始化游戏配置) 已经完成了 - [ ] 百家乐/老虎机 全面测试(多用户并发下注) --- diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 9433487..1296871 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -102,6 +102,15 @@ enum CurrencySource: string /** 神秘箱子——黑化陷阱(倒扣金币,负数) */ case MYSTERY_BOX_TRAP = 'mystery_box_trap'; + /** 赛马竞猜——下注消耗(扣除金币) */ + case HORSE_BET = 'horse_bet'; + + /** 赛马竞猜——中奖赔付(收入金币,含本金返还) */ + case HORSE_WIN = 'horse_win'; + + /** 神秘占卜——额外次数消耗(扣除金币) */ + case FORTUNE_COST = 'fortune_cost'; + /** * 返回该来源的中文名称,用于后台统计展示。 */ @@ -131,10 +140,13 @@ enum CurrencySource: string self::SLOT_SPIN => '老虎机转动', self::SLOT_WIN => '老虎机中奖', self::SLOT_CURSE => '老虎机诅咒', - self::RED_PACKET_RECV => '领取礼包红包(金币)', + self::RED_PACKET_RECV => '领取礼包红包(金币)', self::RED_PACKET_RECV_EXP => '领取礼包红包(经验)', - self::MYSTERY_BOX => '神秘箱子奖励', - self::MYSTERY_BOX_TRAP => '神秘箱子陷阱', + self::MYSTERY_BOX => '神秘箱子奖励', + self::MYSTERY_BOX_TRAP => '神秘箱子陷阱', + self::HORSE_BET => '赛马下注', + self::HORSE_WIN => '赛马赢钱', + self::FORTUNE_COST => '神秘占卜消耗', }; } } diff --git a/app/Events/HorseRaceOpened.php b/app/Events/HorseRaceOpened.php new file mode 100644 index 0000000..f5486c2 --- /dev/null +++ b/app/Events/HorseRaceOpened.php @@ -0,0 +1,67 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.1')]; + } + + /** + * 广播事件名(前端监听 .horse.opened)。 + */ + public function broadcastAs(): string + { + return 'horse.opened'; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'race_id' => $this->race->id, + 'horses' => $this->race->horses, + 'bet_opens_at' => $this->race->bet_opens_at->toIso8601String(), + 'bet_closes_at' => $this->race->bet_closes_at->toIso8601String(), + 'bet_seconds' => (int) now()->diffInSeconds($this->race->bet_closes_at), + ]; + } +} diff --git a/app/Events/HorseRaceProgress.php b/app/Events/HorseRaceProgress.php new file mode 100644 index 0000000..e1a9e7c --- /dev/null +++ b/app/Events/HorseRaceProgress.php @@ -0,0 +1,71 @@ + $positions 各马匹进度 [horse_id => progress(0~100)] + * @param bool $finished 是否已到终点 + * @param int|null $leaderId 当前领跑马匹 ID + */ + public function __construct( + public readonly int $raceId, + public readonly array $positions, + public readonly bool $finished = false, + public readonly ?int $leaderId = null, + ) {} + + /** + * 广播至房间公共频道。 + * + * @return array<\Illuminate\Broadcasting\Channel> + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.1')]; + } + + /** + * 广播事件名(前端监听 .horse.progress)。 + */ + public function broadcastAs(): string + { + return 'horse.progress'; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'race_id' => $this->raceId, + 'positions' => $this->positions, + 'finished' => $this->finished, + 'leader_id' => $this->leaderId, + ]; + } +} diff --git a/app/Events/HorseRaceSettled.php b/app/Events/HorseRaceSettled.php new file mode 100644 index 0000000..78b0ee8 --- /dev/null +++ b/app/Events/HorseRaceSettled.php @@ -0,0 +1,77 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.1')]; + } + + /** + * 广播事件名(前端监听 .horse.settled)。 + */ + public function broadcastAs(): string + { + return 'horse.settled'; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + // 找出获胜马匹的名称 + $horses = $this->race->horses ?? []; + $winnerName = '未知'; + foreach ($horses as $horse) { + if (($horse['id'] ?? 0) === $this->race->winner_horse_id) { + $winnerName = ($horse['emoji'] ?? '').' '.($horse['name'] ?? ''); + break; + } + } + + return [ + 'race_id' => $this->race->id, + 'winner_horse_id' => $this->race->winner_horse_id, + 'winner_name' => $winnerName, + 'total_pool' => $this->race->total_pool, + 'settled_at' => $this->race->settled_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Admin/UserManagerController.php b/app/Http/Controllers/Admin/UserManagerController.php index 25800cf..82d70bd 100644 --- a/app/Http/Controllers/Admin/UserManagerController.php +++ b/app/Http/Controllers/Admin/UserManagerController.php @@ -88,7 +88,7 @@ class UserManagerController extends Controller */ public function update(Request $request, User $user): JsonResponse|RedirectResponse { - $targetUser = $user; + $targetUser = $user; $currentUser = Auth::user(); // 超级管理员专属:仅 id=1 的账号可编辑用户信息 @@ -129,7 +129,24 @@ class UserManagerController extends Controller ); $targetUser->refresh(); } + + // 调整经验后重新计算等级(有职务用户锁定职务等级,无职务用户按经验重算) + $targetUser->load('activePosition.position'); + $superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100'); + if ($targetUser->activePosition?->position) { + // 有在职职务:等级锁定为职务级,不受经验影响 + $lockedLevel = (int) $targetUser->activePosition->position->level; + if ($lockedLevel > 0 && $targetUser->user_level !== $lockedLevel) { + $targetUser->user_level = $lockedLevel; + } + } elseif ($targetUser->user_level < $superLevel) { + // 无职务普通用户:按经验重算等级(不超过满级阈值) + $newLevel = \App\Models\Sysparam::calculateLevel($targetUser->exp_num ?? 0); + $safeLevel = max(1, min($newLevel, $superLevel - 1)); + $targetUser->user_level = $safeLevel; + } } + if (isset($validated['jjb'])) { $jjbDiff = $validated['jjb'] - ($targetUser->jjb ?? 0); if ($jjbDiff !== 0) { @@ -185,7 +202,7 @@ class UserManagerController extends Controller */ public function destroy(Request $request, User $user): RedirectResponse { - $targetUser = $user; + $targetUser = $user; $currentUser = Auth::user(); // 超级管理员专属:仅 id=1 的账号可删除用户 diff --git a/app/Http/Controllers/FortuneTellingController.php b/app/Http/Controllers/FortuneTellingController.php new file mode 100644 index 0000000..d332bb7 --- /dev/null +++ b/app/Http/Controllers/FortuneTellingController.php @@ -0,0 +1,166 @@ +json(['enabled' => false]); + } + + $user = $request->user(); + $config = GameConfig::forGame('fortune_telling')?->params ?? []; + + $freeCount = (int) ($config['free_count_per_day'] ?? 1); + $extraCost = (int) ($config['extra_cost'] ?? 500); + + $todayCount = FortuneLog::todayCount($user->id); + $todayLatest = FortuneLog::todayLatest($user->id); + $freeUsed = FortuneLog::query() + ->where('user_id', $user->id) + ->where('fortune_date', today()) + ->where('is_free', true) + ->count(); + $hasFreeLeft = $freeUsed < $freeCount; + + return response()->json([ + 'enabled' => true, + 'today_count' => $todayCount, + 'free_count' => $freeCount, + 'free_used' => $freeUsed, + 'has_free_left' => $hasFreeLeft, + 'extra_cost' => $extraCost, + 'latest' => $todayLatest ? [ + 'grade' => $todayLatest->grade, + 'grade_label' => $todayLatest->gradeLabel(), + 'grade_color' => $todayLatest->gradeColor(), + 'text' => $todayLatest->text, + 'buff_desc' => $todayLatest->buff_desc, + 'created_at' => $todayLatest->created_at->format('H:i'), + ] : null, + ]); + } + + /** + * 执行一次占卜。 + * + * 免费次数用完后每次消耗 extra_cost 金币。 + */ + public function tell(Request $request): JsonResponse + { + if (! GameConfig::isEnabled('fortune_telling')) { + return response()->json(['ok' => false, 'message' => '神秘占卜当前未开启。']); + } + + $user = $request->user(); + $config = GameConfig::forGame('fortune_telling')?->params ?? []; + + $freeCount = (int) ($config['free_count_per_day'] ?? 1); + $extraCost = (int) ($config['extra_cost'] ?? 500); + + // 判断今日免费次数是否已用完 + $freeUsed = FortuneLog::query() + ->where('user_id', $user->id) + ->where('fortune_date', today()) + ->where('is_free', true) + ->count(); + + $isFree = $freeUsed < $freeCount; + $cost = $isFree ? 0 : $extraCost; + + // 检查余额 + if (! $isFree && ($user->jjb ?? 0) < $cost) { + return response()->json(['ok' => false, 'message' => "金币不足,额外占卜需要 {$cost} 金币。"]); + } + + // 扣费 + if (! $isFree && $cost > 0) { + $this->currency->change( + $user, + 'gold', + -$cost, + CurrencySource::FORTUNE_COST, + '神秘占卜额外次数消耗', + ); + } + + // 抽签 + $grade = FortuneLog::rollGrade($config); + $fortune = FortuneLog::rollFortune($grade); + + // 记录 + $log = FortuneLog::create([ + 'user_id' => $user->id, + 'grade' => $grade, + 'text' => $fortune['text'], + 'buff_desc' => $fortune['buff_desc'] ?? null, + 'is_free' => $isFree, + 'cost' => $cost, + 'fortune_date' => today(), + ]); + + return response()->json([ + 'ok' => true, + 'grade' => $log->grade, + 'grade_label' => $log->gradeLabel(), + 'grade_color' => $log->gradeColor(), + 'text' => $log->text, + 'buff_desc' => $log->buff_desc, + 'is_free' => $isFree, + 'cost' => $cost, + ]); + } + + /** + * 查询近20条个人占卜历史记录。 + */ + public function history(Request $request): JsonResponse + { + $logs = FortuneLog::query() + ->where('user_id', $request->user()->id) + ->orderByDesc('id') + ->limit(20) + ->get(['grade', 'text', 'buff_desc', 'is_free', 'cost', 'fortune_date', 'created_at']) + ->map(fn ($log) => [ + 'grade' => $log->grade, + 'grade_label' => $log->gradeLabel(), + 'grade_color' => $log->gradeColor(), + 'text' => $log->text, + 'buff_desc' => $log->buff_desc, + 'cost' => $log->cost, + 'date' => $log->fortune_date->format('m-d'), + 'time' => $log->created_at->format('H:i'), + ]); + + return response()->json(['history' => $logs]); + } +} diff --git a/app/Http/Controllers/HorseRaceController.php b/app/Http/Controllers/HorseRaceController.php new file mode 100644 index 0000000..c11d3d7 --- /dev/null +++ b/app/Http/Controllers/HorseRaceController.php @@ -0,0 +1,224 @@ +json(['race' => null]); + } + + $user = $request->user(); + $myBet = HorseBet::query() + ->where('race_id', $race->id) + ->where('user_id', $user->id) + ->first(); + + // 计算各马匹当前注额 + $config = GameConfig::forGame('horse_racing')?->params ?? []; + $houseTake = (int) ($config['house_take_percent'] ?? 5); + + $horsePools = HorseBet::query() + ->where('race_id', $race->id) + ->groupBy('horse_id') + ->selectRaw('horse_id, SUM(amount) as pool') + ->pluck('pool', 'horse_id') + ->toArray(); + + // 计算实时赔率 + $horses = $race->horses ?? []; + $horsesWithBets = array_map(function ($horse) use ($horsePools, $houseTake) { + $horsePool = (int) ($horsePools[$horse['id']] ?? 0); + $totalPool = array_sum(array_values($horsePools)); + $netPool = $totalPool * (1 - $houseTake / 100); + $odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null; + + return [ + 'id' => $horse['id'], + 'name' => $horse['name'], + 'emoji' => $horse['emoji'], + 'pool' => $horsePool, + 'odds' => $odds, + ]; + }, $horses); + + return response()->json([ + 'race' => [ + 'id' => $race->id, + 'status' => $race->status, + 'bet_closes_at' => $race->bet_closes_at?->toIso8601String(), + 'seconds_left' => $race->status === 'betting' + ? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false)) + : 0, + 'horses' => $horsesWithBets, + 'total_pool' => $race->total_pool + array_sum(array_values($horsePools)), + 'my_bet' => $myBet ? [ + 'horse_id' => $myBet->horse_id, + 'amount' => $myBet->amount, + ] : null, + ], + ]); + } + + /** + * 用户提交下注。 + * + * 同一场每人限下一注,下注成功后立即扣除金币。 + */ + public function bet(Request $request): JsonResponse + { + if (! GameConfig::isEnabled('horse_racing')) { + return response()->json(['ok' => false, 'message' => '赛马竞猜当前未开启。']); + } + + $data = $request->validate([ + 'race_id' => 'required|integer|exists:horse_races,id', + 'horse_id' => 'required|integer|min:1', + 'amount' => 'required|integer|min:1', + ]); + + $config = GameConfig::forGame('horse_racing')?->params ?? []; + $minBet = (int) ($config['min_bet'] ?? 100); + $maxBet = (int) ($config['max_bet'] ?? 100000); + + if ($data['amount'] < $minBet || $data['amount'] > $maxBet) { + return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]); + } + + $race = HorseRace::find($data['race_id']); + + if (! $race || ! $race->isBettingOpen()) { + return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']); + } + + // 验证马匹 ID 是否有效 + $horses = $race->horses ?? []; + $validIds = array_column($horses, 'id'); + if (! in_array($data['horse_id'], $validIds, true)) { + return response()->json(['ok' => false, 'message' => '无效的马匹编号。']); + } + + $user = $request->user(); + $currency = $this->currency; + + // 校验余额 + if (($user->jjb ?? 0) < $data['amount']) { + return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']); + } + + return DB::transaction(function () use ($user, $race, $data, $currency, $horses): JsonResponse { + // 幂等:同一场只能下一注 + $existing = HorseBet::query() + ->where('race_id', $race->id) + ->where('user_id', $user->id) + ->lockForUpdate() + ->exists(); + + if ($existing) { + return response()->json(['ok' => false, 'message' => '本场您已下注,请等待开奖。']); + } + + // 找出马匹名称 + $horseName = ''; + foreach ($horses as $horse) { + if ((int) $horse['id'] === (int) $data['horse_id']) { + $horseName = ($horse['emoji'] ?? '').($horse['name'] ?? ''); + break; + } + } + + // 扣除金币 + $currency->change( + $user, + 'gold', + -$data['amount'], + CurrencySource::HORSE_BET, + "赛马 #{$race->id} 押注 {$horseName}", + ); + + // 写入下注记录 + HorseBet::create([ + 'race_id' => $race->id, + 'user_id' => $user->id, + 'horse_id' => $data['horse_id'], + 'amount' => $data['amount'], + 'status' => 'pending', + ]); + + return response()->json([ + 'ok' => true, + 'message' => "✅ 已押注「{$horseName}」{$data['amount']} 金币,等待开跑!", + 'amount' => $data['amount'], + 'horse_id' => $data['horse_id'], + ]); + }); + } + + /** + * 查询最近10场历史记录(前端展示胜负趋势)。 + */ + public function history(): JsonResponse + { + $races = HorseRace::query() + ->where('status', 'settled') + ->orderByDesc('id') + ->limit(10) + ->get(['id', 'horses', 'winner_horse_id', 'total_pool', 'total_bets', 'settled_at']); + + // 转换获胜马匹名称 + $history = $races->map(function ($race) { + $winnerName = '未知'; + foreach (($race->horses ?? []) as $horse) { + if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) { + $winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? ''); + break; + } + } + + return [ + 'id' => $race->id, + 'winner_id' => $race->winner_horse_id, + 'winner_name' => $winnerName, + 'total_pool' => $race->total_pool, + 'total_bets' => $race->total_bets, + 'settled_at' => $race->settled_at?->toDateTimeString(), + ]; + }); + + return response()->json(['history' => $history]); + } +} diff --git a/app/Http/Controllers/WeddingController.php b/app/Http/Controllers/WeddingController.php index ca60770..125bafd 100644 --- a/app/Http/Controllers/WeddingController.php +++ b/app/Http/Controllers/WeddingController.php @@ -107,4 +107,36 @@ class WeddingController extends Controller 'expires_at' => $ceremony->expires_at, ]); } + + /** + * 查询当前用户所有未领取且未过期的婚礼红包(页面刷新后恢复领取按钮用)。 + */ + public function pendingEnvelopes(Request $request): JsonResponse + { + $userId = $request->user()->id; + + // 查询有未领取 claim 的 active 婚礼(未过期) + $claims = \App\Models\WeddingEnvelopeClaim::query() + ->where('user_id', $userId) + ->where('claimed', false) + ->with(['ceremony.marriage.user', 'ceremony.marriage.partner', 'ceremony.tier']) + ->get() + ->filter(fn ($c) => $c->ceremony + && in_array($c->ceremony->status, ['active']) + && (! $c->ceremony->expires_at || $c->ceremony->expires_at->isFuture())) + ->values(); + + return response()->json([ + 'envelopes' => $claims->map(fn ($c) => [ + 'ceremony_id' => $c->ceremony_id, + 'amount' => $c->amount, + 'total_amount' => $c->ceremony->total_amount, + 'groom' => $c->ceremony->marriage->user->username ?? '—', + 'bride' => $c->ceremony->marriage->partner->username ?? '—', + 'tier_name' => $c->ceremony->tier?->name ?? '婚礼', + 'tier_icon' => $c->ceremony->tier?->icon ?? '🎊', + 'expires_at' => $c->ceremony->expires_at?->toDateTimeString(), + ]), + ]); + } } diff --git a/app/Jobs/CloseHorseRaceJob.php b/app/Jobs/CloseHorseRaceJob.php new file mode 100644 index 0000000..ddbf4fb --- /dev/null +++ b/app/Jobs/CloseHorseRaceJob.php @@ -0,0 +1,171 @@ +race->fresh(); + + // 防止重复结算 + if (! $race || $race->status !== 'running') { + return; + } + + // CAS 改状态为 settled + $updated = HorseRace::query() + ->where('id', $race->id) + ->where('status', 'running') + ->update(['status' => 'settled', 'settled_at' => now()]); + + if (! $updated) { + return; + } + + $race->refresh(); + + $config = GameConfig::forGame('horse_racing')?->params ?? []; + $houseTake = (int) ($config['house_take_percent'] ?? 5); + $winnerId = (int) $race->winner_horse_id; + + // 按马匹统计各匹下注金额 + $horsePools = HorseBet::query() + ->where('race_id', $race->id) + ->groupBy('horse_id') + ->selectRaw('horse_id, SUM(amount) as pool') + ->pluck('pool', 'horse_id') + ->toArray(); + + $totalPool = array_sum($horsePools); + $winnerPool = (int) ($horsePools[$winnerId] ?? 0); + $netPool = (int) ($totalPool * (1 - $houseTake / 100)); + + // 结算:遍历所有下注记录 + $bets = HorseBet::query() + ->where('race_id', $race->id) + ->where('status', 'pending') + ->with('user') + ->get(); + + $totalPayout = 0; + + DB::transaction(function () use ($bets, $winnerId, $netPool, $winnerPool, $currency, &$totalPayout) { + foreach ($bets as $bet) { + if ((int) $bet->horse_id !== $winnerId) { + // 未中奖(本金已在下注时扣除) + $bet->update(['status' => 'lost', 'payout' => 0]); + + continue; + } + + // 中奖:按注额比例分配净注池 + if ($winnerPool > 0) { + $payout = (int) round($netPool * ($bet->amount / $winnerPool)); + } else { + $payout = 0; + } + + $bet->update(['status' => 'won', 'payout' => $payout]); + + if ($payout > 0 && $bet->user) { + $currency->change( + $bet->user, + 'gold', + $payout, + CurrencySource::HORSE_WIN, + "赛马 #{$this->race->id} 「{$bet->horse_id}号马」中奖", + ); + } + + $totalPayout += $payout; + } + }); + + // 公屏公告 + $this->pushResultMessage($race, $chatState, $totalPayout); + + // 广播结算事件 + broadcast(new HorseRaceSettled($race)); + } + + /** + * 向公屏发送赛果系统消息。 + */ + private function pushResultMessage(HorseRace $race, ChatStateService $chatState, int $totalPayout): void + { + // 找出胜利马匹名称 + $horses = $race->horses ?? []; + $winnerName = '未知'; + foreach ($horses as $horse) { + if (($horse['id'] ?? 0) === (int) $race->winner_horse_id) { + $winnerName = ($horse['emoji'] ?? '').($horse['name'] ?? ''); + break; + } + } + + $payoutText = $totalPayout > 0 + ? '共派发 🪙'.number_format($totalPayout).' 金币' + : '本场无人获奖'; + + $content = "🏆 【赛马】第 #{$race->id} 场结束!冠军:{$winnerName}!{$payoutText}。"; + + $msg = [ + 'id' => $chatState->nextMessageId(1), + 'room_id' => 1, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => $content, + 'is_secret' => false, + 'font_color' => '#f59e0b', + 'action' => '大声宣告', + 'sent_at' => now()->toDateTimeString(), + ]; + $chatState->pushMessage(1, $msg); + broadcast(new MessageSent(1, $msg)); + SaveMessageJob::dispatch($msg); + } +} diff --git a/app/Jobs/OpenHorseRaceJob.php b/app/Jobs/OpenHorseRaceJob.php new file mode 100644 index 0000000..94ddf9a --- /dev/null +++ b/app/Jobs/OpenHorseRaceJob.php @@ -0,0 +1,96 @@ +params ?? []; + $betSeconds = (int) ($config['bet_window_seconds'] ?? 90); + $horseCount = (int) ($config['horse_count'] ?? 4); + $minBet = (int) ($config['min_bet'] ?? 100); + $maxBet = (int) ($config['max_bet'] ?? 100000); + + $now = now(); + $closesAt = $now->copy()->addSeconds($betSeconds); + + // 生成参赛马匹 + $horses = HorseRace::generateHorses($horseCount); + + // 创建新场次 + $race = HorseRace::create([ + 'status' => 'betting', + 'bet_opens_at' => $now, + 'bet_closes_at' => $closesAt, + 'horses' => $horses, + ]); + + // 广播开赛事件 + broadcast(new HorseRaceOpened($race)); + + // 公屏系统公告 + $horseList = implode(' ', array_map( + fn ($h) => "{$h['emoji']}{$h['name']}", + $horses + )); + $content = "🐎 【赛马】第 #{$race->id} 场开始!押注时间 {$betSeconds} 秒,参赛马匹:{$horseList}。押注范围 ".number_format($minBet).'~'.number_format($maxBet).' 金币!'; + + $msg = [ + 'id' => $chatState->nextMessageId(1), + 'room_id' => 1, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => $content, + 'is_secret' => false, + 'font_color' => '#f59e0b', + 'action' => '大声宣告', + 'sent_at' => $now->toDateTimeString(), + ]; + $chatState->pushMessage(1, $msg); + broadcast(new MessageSent(1, $msg)); + SaveMessageJob::dispatch($msg); + + // 押注截止后触发跑马 & 结算任务 + RunHorseRaceJob::dispatch($race)->delay($closesAt); + } +} diff --git a/app/Jobs/RunHorseRaceJob.php b/app/Jobs/RunHorseRaceJob.php new file mode 100644 index 0000000..eb8ba30 --- /dev/null +++ b/app/Jobs/RunHorseRaceJob.php @@ -0,0 +1,172 @@ +race->fresh(); + + // 防止重复执行 + if (! $race || $race->status !== 'betting') { + return; + } + + // 乐观锁:CAS 改状态为 running + $updated = HorseRace::query() + ->where('id', $race->id) + ->where('status', 'betting') + ->update([ + 'status' => 'running', + 'race_starts_at' => now(), + ]); + + if (! $updated) { + return; + } + + $race->refresh(); + + // 公屏通知:跑马开始 + $horseList = implode(' ', array_map( + fn ($h) => "{$h['emoji']}{$h['name']}", + $race->horses ?? [] + )); + $startMsg = [ + 'id' => $chatState->nextMessageId(1), + 'room_id' => 1, + 'from_user' => '系统传音', + 'to_user' => '大家', + 'content' => "🏇 【赛马】第 #{$race->id} 场押注截止!马匹已进入跑道,比赛开始!参赛阵容:{$horseList}", + 'is_secret' => false, + 'font_color' => '#336699', + 'action' => '大声宣告', + 'sent_at' => now()->toDateTimeString(), + ]; + $chatState->pushMessage(1, $startMsg); + broadcast(new MessageSent(1, $startMsg)); + SaveMessageJob::dispatch($startMsg); + + $config = GameConfig::forGame('horse_racing')?->params ?? []; + $raceDuration = (int) ($config['race_duration'] ?? 30); + $horses = $race->horses ?? []; + $horseCount = count($horses); + + // 初始化各马匹进度(0~100),每步随机增量 + $positions = array_fill_keys(array_column($horses, 'id'), 0); + $speeds = []; + foreach ($horses as $horse) { + // 基础速度:随机值,确保比赛有悬念(均值接近 race_duration 步完成) + $speeds[$horse['id']] = random_int(2, 5); + } + + // 跑马循环:模拟进度广播(此 job 为同步阻塞广播,每步 sleep 1 秒) + $step = 0; + $maxSteps = $raceDuration; + $winnerId = null; + + while ($step < $maxSteps && $winnerId === null) { + $step++; + + foreach ($horses as $horse) { + $horseId = $horse['id']; + // 随机冲刺(小概率加速) + $boost = random_int(0, 10) >= 9 ? random_int(5, 15) : 0; + $positions[$horseId] = min(100, $positions[$horseId] + $speeds[$horseId] + $boost); + } + + // 检查是否有马到达终点 + $finishedHorses = array_filter($positions, fn ($p) => $p >= 100); + $finished = ! empty($finishedHorses); + + if ($finished) { + // 取进度最高的马为冠军(若并列取 id 最小的) + arsort($finishedHorses); + $winnerId = (int) array_key_first($finishedHorses); + } + + // 广播当前帧进度 + broadcast(new HorseRaceProgress($race->id, $positions, $finished, $winnerId ?? $this->leadingHorse($positions))); + + if ($finished) { + break; + } + + sleep(1); + } + + // 如果时间到还没分出胜负,取最高进度的马为赢家 + if ($winnerId === null) { + arsort($positions); + $winnerId = (int) array_key_first($positions); + } + + // 更新场次记录 + $race->update([ + 'race_ends_at' => now(), + 'winner_horse_id' => $winnerId, + ]); + + // 计算注池统计 + $totalBets = HorseBet::query()->where('race_id', $race->id)->count(); + $totalPool = HorseBet::query()->where('race_id', $race->id)->sum('amount'); + + $race->update([ + 'total_bets' => $totalBets, + 'total_pool' => $totalPool, + ]); + + // 触发结算任务 + CloseHorseRaceJob::dispatch($race->fresh()); + } + + /** + * 获取当前领跑马匹 ID(进度最高)。 + * + * @param array $positions + */ + private function leadingHorse(array $positions): int + { + arsort($positions); + + return (int) array_key_first($positions); + } +} diff --git a/app/Models/FortuneLog.php b/app/Models/FortuneLog.php new file mode 100644 index 0000000..04e05b2 --- /dev/null +++ b/app/Models/FortuneLog.php @@ -0,0 +1,224 @@ + 'boolean', + 'cost' => 'integer', + 'fortune_date' => 'date', + ]; + } + + /** + * 占卜用户关联。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 查询指定用户今日占卜次数。 + */ + public static function todayCount(int $userId): int + { + return static::query() + ->where('user_id', $userId) + ->where('fortune_date', today()) + ->count(); + } + + /** + * 查询指定用户今日最新一条占卜记录。 + */ + public static function todayLatest(int $userId): ?static + { + return static::query() + ->where('user_id', $userId) + ->where('fortune_date', today()) + ->latest() + ->first(); + } + + /** + * 根据概率配置随机抽取一个签文等级。 + * + * @param array $config 游戏配置参数 + * @return string 签文等级:jackpot | good | normal | bad | curse + */ + public static function rollGrade(array $config): string + { + $jackpotChance = (int) ($config['jackpot_chance'] ?? 5); + $goodChance = (int) ($config['good_chance'] ?? 20); + $badChance = (int) ($config['bad_chance'] ?? 20); + $curseChance = (int) ($config['curse_chance'] ?? 5); + // normal 占剩余概率 + $normalChance = max(0, 100 - $jackpotChance - $goodChance - $badChance - $curseChance); + + $rand = random_int(1, 100); + + return match (true) { + $rand <= $jackpotChance => 'jackpot', + $rand <= $jackpotChance + $goodChance => 'good', + $rand <= $jackpotChance + $goodChance + $normalChance => 'normal', + $rand <= $jackpotChance + $goodChance + $normalChance + $badChance => 'bad', + default => 'curse', + }; + } + + /** + * 根据签文等级随机抽取签文内容。 + * + * @param string $grade 签文等级 + * @return array{text: string, buff_desc: string} 签文文字和加成描述 + */ + public static function rollFortune(string $grade): array + { + $library = self::fortuneLibrary(); + $pool = $library[$grade] ?? $library['normal']; + $entry = $pool[array_rand($pool)]; + + return $entry; + } + + /** + * 签文库:各等级预设签文(共 55 条)。 + * + * @return array> + */ + private static function fortuneLibrary(): array + { + return [ + // ─── 上上签(5条)────────────────────────────────────── + 'jackpot' => [ + ['text' => '龙凤呈祥,万事皆宜。天降鸿运,财源广进!', 'buff_desc' => '✨ 今日金币获取 +30%,经验获取 +20%'], + ['text' => '紫气东来,鸿运当头。凡事顺遂,财运亨通!', 'buff_desc' => '✨ 今日金币获取 +30%,魅力增长 +50%'], + ['text' => '吉星高照,百事大吉。此乃天赐良机,把握之!', 'buff_desc' => '✨ 今日全属性获取 +25%'], + ['text' => '神明庇佑,万难消散。今日出行,无往不利!', 'buff_desc' => '✨ 今日金币获取 +40%'], + ['text' => '天时地利人和,三才俱备。大展宏图,正此时也!', 'buff_desc' => '✨ 今日经验获取 +50%,金币 +20%'], + ], + + // ─── 上签(10条)────────────────────────────────────── + 'good' => [ + ['text' => '春风得意马蹄疾,一日看尽长安花。好运正来,进取可得!', 'buff_desc' => '🌸 今日金币获取 +15%'], + ['text' => '风调雨顺,五谷丰登。诸事顺利,喜事临门。', 'buff_desc' => '🌸 今日经验获取 +20%'], + ['text' => '柳暗花明又一村,峰回路转现坦途。坚持便是胜利!', 'buff_desc' => '🌸 今日金币及经验各 +10%'], + ['text' => '心想事成,万事如意。凡有所求,皆可如愿。', 'buff_desc' => '🌸 今日金币获取 +15%'], + ['text' => '鱼跃龙门,一步登天。今日努力,事半功倍!', 'buff_desc' => '🌸 今日经验获取 +25%'], + ['text' => '花好月圆,良辰美景。诸事大吉,百福临门。', 'buff_desc' => '🌸 今日魅力增长 +30%'], + ['text' => '云开雾散见朝阳,前路光明万里长。好运常伴,前途无量!', 'buff_desc' => '🌸 今日金币获取 +20%'], + ['text' => '锦上添花,好事成双。今日诸事皆宜,勇往直前!', 'buff_desc' => '🌸 今日全属性 +10%'], + ['text' => '马到成功,旗开得胜。积极行动,收获满满!', 'buff_desc' => '🌸 今日经验获取 +15%'], + ['text' => '福星高照,喜气洋洋。和气生财,贵人相助!', 'buff_desc' => '🌸 今日金币 +18%,魅力 +20%'], + ], + + // ─── 中签(20条)────────────────────────────────────── + 'normal' => [ + ['text' => '守株待兔不如主动出击,时机稍纵即逝,把握当下。', 'buff_desc' => null], + ['text' => '平平淡淡才是真,安分守己自太平。此乃中签,宜静不宜动。', 'buff_desc' => null], + ['text' => '水至清则无鱼,人至察则无徒。凡事保持平常心。', 'buff_desc' => null], + ['text' => '船到桥头自然直,车到山前必有路。莫要忧虑,顺其自然。', 'buff_desc' => null], + ['text' => '路漫漫其修远兮,吾将上下而求索。持之以恒,终见曙光。', 'buff_desc' => null], + ['text' => '千里之行,始于足下。今日无大凶无大吉,稳中求进。', 'buff_desc' => null], + ['text' => '谋事在人,成事在天。尽力而为,其余顺其自然。', 'buff_desc' => null], + ['text' => '不以物喜,不以己悲。心态平和,自有一番天地。', 'buff_desc' => null], + ['text' => '厚积薄发,积柔成刚。今日蓄力,来日爆发。', 'buff_desc' => null], + ['text' => '勤能补拙,笨鸟先飞。平庸亦可,持续努力方为上策。', 'buff_desc' => null], + ['text' => '事无大小,做好眼前即是。此签平稳,无风雨亦无彩虹。', 'buff_desc' => null], + ['text' => '知足者常乐,贪多者多失。今日知足,便是福气。', 'buff_desc' => null], + ['text' => '中庸之道,乃处世良方。不争不抢,随缘自在。', 'buff_desc' => null], + ['text' => '晴天要备雨伞,好时要留余地。居安思危,防患未然。', 'buff_desc' => null], + ['text' => '静水流深,沉默有力。今日低调行事,暗中蓄势。', 'buff_desc' => null], + ['text' => '月有阴晴圆缺,人有悲欢离合。此乃常态,坦然接受。', 'buff_desc' => null], + ['text' => '三人行,必有我师。今日宜多学习,少出风头。', 'buff_desc' => null], + ['text' => '凡事量力而行,不可强求。今日随缘,顺其自然。', 'buff_desc' => null], + ['text' => '忍一时风平浪静,退一步海阔天空。宽容待人,必有回报。', 'buff_desc' => null], + ['text' => '心静自然凉,此乃中签之意,平安即是福。', 'buff_desc' => null], + ], + + // ─── 下签(10条)────────────────────────────────────── + 'bad' => [ + ['text' => '乌云当头,诸事不顺。今日宜静守,切莫轻举妄动。', 'buff_desc' => '😞 今日金币获取 -10%'], + ['text' => '时运不济,命途多舛。凡事三思而后行,小心为上。', 'buff_desc' => '😞 今日经验获取 -10%'], + ['text' => '逆水行舟,举步维艰。今日诸事宜谨慎,避免重大决策。', 'buff_desc' => '😞 今日金币获取 -10%'], + ['text' => '阴云密布,好运暂退。宜韬光养晦,待时机再出。', 'buff_desc' => '😞 今日全属性 -5%'], + ['text' => '事与愿违,心力交瘁。今日多加休息,来日再战。', 'buff_desc' => '😞 今日经验获取 -15%'], + ['text' => '小人横行,道路坎坷。今日言多必失,祸从口出,慎言!', 'buff_desc' => '😞 今日金币 -10%,魅力 -10%'], + ['text' => '财帛散尽,才识运归。今日不宜大额消费,节俭为上。', 'buff_desc' => '😞 今日金币获取 -15%'], + ['text' => '阳光总在风雨后,苦尽甘来是常事。今日忍耐,明日可期。', 'buff_desc' => '😞 今日经验获取 -10%'], + ['text' => '暗箭难防,处处小心。今日谨慎行事,切勿冒进。', 'buff_desc' => '😞 今日全属性 -8%'], + ['text' => '屋漏偏逢连夜雨,行路偏遇顶头风。逆境磨砺,坚持即胜。', 'buff_desc' => '😞 今日金币获取 -12%'], + ], + + // ─── 大凶签(5条)────────────────────────────────────── + 'curse' => [ + ['text' => '大凶!鬼门大开,诸事皆凶。今日宜闭门避祸,切忌妄动!', 'buff_desc' => '💀 今日金币获取 -25%,经验 -20%'], + ['text' => '凶星临头,百事皆忌。今日万事不顺,速速祈神化解!', 'buff_desc' => '💀 今日金币获取 -30%'], + ['text' => '阴煞临身,大凶兆也!今日诸事皆休,静待天命。', 'buff_desc' => '💀 今日全属性 -20%'], + ['text' => '问此签,凶也大凶。如遇困境,切勿强行克服,顺势而为!', 'buff_desc' => '💀 今日经验获取 -30%,金币 -20%'], + ['text' => '天机不可泄露,然此签示警。今日三灾八难,小心谨慎,化凶为吉!', 'buff_desc' => '💀 今日金币获取 -25%,魅力 -15%'], + ], + ]; + } + + /** + * 返回签文等级对应的中文名称。 + */ + public function gradeLabel(): string + { + return match ($this->grade) { + 'jackpot' => '上上签', + 'good' => '上签', + 'normal' => '中签', + 'bad' => '下签', + 'curse' => '大凶签', + default => '未知', + }; + } + + /** + * 返回签文等级对应的颜色(前端展示用)。 + */ + public function gradeColor(): string + { + return match ($this->grade) { + 'jackpot' => '#f59e0b', // 金色 + 'good' => '#10b981', // 翠绿 + 'normal' => '#6b7280', // 灰色 + 'bad' => '#f97316', // 橙色 + 'curse' => '#ef4444', // 红色 + default => '#6b7280', + }; + } +} diff --git a/app/Models/HorseBet.php b/app/Models/HorseBet.php new file mode 100644 index 0000000..5f3ff48 --- /dev/null +++ b/app/Models/HorseBet.php @@ -0,0 +1,56 @@ + 'integer', + 'amount' => 'integer', + 'payout' => 'integer', + ]; + } + + /** + * 所属场次。 + */ + public function race(): BelongsTo + { + return $this->belongsTo(HorseRace::class, 'race_id'); + } + + /** + * 下注用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/HorseRace.php b/app/Models/HorseRace.php new file mode 100644 index 0000000..42c6079 --- /dev/null +++ b/app/Models/HorseRace.php @@ -0,0 +1,149 @@ + 'datetime', + 'bet_closes_at' => 'datetime', + 'race_starts_at' => 'datetime', + 'race_ends_at' => 'datetime', + 'settled_at' => 'datetime', + 'horses' => 'array', + 'winner_horse_id' => 'integer', + 'total_bets' => 'integer', + 'total_pool' => 'integer', + ]; + } + + /** + * 本场所有下注记录。 + */ + public function bets(): HasMany + { + return $this->hasMany(HorseBet::class, 'race_id'); + } + + /** + * 判断当前是否在押注时间窗口内。 + */ + public function isBettingOpen(): bool + { + return $this->status === 'betting' + && now()->between($this->bet_opens_at, $this->bet_closes_at); + } + + /** + * 查询当前正在进行的场次(状态为 betting 且押注未截止)。 + */ + public static function currentRace(): ?static + { + return static::query() + ->whereIn('status', ['betting', 'running']) + ->latest() + ->first(); + } + + /** + * 生成参赛马匹列表(根据马匹数量随机选名)。 + * + * @param int $count 马匹数量 + * @return array + */ + public static function generateHorses(int $count): array + { + // 可用马匹名池(原版竞技风格) + $namePool = [ + ['name' => '赤兔', 'emoji' => '🐎'], + ['name' => '乌骓', 'emoji' => '🐴'], + ['name' => '的卢', 'emoji' => '🎠'], + ['name' => '绝影', 'emoji' => '🦄'], + ['name' => '紫骍', 'emoji' => '🐎'], + ['name' => '爪黄', 'emoji' => '🐴'], + ['name' => '汗血', 'emoji' => '🎠'], + ['name' => '飞电', 'emoji' => '⚡'], + ]; + + // 随机打乱并取前 N 个 + shuffle($namePool); + $selected = array_slice($namePool, 0, $count); + + $horses = []; + foreach ($selected as $i => $horse) { + $horses[] = [ + 'id' => $i + 1, + 'name' => $horse['name'], + 'emoji' => $horse['emoji'], + ]; + } + + return $horses; + } + + /** + * 根据注池计算各马匹实时赔率(彩池制,扣除庄家抽水后按比例分配)。 + * + * @param int $horseBetAmounts 各马匹的注额数组 [horse_id => amount] + * @param int $housePercent 庄家抽水百分比 + * @return array horse_id => 赔率(含本金) + */ + public static function calcOdds(array $horseBetAmounts, int $housePercent): array + { + $totalPool = array_sum($horseBetAmounts); + + if ($totalPool <= 0) { + // 尚无下注,返回等额赔率 + $count = count($horseBetAmounts); + + return array_map(fn () => 1.0, $horseBetAmounts); + } + + $netPool = $totalPool * (1 - $housePercent / 100); + $odds = []; + + foreach ($horseBetAmounts as $horseId => $amount) { + if ($amount <= 0) { + // 无人押注的马,赔率设为理论最大值 + $odds[$horseId] = round($netPool, 2); + } else { + // 赔率 = 净注池 / 该马注额(含本金返还) + $odds[$horseId] = round($netPool / $amount, 2); + } + } + + return $odds; + } +} diff --git a/app/Models/WeddingEnvelopeClaim.php b/app/Models/WeddingEnvelopeClaim.php index 81cd1ac..e4101c9 100644 --- a/app/Models/WeddingEnvelopeClaim.php +++ b/app/Models/WeddingEnvelopeClaim.php @@ -1,10 +1,69 @@ + */ + protected $fillable = [ + 'ceremony_id', + 'user_id', + 'amount', + 'claimed', + 'claimed_at', + 'created_at', + ]; + + /** + * 获取属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'claimed' => 'boolean', + 'claimed_at' => 'datetime', + 'created_at' => 'datetime', + 'amount' => 'integer', + ]; + } + + /** + * 关联婚礼仪式。 + */ + public function ceremony(): BelongsTo + { + return $this->belongsTo(WeddingCeremony::class, 'ceremony_id'); + } + + /** + * 关联领取用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } } diff --git a/app/Services/AppointmentService.php b/app/Services/AppointmentService.php index 36f6a56..cbeeb0b 100644 --- a/app/Services/AppointmentService.php +++ b/app/Services/AppointmentService.php @@ -213,7 +213,7 @@ class AppointmentService ->whereNull('logout_at') ->whereDate('login_at', today()) ->update([ - 'logout_at' => now(), + 'logout_at' => now(), 'duration_seconds' => DB::raw('TIMESTAMPDIFF(SECOND, login_at, NOW())'), ]); @@ -222,12 +222,17 @@ class AppointmentService ->whereNull('logout_at') ->whereDate('login_at', '<', today()) ->update([ - 'logout_at' => DB::raw('login_at'), + 'logout_at' => DB::raw('login_at'), 'duration_seconds' => 0, ]); - // user_level 归 1(由系统经验值自然升级机制重新成长) - $target->update(['user_level' => 1]); + // 撤职后:按当前经验值重新计算等级(不再无条件归 1) + // 这样用户撤职后能保留正常的经验升级成果 + $recalcLevel = \App\Models\Sysparam::calculateLevel($target->exp_num ?? 0); + $superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100'); + // 不超过满级阈值 + $safeLevel = max(1, min($recalcLevel, $superLevel - 1)); + $target->update(['user_level' => $safeLevel]); // 写入权限操作日志 $this->logAuthority( diff --git a/config/logging.php b/config/logging.php index 9e998a4..cf2be34 100644 --- a/config/logging.php +++ b/config/logging.php @@ -127,6 +127,15 @@ return [ 'path' => storage_path('logs/laravel.log'), ], + // 覆盖 Laravel Boost 默认的 browser 日志频道,改为按天分割存储。 + // Boost 在注册时会检查此频道是否已存在,若已定义则直接使用此配置。 + 'browser' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/browser.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 14, + ], + ], ]; diff --git a/database/migrations/2026_03_03_193636_create_horse_races_table.php b/database/migrations/2026_03_03_193636_create_horse_races_table.php new file mode 100644 index 0000000..0144aa6 --- /dev/null +++ b/database/migrations/2026_03_03_193636_create_horse_races_table.php @@ -0,0 +1,62 @@ +id(); + + // 场次状态:betting(押注中)| running(跑马中)| settled(已结算) + $table->string('status', 20)->default('betting')->index(); + + // 押注时间窗口 + $table->timestamp('bet_opens_at')->nullable(); + $table->timestamp('bet_closes_at')->nullable(); + + // 跑马开始和结束时间 + $table->timestamp('race_starts_at')->nullable(); + $table->timestamp('race_ends_at')->nullable(); + + // 参赛马匹(JSON 数组:[{id, name, emoji, odds}]) + $table->json('horses')->nullable(); + + // 获胜马匹 ID(1~N) + $table->tinyInteger('winner_horse_id')->nullable(); + + // 本场统计 + $table->unsignedBigInteger('total_bets')->default(0); // 总参与人次 + $table->unsignedBigInteger('total_pool')->default(0); // 注池总金额 + + $table->timestamp('settled_at')->nullable(); + + $table->timestamps(); + }); + } + + /** + * 回滚。 + */ + public function down(): void + { + Schema::dropIfExists('horse_races'); + } +}; diff --git a/database/migrations/2026_03_03_193637_create_fortune_logs_table.php b/database/migrations/2026_03_03_193637_create_fortune_logs_table.php new file mode 100644 index 0000000..c81b18a --- /dev/null +++ b/database/migrations/2026_03_03_193637_create_fortune_logs_table.php @@ -0,0 +1,63 @@ +id(); + + // 占卜用户 + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + + // 签文类型:jackpot(上上签)| good(上签)| normal(中签)| bad(下签)| curse(大凶签) + $table->string('grade', 20); + + // 签文内容(预设文字) + $table->string('text', 500); + + // 当日加成描述(例如:"今日经验+20%") + $table->string('buff_desc', 200)->nullable(); + + // 是否为免费次数(false=付费额外次数) + $table->boolean('is_free')->default(true); + + // 占卜消耗金币(免费次数为 0) + $table->unsignedInteger('cost')->default(0); + + // 占卜日期(用于计算每日首次) + $table->date('fortune_date'); + + $table->timestamps(); + + // 加速每日次数查询 + $table->index(['user_id', 'fortune_date']); + }); + } + + /** + * 回滚。 + */ + public function down(): void + { + Schema::dropIfExists('fortune_logs'); + } +}; diff --git a/database/migrations/2026_03_03_193637_create_horse_bets_table.php b/database/migrations/2026_03_03_193637_create_horse_bets_table.php new file mode 100644 index 0000000..34f1ee3 --- /dev/null +++ b/database/migrations/2026_03_03_193637_create_horse_bets_table.php @@ -0,0 +1,63 @@ +id(); + + // 关联赛事 + $table->foreignId('race_id')->constrained('horse_races')->cascadeOnDelete(); + + // 下注用户 + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + + // 押注的马匹 ID(1~N) + $table->tinyInteger('horse_id'); + + // 下注金额 + $table->unsignedBigInteger('amount'); + + // 状态:pending(待开奖)| won(中奖)| lost(未中) + $table->string('status', 20)->default('pending'); + + // 实际赔付金额(含本金返还,lost 时为 0) + $table->unsignedBigInteger('payout')->default(0); + + $table->timestamps(); + + // 每场每人只能押注一次 + $table->unique(['race_id', 'user_id']); + + // 加速查询 + $table->index('status'); + }); + } + + /** + * 回滚。 + */ + public function down(): void + { + Schema::dropIfExists('horse_bets'); + } +}; diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 7f8f866..be32cef 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -143,6 +143,12 @@ @include('chat.partials.slot-machine') {{-- ═══════════ 神秘箱子游戏面板 ═══════════ --}} @include('chat.partials.mystery-box') + {{-- ═══════════ 赛马竞猜游戏面板 ═══════════ --}} + @include('chat.partials.horse-race-panel') + {{-- ═══════════ 神秘占卜游戏面板 ═══════════ --}} + @include('chat.partials.fortune-panel') + {{-- ═══════════ 娱乐游戏大厅弹窗 ═══════════ --}} + @include('chat.partials.game-hall') {{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}} diff --git a/resources/views/chat/partials/baccarat-panel.blade.php b/resources/views/chat/partials/baccarat-panel.blade.php index 6a6c4df..4d12d9b 100644 --- a/resources/views/chat/partials/baccarat-panel.blade.php +++ b/resources/views/chat/partials/baccarat-panel.blade.php @@ -8,207 +8,203 @@ - 展示近10局历史趋势 --}} -{{-- ─── 百家乐主面板 ─── --}} +{{-- 百家乐主面板 --}}
+ style="width:460px; max-width:96vw; border-radius:8px; overflow:hidden; + box-shadow:0 8px 32px rgba(0,0,0,.3); font-family:'Microsoft YaHei',SimSun,sans-serif; background:#fff;"> {{-- ─── 顶部标题 ─── --}}
-
-
-
🎲 百家乐
-
- 第 局 -
-
- {{-- 倒计时 --}} -
-
-
-
秒后截止
-
- {{-- 骰子结果 --}} -
-
-
+ style="background:linear-gradient(135deg,#336699,#5a8fc0); padding:10px 16px; + display:flex; align-items:center; justify-content:space-between;"> +
+
🎲 百家乐
+
+ 第
+ {{-- 倒计时 --}} +
+
+
+
秒后截止
+
+ {{-- 骰子结果 --}} +
+
+
+
+
+
- {{-- 进度条 --}} -
-
-
+ {{-- 进度条 --}} +
+
{{-- ─── 历史趋势 ─── --}}
- 近期 + style="background:#f0f6ff; padding:7px 14px; display:flex; gap:5px; align-items:center; + flex-wrap:wrap; border-bottom:1px solid #d0e4f5;"> + 近期 - 暂无记录 + 暂无记录
- {{-- ─── 主体内容 ─── --}} -
+ {{-- ─── 主体内容(白底) ─── --}} +
{{-- 押注阶段 --}}
- {{-- 当前下注池统计 --}} -
-
-
押大
-
+
+
押大
+
-
-
押小
-
+
押小
+
-
押豹子
-
+
押豹子
+
- {{-- 已下注状态 / 下注表单 --}} + {{-- 已下注状态 --}}
-
+ style="background:#f0fdf4; border:1px solid #86efac; border-radius:10px; + padding:10px 14px; text-align:center; margin-bottom:10px;"> +
✅ 已押注「 金币
-
等待开奖中…
+
等待开奖中…
+ {{-- 下注表单 --}}
{{-- 押注选项 --}}
{{-- 大 --}} {{-- 小 --}} {{-- 豹子 --}}
- {{-- 快捷金额 + 自定义 --}} -
-
- -
- + {{-- 快捷金额 --}} +
+
+ {{-- 自定义金额 --}} + + {{-- 下注按钮 --}}
{{-- 规则提示 --}} -
+
☠️ 3点或18点为庄家收割,全灭无退款。豹子优先于大小判断。
{{-- 等待开奖阶段 --}} -
-
🎲
-
正在摇骰子…
+
+
🎲
+
正在摇骰子…
{{-- 结算阶段 --}}
- {{-- 骰子展示(数字方块,跨平台兼容) --}} -
+ {{-- 骰子展示 --}} +
@@ -216,13 +212,13 @@ {{-- 结果标签 --}}
-
-
@@ -230,15 +226,14 @@
{{-- 中奖 --}}
+ style="border-radius:12px; overflow:hidden; margin-bottom:4px; + background:#f0fdf4; border:1px solid #86efac;">
🎉
-
恭喜中奖!
-
恭喜中奖!
+
-
@@ -246,48 +241,46 @@ {{-- 未中奖 --}}
+ style="border-radius:12px; overflow:hidden; margin-bottom:4px; + background:#fff5f5; border:1px solid #fecaca;">
😔
-
本局未中奖 +
本局未中奖
- 你押了 - + 你押了 + - · - 开了 + · + 开了
-
{{-- 未下注但看结果 --}} -
+
本局未参与下注
- {{-- ─── 底部关闭 ─── --}} -
+ {{-- 底部关闭 --}} +
@@ -295,19 +288,7 @@
-{{-- ─── 骨骰悬浮入口(游戏开启时常驻,支持拖拽) ─── --}} -
- -
+ diff --git a/resources/views/chat/partials/game-hall.blade.php b/resources/views/chat/partials/game-hall.blade.php new file mode 100644 index 0000000..a0f1f9a --- /dev/null +++ b/resources/views/chat/partials/game-hall.blade.php @@ -0,0 +1,396 @@ +{{-- + 文件功能:娱乐游戏大厅弹窗组件 + + 点击工具栏「娱乐」按钮后弹出,展示所有已开启的游戏: + - 百家乐:当前场次状态 + 倒计时 + 直接参与按钮 + - 老虎机:今日限额余量 + 直接打开按钮 + - 神秘箱子:已投放数量 + 直接打开按钮 + - 赛马竞猜:当前场次状态 + 参与按钮 + - 神秘占卜:今日占卜次数 + 直接打开按钮 + - 钓鱼:状态 + 打开按钮 + + @author ChatRoom Laravel + @version 1.0.0 +--}} + +{{-- ─── 服务端注入各游戏开关状态(避免前端额外请求)─── --}} +@php + $gameEnabled = [ + 'baccarat' => \App\Models\GameConfig::isEnabled('baccarat'), + 'slot_machine' => \App\Models\GameConfig::isEnabled('slot_machine'), + 'mystery_box' => \App\Models\GameConfig::isEnabled('mystery_box'), + 'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'), + 'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'), + 'fishing' => \App\Models\GameConfig::isEnabled('fishing'), + ]; +@endphp + + +{{-- ═══════════ 游戏大厅弹窗遮罩 ═══════════ --}} + + + diff --git a/resources/views/chat/partials/horse-race-panel.blade.php b/resources/views/chat/partials/horse-race-panel.blade.php new file mode 100644 index 0000000..7053d2f --- /dev/null +++ b/resources/views/chat/partials/horse-race-panel.blade.php @@ -0,0 +1,677 @@ +{{-- + 文件功能:赛马竞猜前台弹窗组件 + + 聊天室内赛马竞猜游戏面板: + - 监听 WebSocket horse.opened 事件触发弹窗 + - 展示参赛马匹列表和实时注池赔率 + - 倒计时押注后进入跑马阶段(动态进度条) + - 监听 horse.progress 更新赛道动画 + - 监听 horse.settled 展示结果 + 个人赔付 + - 展示近10场历史趋势 +--}} + +{{-- ─── 赛马主面板 ─── --}} +
+
+ +
+ + {{-- ─── 标题栏(海军蓝风格)─── --}} +
+
+ 🐎 赛马竞猜 + +
+ {{-- 倒计时(押注阶段) --}} +
+ ⏳ 剩 秒 +
+ {{-- 跑马阶段 --}} +
+ 🏇 跑马中… +
+ {{-- 结算 --}} +
🏆 已结算
+ × +
+ + {{-- 押注进度条(蓝色风格) --}} +
+
+
+ + {{-- ─── 历史趋势(蓝白色系)─── --}} +
+ 近期冒涨: + + 暂无记录 +
+ + {{-- ─── 主体内容(白底)─── --}} +
+ + {{-- ── 押注阶段 ── --}} +
+ {{-- 注池统计 --}} +
+ 注池总额: +
+ + {{-- 马匹列表 --}} +
+ +
+ + {{-- 已下注状态 --}} +
+
+
+ ✅ 已押注「」 + 金币 +
+
等待开跑…
+
+
+ + {{-- 下注区 --}} +
+ {{-- 快捷金额 --}} +
+ +
+ + + {{-- 下注按钮 --}} + +
+
+ + {{-- ── 跑马阶段 ── --}} +
+
+ 🏁 赛道实况 +
+
+ +
+
+ 🏁 终点线 +
+
+ + {{-- ── 结算阶段(蓝白风格)── --}} +
+ {{-- 获胜马匹 --}} +
+
+
+
+
+
+ + {{-- 个人结果 --}} +
+ {{-- 中奖 --}} +
+
🎉
+
恭喜中奖!
+
+
+
+
+ {{-- 未中奖 --}} +
+
😔
+
本场未中奖 +
+
+
+
+
+
+ 本场未参与下注 +
+
+
+ + {{-- ─── 底部关闭 ─── --}} +
+ +
+
+
+
+ + + + + + diff --git a/resources/views/chat/partials/marriage-modals.blade.php b/resources/views/chat/partials/marriage-modals.blade.php index 55a2a91..aa839de 100644 --- a/resources/views/chat/partials/marriage-modals.blade.php +++ b/resources/views/chat/partials/marriage-modals.blade.php @@ -611,49 +611,54 @@ style="position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:9910; display:flex; align-items:center; justify-content:center;">
-
-
🧧
-
-
+ style="width:380px; max-width:95vw; + background:linear-gradient(160deg,#c0392b,#e74c3c,#c0392b); + border-radius:20px; overflow:hidden; text-align:center; + box-shadow:0 24px 80px rgba(231,76,60,.65), 0 0 0 1px rgba(255,210,100,.25); + border:1px solid rgba(255,210,100,.3);"> +
+
🧧
+
+
{{-- 未领取 --}}
-
+
红包有效期 24小时,过期自动消失
- {{-- 仿"同意离婚"按钮:深色外框 + 内部实心颜色按钮 --}} -
+ + {{-- 圆形领取按钮(仿「開」按钮,全样式写入 :style 避免 Alpine 覆盖) --}} +
- {{-- 已领取 --}} -
-
+
-
🎉 恭喜你领取了红包!
-
金币已自动到账
+
🎉 恭喜你领取了红包!
+
金币已自动到账
-
+ + {{-- 关闭按钮 --}} +
@@ -661,6 +666,7 @@
+