$numbers 每注号码:[['reds'=>[3,8,12],'blue'=>4], ...] * @param bool $quickPick 是否机选 * @return array 新建的购票记录列表 * * @throws \RuntimeException */ public function buyTickets(User $user, array $numbers, bool $quickPick = false): array { $config = GameConfig::forGame('lottery')?->params ?? []; if (! GameConfig::isEnabled('lottery')) { throw new \RuntimeException('双色球彩票游戏未开启'); } $issue = LotteryIssue::currentIssue(); if (! $issue || ! $issue->isOpen()) { throw new \RuntimeException('当前无正在进行的期次,或已停售'); } $ticketPrice = (int) ($config['ticket_price'] ?? 100); $maxPerUser = (int) ($config['max_tickets_per_user'] ?? 50); $maxPerBuy = (int) ($config['max_tickets_per_buy'] ?? 10); $poolRatio = (int) ($config['pool_ratio'] ?? 70); // 本期已购注数 $alreadyBought = LotteryTicket::query() ->where('issue_id', $issue->id) ->where('user_id', $user->id) ->count(); $buyCount = count($numbers); if ($buyCount > $maxPerBuy) { throw new \RuntimeException("单次最多购买 {$maxPerBuy} 注"); } if ($alreadyBought + $buyCount > $maxPerUser) { $remain = $maxPerUser - $alreadyBought; throw new \RuntimeException("本期单人最多 {$maxPerUser} 注,您还可购 {$remain} 注"); } $totalCost = $ticketPrice * $buyCount; if ($user->jjb < $totalCost) { throw new \RuntimeException("金币不足,需要 {$totalCost} 金币"); } $tickets = []; DB::transaction(function () use ( $user, $numbers, $issue, $ticketPrice, $poolRatio, $quickPick, &$tickets ) { foreach ($numbers as $idx => $num) { // ...existing validation/DB storing logic... $reds = $num['reds']; sort($reds); // 红球排序存储,方便比对 /** @var LotteryTicket $ticket */ $ticket = LotteryTicket::create([ 'issue_id' => $issue->id, 'user_id' => $user->id, 'red1' => $reds[0], 'red2' => $reds[1], 'red3' => $reds[2], 'blue' => $num['blue'], 'amount' => $ticketPrice, 'is_quick_pick' => $quickPick, 'prize_level' => 0, 'payout' => 0, ]); $tickets[] = $ticket; // 扣除购票金币并记录流水 $this->currency->change( user: $user, currency: 'gold', amount: -$ticketPrice, source: CurrencySource::LOTTERY_BUY, remark: "双色球 #{$issue->issue_no} 第".($idx + 1).'注 '.$ticket->numbersLabel(), ); // 记录奖池流水(购票入池部分) $poolIn = (int) round($ticketPrice * $poolRatio / 100); $issue->increment('pool_amount', $poolIn); $issue->increment('total_tickets'); LotteryPoolLog::create([ 'issue_id' => $issue->id, 'change_amount' => $poolIn, 'reason' => 'ticket_sale', 'pool_after' => $issue->fresh()->pool_amount, 'remark' => "用户 {$user->username} 购第".($idx + 1).'注', 'created_at' => now(), ]); } }); // 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票) $firstTicket = $tickets[0]; $numsStr = $firstTicket->numbersLabel(); $moreStr = $buyCount > 1 ? "等 {$buyCount} 注号码" : ''; $this->pushSystemMessage("🎟️ 【双色球彩票】财神爷保佑!玩家【{$user->username}】豪掷千金,购买了当前 #{$issue->issue_no} 期双色球 {$numsStr} {$moreStr},祝 Ta 中大奖!"); return $tickets; } /** * 机选号码(随机生成一注或多注)。 * * @param int $count 生成注数 * @return array 格式:[['reds'=>[...], 'blue'=>n], ...] */ public function quickPick(int $count = 1): array { $result = []; for ($i = 0; $i < $count; $i++) { $reds = []; while (count($reds) < 3) { $n = random_int(1, 12); if (! in_array($n, $reds, true)) { $reds[] = $n; } } sort($reds); $result[] = [ 'reds' => $reds, 'blue' => random_int(1, 6), ]; } return $result; } // ─── 开奖 ───────────────────────────────────────────────────────── /** * 执行开奖:随机号码 → 逐票结算 → 奖池派发 → 公屏广播。 * * 由 DrawLotteryJob 在每日指定时间调用。 */ public function draw(LotteryIssue $issue): void { // 防重复开奖 if ($issue->status !== 'closed') { return; } $config = GameConfig::forGame('lottery')?->params ?? []; // ── 生成开奖号码 ── $reds = []; while (count($reds) < 3) { $n = random_int(1, 12); if (! in_array($n, $reds, true)) { $reds[] = $n; } } sort($reds); $blue = random_int(1, 6); // 写入开奖号码 $issue->update([ 'red1' => $reds[0], 'red2' => $reds[1], 'red3' => $reds[2], 'blue' => $blue, 'draw_at' => now(), ]); $issue->refresh(); // ── 加载所有购票记录 ── $tickets = LotteryTicket::query() ->where('issue_id', $issue->id) ->with('user') ->get(); $poolAmount = $issue->pool_amount; // ── 先扣除固定小奖(四/五等,从奖池中扣)── $prize4Fixed = (int) ($config['prize_4th_fixed'] ?? 150); $prize5Fixed = (int) ($config['prize_5th_fixed'] ?? 50); $prize4Tickets = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 4); $prize5Tickets = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 5); $totalFixed = ($prize4Tickets->count() * $prize4Fixed) + ($prize5Tickets->count() * $prize5Fixed); // 若奖池不足则按比例缩减固定奖 $fixedScale = $totalFixed > 0 && $totalFixed > $poolAmount ? $poolAmount / $totalFixed : 1.0; $poolAfterFixed = max(0, $poolAmount - (int) round($totalFixed * $fixedScale)); // ── 计算大奖(一/二/三等)可用奖池 ── $prize1Ratio = (int) ($config['prize_1st_ratio'] ?? 60); $prize2Ratio = (int) ($config['prize_2nd_ratio'] ?? 20); $prize3Ratio = (int) ($config['prize_3rd_ratio'] ?? 10); $carryRatio = (int) ($config['carry_ratio'] ?? 10); $pool1 = (int) round($poolAfterFixed * $prize1Ratio / 100); $pool2 = (int) round($poolAfterFixed * $prize2Ratio / 100); $pool3 = (int) round($poolAfterFixed * $prize3Ratio / 100); // ── 逐票结算 ── $totalPayout = 0; $winner1Names = []; $winner2Names = []; $winner3Names = []; $prize4Count = 0; $prize5Count = 0; // 先统计各奖级人数(用于均分) $count1 = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 1)->count(); $count2 = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 2)->count(); $count3 = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 3)->count(); $payout1Each = $count1 > 0 ? (int) floor($pool1 / $count1) : 0; $payout2Each = $count2 > 0 ? (int) floor($pool2 / $count2) : 0; $payout3Each = $count3 > 0 ? (int) floor($pool3 / $count3) : 0; DB::transaction(function () use ( $tickets, $issue, $prize4Fixed, $prize5Fixed, $fixedScale, $payout1Each, $payout2Each, $payout3Each, &$totalPayout, &$winner1Names, &$winner2Names, &$winner3Names, &$prize4Count, &$prize5Count ) { foreach ($tickets as $ticket) { $level = $issue->calcPrizeLevel($ticket->red1, $ticket->red2, $ticket->red3, $ticket->blue); $payout = 0; switch ($level) { case 1: $payout = $payout1Each; $winner1Names[] = $ticket->user->username.'+'.\number_format($payout); break; case 2: $payout = $payout2Each; $winner2Names[] = $ticket->user->username.'+'.\number_format($payout); break; case 3: $payout = $payout3Each; $winner3Names[] = $ticket->user->username.'+'.\number_format($payout); break; case 4: $payout = (int) round($prize4Fixed * $fixedScale); $prize4Count++; break; case 5: $payout = (int) round($prize5Fixed * $fixedScale); $prize5Count++; break; } $ticket->update(['prize_level' => $level, 'payout' => $payout]); if ($payout > 0) { $totalPayout += $payout; $this->currency->change( user: $ticket->user, currency: 'gold', amount: $payout, source: CurrencySource::LOTTERY_WIN, remark: "双色球 #{$issue->issue_no} {$issue::prizeLevelLabel($level)}中奖,得 {$payout} 金币", ); } } }); // ── 计算滚存金额 ── $usedPool = $totalPayout; $carryAmount = max(0, $poolAfterFixed - $usedPool + (int) round($poolAfterFixed * $carryRatio / 100)); // 无人中一/二/三等时该部分奖池全部滚存 $actualCarry = $poolAfterFixed - min($usedPool, $poolAfterFixed); // ── 更新期次记录 ── $noWinnerStreak = $count1 === 0 ? $issue->no_winner_streak + 1 : 0; $issue->update([ 'status' => 'settled', 'payout_amount' => $totalPayout, 'no_winner_streak' => $noWinnerStreak, ]); // ── 记录奖池派奖流水 ── LotteryPoolLog::create([ 'issue_id' => $issue->id, 'change_amount' => -$totalPayout, 'reason' => 'payout', 'pool_after' => max(0, $poolAfterFixed - $totalPayout), 'remark' => "开奖派奖,共 {$totalPayout} 金币", 'created_at' => now(), ]); // ── 开启下一期(携带滚存)── $this->openNextIssue($issue, $actualCarry, $noWinnerStreak); // ── 公屏广播 ── $this->broadcastResult($issue, $count1, $count2, $count3, $prize4Count, $prize5Count, $winner1Names); } /** * 开启新一期,携带上期滚存金额。 * * @param LotteryIssue $prevIssue 上一期 * @param int $carryAmount 滚存金额 * @param int $noWinnerStreak 连续无一等奖期数 */ private function openNextIssue(LotteryIssue $prevIssue, int $carryAmount, int $noWinnerStreak): void { $config = GameConfig::forGame('lottery')?->params ?? []; $drawHour = (int) ($config['draw_hour'] ?? 20); $drawMinute = (int) ($config['draw_minute'] ?? 0); $stopMinutes = (int) ($config['stop_sell_minutes'] ?? 2); // 下一期开奖时间(明天同一时间) $drawAt = now()->addDay()->setTime($drawHour, $drawMinute, 0); $closeAt = $drawAt->copy()->subMinutes($stopMinutes); // 判断是否超级期 $superThreshold = (int) ($config['super_issue_threshold'] ?? 3); $isSuper = $noWinnerStreak >= $superThreshold; $injectAmount = 0; if ($isSuper) { $injectAmount = (int) ($config['super_issue_inject'] ?? 20000); } $newIssue = LotteryIssue::create([ 'issue_no' => LotteryIssue::nextIssueNo(), 'status' => 'open', 'pool_amount' => $carryAmount + $injectAmount, 'carry_amount' => $carryAmount, 'is_super_issue' => $isSuper, 'no_winner_streak' => $noWinnerStreak, 'sell_closes_at' => $closeAt, 'draw_at' => $drawAt, ]); // 记录滚存流水 if ($carryAmount > 0) { LotteryPoolLog::create([ 'issue_id' => $newIssue->id, 'change_amount' => $carryAmount, 'reason' => 'carry_over', 'pool_after' => $newIssue->pool_amount, 'remark' => "从第 #{$prevIssue->issue_no} 期滚存", 'created_at' => now(), ]); } // 记录系统注入流水 if ($injectAmount > 0) { LotteryPoolLog::create([ 'issue_id' => $newIssue->id, 'change_amount' => $injectAmount, 'reason' => 'system_inject', 'pool_after' => $newIssue->pool_amount, 'remark' => "超级期系统注入(连续 {$noWinnerStreak} 期无一等奖)", 'created_at' => now(), ]); } // 超级期全服预热广播 if ($isSuper) { $this->broadcastSuperIssue($newIssue); } } /** * 向公屏广播开奖结果。 * * @param array $winner1Names 一等奖得主列表 */ private function broadcastResult( LotteryIssue $issue, int $count1, int $count2, int $count3, int $prize4Count, int $prize5Count, array $winner1Names ): void { $drawNums = sprintf( '🔴%02d 🔴%02d 🔴%02d + 🎲%d', $issue->red1, $issue->red2, $issue->red3, $issue->blue ); // 一等奖 if ($count1 > 0) { $w1Str = implode('、', array_slice($winner1Names, 0, 5)); $line1 = "🏆 一等奖:{$w1Str} 💰"; } else { $line1 = '无一等奖!奖池滚存 → 下期累计 💰 '.number_format($issue->pool_amount).' 金币'; } $details = []; if ($count2 > 0) { $details[] = "🥇 二等奖:{$count2}人"; } if ($count3 > 0) { $details[] = "🥈 三等奖:{$count3}人"; } if ($prize4Count > 0) { $details[] = "🥉 四等奖:{$prize4Count}人"; } if ($prize5Count > 0) { $details[] = "🎫 五等奖:{$prize5Count}人"; } $detailStr = $details ? ' '.implode(' | ', $details) : ''; $content = "🎟️ 【双色球 第{$issue->issue_no}期 开奖】{$drawNums} {$line1}{$detailStr}"; $this->pushSystemMessage($content); } /** * 超级期预热广播。 */ 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')} 开奖,赶紧购票!"; $this->pushSystemMessage($content); } /** * 向公屏发送系统消息。 */ private function pushSystemMessage(string $content): void { $msg = [ 'id' => $this->chatState->nextMessageId(1), 'room_id' => 1, 'from_user' => '系统传音', 'to_user' => '大家', 'content' => $content, 'is_secret' => false, 'font_color' => '#dc2626', 'action' => '大声宣告', 'sent_at' => now()->toDateTimeString(), ]; $this->chatState->pushMessage(1, $msg); broadcast(new MessageSent(1, $msg)); \App\Jobs\SaveMessageJob::dispatch($msg); } }