whereIn('status', [0, 1, 2, 3]) ->order('id', 'desc') ->find(); if (!$row) { return; } $recordId = (int) ($row['id'] ?? 0); if ($recordId <= 0) { return; } $periodStartAt = (int) ($row['period_start_at'] ?? 0); if ($periodStartAt <= 0) { return; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $timeoutAt = $periodStartAt + $periodSeconds + self::PAYOUT_GRACE_SECONDS + self::STARTUP_RECOVER_GRACE_SECONDS; if (time() <= $timeoutAt) { return; } $status = (int) ($row['status'] ?? 0); $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000); if (!$lock['acquired']) { return; } try { $fresh = Db::name('game_record')->where('id', $recordId)->find(); if (!$fresh) { return; } $freshStatus = (int) ($fresh['status'] ?? 0); if (!in_array($freshStatus, [0, 1, 2, 3], true)) { return; } $freshPeriodStartAt = (int) ($fresh['period_start_at'] ?? 0); if ($freshPeriodStartAt <= 0) { return; } $freshTimeoutAt = $freshPeriodStartAt + $periodSeconds + self::PAYOUT_GRACE_SECONDS + self::STARTUP_RECOVER_GRACE_SECONDS; if (time() <= $freshTimeoutAt) { return; } $now = time(); $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00']; Db::startTrans(); try { $refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now); $oldStatus = $freshStatus; $refundedUserCount = count($refund['user_ids']); $refundedOrderCount = (int) ($refund['order_count'] ?? 0); $refundedTotalAmount = is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00'; $reason = sprintf( 'system_recover:from=%d|users=%d|orders=%d|amount=%s', $oldStatus, $refundedUserCount, $refundedOrderCount, $refundedTotalAmount ); Db::name('game_record')->where('id', $recordId)->update([ 'status' => 5, 'void_reason' => $reason, 'pending_draw_number' => null, 'payout_until' => null, 'ai_locked_number' => null, 'update_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); Log::warning('game live startup recover failed', [ 'record_id' => $recordId, 'error' => $e->getMessage(), ]); return; } GameHotDataCoordinator::afterGameRecordCommitted($recordId); foreach ($refund['user_ids'] as $uid) { if ($uid > 0) { GameHotDataCoordinator::afterUserCommitted($uid); } } GameRecordService::bootstrapPeriodWhenRuntimeEnabled(); self::publishSnapshot(null); Log::info('game live startup recovered abnormal period', [ 'record_id' => $recordId, 'old_status' => $freshStatus, 'refunded_user_count' => count($refund['user_ids']), 'refunded_order_count' => (int) ($refund['order_count'] ?? 0), 'refunded_total_amount' => is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00', ]); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); } } public static function buildSnapshot(?int $recordId = null): array { $record = self::resolveRecord($recordId); if (!$record) { return self::emptySnapshotPayload(); } $rid = (int) $record['id']; self::ensureAiLocked($rid); $record = self::reloadRecord($rid); if (!$record) { return self::emptySnapshotPayload(); } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $pickMax = self::getPickMaxNumberCount(); $elapsed = max(0, time() - (int) $record['period_start_at']); $remaining = max(0, $periodSeconds - $elapsed); $betRemaining = max(0, $betSeconds - $elapsed); $status = (int) $record['status']; $payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0; $payoutRemaining = 0; if ($status === 3 && $payoutUntil > 0) { $payoutRemaining = max(0, $payoutUntil - time()); } $bets = Db::name('bet_order') ->where('period_id', $rid) ->order('id', 'desc') ->limit(200) ->select() ->toArray(); $candidates = []; $canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1); if ($canCalculate) { for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { $loss = self::estimateLossForNumber($bets, $n); $candidates[] = [ 'number' => $n, 'estimated_loss' => $loss, ]; } } $aiLocked = $record['ai_locked_number'] ?? null; $aiDisplay = null; if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) { $aiDisplay = (int) $aiLocked; } $pendingRaw = $record['pending_draw_number'] ?? null; $pendingDraw = null; if ($pendingRaw !== null && $pendingRaw !== '' && is_numeric((string) $pendingRaw)) { $pd = (int) $pendingRaw; if ($pd >= 1 && $pd <= self::DRAW_NUMBER_MAX) { $pendingDraw = $pd; } } $canScheduleDraw = ($status === 0 || $status === 1) && $elapsed >= $betSeconds && $elapsed < $periodSeconds; $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled(); $hasActiveRound = GameRecordService::hasActiveRecord(); /** 关服且已无进行中局:派彩结束后的「完整维护」态(仅此时展示维护中 UI) */ $maintenanceUi = !$runtimeEnabled && !$hasActiveRound; return [ 'record' => $record, 'bets' => array_map(static function (array $row): array { return [ 'id' => (int) $row['id'], 'user_id' => (int) $row['user_id'], 'period_no' => (string) $row['period_no'], 'pick_numbers' => $row['pick_numbers'], 'total_amount' => (string) $row['total_amount'], 'streak_at_bet' => (int) $row['streak_at_bet'], 'create_time' => (int) $row['create_time'], ]; }, $bets), 'candidate_numbers' => $candidates, 'ai_default_number' => $aiDisplay, 'calc_number' => $aiDisplay, 'pending_draw_number' => $pendingDraw, 'period_seconds' => $periodSeconds, 'bet_seconds' => $betSeconds, 'pick_max_number_count' => $pickMax, 'draw_number_max' => self::DRAW_NUMBER_MAX, 'remaining_seconds' => $remaining, 'bet_remaining_seconds' => $betRemaining, 'payout_remaining_seconds' => $payoutRemaining, 'is_payout_phase' => $status === 3, 'runtime_enabled' => $runtimeEnabled, 'maintenance_ui' => $maintenanceUi, /** 关闭游戏(维护)时仍允许完成当局、计算与预约开奖;仅阻止新用户下注与结束后自动开新期 */ 'can_calculate' => $canCalculate, 'can_draw' => $canScheduleDraw, 'can_schedule_draw' => $canScheduleDraw, 'server_time' => time(), ]; } /** * @return array */ private static function emptySnapshotPayload(): array { $runtimeEnabled = GameRecordService::isLiveRuntimeEnabled(); $hasActiveRound = GameRecordService::hasActiveRecord(); $maintenanceUi = !$runtimeEnabled && !$hasActiveRound; return [ 'record' => null, 'bets' => [], 'candidate_numbers' => [], 'ai_default_number' => null, 'calc_number' => null, 'pending_draw_number' => null, 'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30), 'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20), 'pick_max_number_count' => self::getPickMaxNumberCount(), 'draw_number_max' => self::DRAW_NUMBER_MAX, 'remaining_seconds' => 0, 'bet_remaining_seconds' => 0, 'payout_remaining_seconds' => 0, 'is_payout_phase' => false, 'runtime_enabled' => $runtimeEnabled, 'maintenance_ui' => $maintenanceUi, 'can_calculate' => false, 'can_draw' => false, 'can_schedule_draw' => false, 'server_time' => time(), ]; } public static function calculateResult(?int $recordId, ?int $manualNumber = null): array { $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => __('Current game status does not allow calculation')]; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => __('Betting period has not ended; calculation is not available yet')]; } self::ensureAiLocked((int) $record['id']); $record = self::reloadRecord((int) $record['id']); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } $pickMax = self::getPickMaxNumberCount(); if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX)) { return ['ok' => false, 'msg' => __('Manual draw number is out of the allowed range')]; } $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); $candidates = []; $bestNumber = null; $bestLoss = null; $bestNumbers = []; for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { $loss = self::estimateLossForNumber($bets, $n); $candidates[] = ['number' => $n, 'estimated_loss' => $loss]; if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 2) < 0) { $bestLoss = $loss; $bestNumbers = [$n]; continue; } if (bccomp((string) $loss, (string) $bestLoss, 2) === 0) { $bestNumbers[] = $n; } } $bestNumber = self::pickRandomNumber($bestNumbers); $aiLocked = $record['ai_locked_number'] ?? null; $aiDisplay = null; if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) { $aiDisplay = (int) $aiLocked; } $finalNumber = $manualNumber ?? $bestNumber; $finalLoss = '0.00'; if ($finalNumber !== null) { $finalLoss = self::estimateLossForNumber($bets, $finalNumber); } return [ 'ok' => true, 'msg' => __('Calculation completed'), 'record' => $record, 'period_seconds' => $periodSeconds, 'bet_seconds' => $betSeconds, 'pick_max_number_count' => $pickMax, 'draw_number_max' => self::DRAW_NUMBER_MAX, 'candidate_numbers' => $candidates, 'ai_default_number' => $aiDisplay, 'final_number' => $finalNumber, 'final_estimated_loss' => $finalLoss, ]; } /** * 管理员预约本期开奖号码(倒计时结束后由 tick 自动开奖,不立即开奖)。 */ public static function scheduleDraw(?int $recordId, int $manualNumber): array { if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) { return ['ok' => false, 'msg' => __('Draw number is out of the allowed range')]; } $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => __('Current game status does not allow scheduling the draw')]; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => __('Betting has not ended; cannot schedule the draw')]; } if ($elapsed >= $periodSeconds) { return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')]; } $rid = (int) $record['id']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 1200); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } try { self::ensureAiLocked($rid); Db::name('game_record')->where('id', $rid)->update([ 'pending_draw_number' => $manualNumber, 'update_time' => time(), ]); GameHotDataCoordinator::afterGameRecordCommitted($rid); self::publishSnapshot(null); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } return [ 'ok' => true, 'msg' => __('Draw number scheduled; it will be used when the countdown ends'), ]; } /** * 倒计时结束自动开奖(AI 或预约号码);派彩宽限期后由 finalizePayoutGrace 结单并开下一期。 */ public static function drawResult(?int $recordId, ?int $manualNumber = null): array { $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => __('Current game status does not allow drawing')]; } $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return ['ok' => false, 'msg' => __('Betting period has not ended; drawing is not available yet')]; } if ($elapsed < $periodSeconds) { return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')]; } $rid = (int) $record['id']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } try { self::ensureAiLocked($rid); $record = self::reloadRecord($rid); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } $useManual = $manualNumber; if ($useManual === null) { $p = $record['pending_draw_number'] ?? null; if ($p !== null && $p !== '' && is_numeric((string) $p)) { $pn = (int) $p; if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) { $useManual = $pn; } } } $finalNumber = null; $drawMode = 0; if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) { $finalNumber = $useManual; $drawMode = 1; } else { $al = $record['ai_locked_number'] ?? null; if ($al !== null && $al !== '' && is_numeric((string) $al)) { $finalNumber = (int) $al; } } if ($finalNumber === null || $finalNumber < 1) { $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); $finalNumber = self::computeBestNumberFromBets($bets) ?? 1; $drawMode = 0; } $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); $finalLoss = self::estimateLossForNumber($bets, $finalNumber); $now = time(); $payoutUntil = $now + self::PAYOUT_GRACE_SECONDS; $settleOut = ['jackpot_hits' => []]; Db::startTrans(); try { Db::name('game_record')->where('id', (int) $record['id'])->update([ 'status' => 3, 'result_number' => $finalNumber, 'draw_mode' => $drawMode, 'pending_draw_number' => null, 'payout_until' => $payoutUntil, 'update_time' => $now, ]); $settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber); Db::commit(); } catch (Throwable $e) { Db::rollback(); return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; } GameHotDataCoordinator::afterGameRecordCommitted($rid); try { GameRecordStatService::refreshForRecordId($rid); } catch (Throwable) { } self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now); self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil); $jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : []; GameWebSocketEventBus::publish('admin.live.opened', [ 'period_id' => $rid, 'period_no' => (string) $record['period_no'], 'result_number' => $finalNumber, 'payout_until' => $payoutUntil, 'jackpot_hits' => $jackpotHits, 'server_time' => $now, ]); if ($jackpotHits !== []) { GameWebSocketEventBus::publish('jackpot.hit', [ 'period_id' => $rid, 'period_no' => (string) $record['period_no'], 'result_number' => $finalNumber, 'hits' => $jackpotHits, 'server_time' => $now, ]); } self::publishSnapshot(null); return [ 'ok' => true, 'msg' => __('Draw completed; paying out'), 'result_number' => $finalNumber, 'estimated_loss' => $finalLoss, 'payout_until' => $payoutUntil, ]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } } /** * 派彩宽限期结束:将本期置为已结束并创建下一期。 */ public static function finalizePayoutGrace(): void { $row = Db::name('game_record') ->where('status', 3) ->where('payout_until', '>', 0) ->where('payout_until', '<=', time()) ->order('id', 'desc') ->find(); if (!$row) { return; } $id = (int) $row['id']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, 2000); if (!$lock['acquired']) { return; } try { Db::startTrans(); try { Db::name('game_record')->where('id', $id)->update([ 'status' => 4, 'payout_until' => null, 'update_time' => time(), ]); GameRecordService::createNextRecordAfterDraw(); Db::commit(); } catch (Throwable) { Db::rollback(); return; } GameHotDataCoordinator::afterGameRecordCommitted($id); GameRecordStatService::refreshForRecordId($id); self::publishSnapshot(null); } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']); } } public static function tickAutoDraw(): void { $record = self::resolveRecord(null); if (!$record || !in_array((int) $record['status'], [0, 1], true)) { return; } $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $elapsed = max(0, time() - (int) $record['period_start_at']); self::ensureAiLocked((int) $record['id']); $record = self::reloadRecord((int) $record['id']); if (!$record) { return; } $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $periodSeconds) { return; } self::drawResult((int) $record['id'], null); } /** * 作废当前期(仅 status 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。 * * @return array{ok: bool, msg?: string, record?: array|null} */ public static function voidCurrentPeriod(?int $recordId, string $voidReason): array { $reason = trim($voidReason); if ($reason === '') { return ['ok' => false, 'msg' => __('Void reason is required')]; } if (function_exists('mb_strlen')) { if (mb_strlen($reason) > 255) { return ['ok' => false, 'msg' => __('Void reason is too long')]; } } elseif (strlen($reason) > 255) { return ['ok' => false, 'msg' => __('Void reason is too long')]; } if (strlen($reason) < 2) { return ['ok' => false, 'msg' => __('Void reason is too short')]; } $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } $st = (int) $record['status']; if (!in_array($st, [0, 1], true)) { return ['ok' => false, 'msg' => __('Current period cannot be voided')]; } $rid = (int) $record['id']; $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000); if (!$lock['acquired']) { return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; } $refundedUserIds = []; try { $now = time(); Db::startTrans(); try { $refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now); $refundedUserIds = $refund['user_ids']; Db::name('game_record')->where('id', $rid)->update([ 'status' => 5, 'void_reason' => $reason, 'pending_draw_number' => null, 'payout_until' => null, 'ai_locked_number' => null, 'update_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()]; } GameRecordService::setLiveRuntimeEnabled(false); GameHotDataCoordinator::afterGameRecordCommitted($rid); GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME); foreach ($refundedUserIds as $uid) { if ($uid > 0) { GameHotDataCoordinator::afterUserCommitted($uid); } } self::publishSnapshot(null); return [ 'ok' => true, 'msg' => __('Period voided'), 'record' => self::reloadRecord($rid), ]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); } } /** * @return list */ private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array { $summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now); return $summary['user_ids']; } /** * @return array{user_ids:list,order_count:int,total_amount:string} */ private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array { $userIdSet = []; $orderCount = 0; $totalAmount = '0.00'; $bets = Db::name('bet_order') ->where('period_id', $periodId) ->where('status', 1) ->order('id', 'asc') ->select() ->toArray(); foreach ($bets as $bet) { $betId = (int) ($bet['id'] ?? 0); $userId = (int) ($bet['user_id'] ?? 0); $totalRaw = $bet['total_amount'] ?? '0'; $total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw; if ($betId <= 0) { continue; } if ($userId <= 0 || bccomp($total, '0', 2) <= 0) { Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ 'status' => 3, 'update_time' => $now, ]); continue; } $before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); $after = bcadd($before, $total, 2); $u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([ 'coin' => $after, 'update_time' => $now, ]); if ($u !== 1) { throw new \RuntimeException((string) __('Concurrent balance update; please retry')); } $bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([ 'status' => 3, 'update_time' => $now, ]); if ($bo !== 1) { throw new \RuntimeException((string) __('Bet order state changed; please retry')); } $channelIdRaw = $bet['channel_id'] ?? null; $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT); if ($channelId === false) { $channelId = null; } UserWalletRecord::create([ 'user_id' => $userId, 'channel_id' => $channelId, 'biz_type' => 'bet_void', 'direction' => 1, 'amount' => $total, 'balance_before' => $before, 'balance_after' => $after, 'ref_type' => 'bet_order', 'remark' => (string) __('Period void refund'), 'create_time' => $now, ]); $userIdSet[$userId] = true; $orderCount++; $totalAmount = bcadd($totalAmount, $total, 2); } $out = []; foreach (array_keys($userIdSet) as $uid) { $out[] = (int) $uid; } return [ 'user_ids' => $out, 'order_count' => $orderCount, 'total_amount' => $totalAmount, ]; } public static function publishSnapshot(?int $recordId = null): void { $snapshot = self::buildSnapshot($recordId); self::publishPublicPeriodTick($snapshot); } /** * 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义) */ private static function publishPublicPeriodTick(array $snapshot): void { $record = $snapshot['record'] ?? null; $periodNo = ''; $periodId = 0; $status = 'finished'; $resultNumber = null; if (is_array($record)) { $periodNoRaw = $record['period_no'] ?? ''; if (is_string($periodNoRaw)) { $periodNo = $periodNoRaw; } $periodIdRaw = $record['id'] ?? 0; $periodIdParsed = filter_var($periodIdRaw, FILTER_VALIDATE_INT); if ($periodIdParsed !== false && $periodIdParsed > 0) { $periodId = $periodIdParsed; } $dbStatusRaw = $record['status'] ?? 4; $dbStatus = filter_var($dbStatusRaw, FILTER_VALIDATE_INT); if ($dbStatus === false) { $dbStatus = 4; } $betRemainingRaw = $snapshot['bet_remaining_seconds'] ?? 0; $betRemaining = filter_var($betRemainingRaw, FILTER_VALIDATE_INT); if ($betRemaining === false || $betRemaining < 0) { $betRemaining = 0; } $status = self::mapPublicPeriodStatus($dbStatus, $betRemaining); $resultRaw = $record['result_number'] ?? null; $resultParsed = filter_var($resultRaw, FILTER_VALIDATE_INT); if ($resultParsed !== false && $resultParsed > 0) { $resultNumber = $resultParsed; } } $payload = [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => $status, 'countdown' => max(0, self::safeInt($snapshot['remaining_seconds'] ?? 0)), 'bet_close_in' => max(0, self::safeInt($snapshot['bet_remaining_seconds'] ?? 0)), 'result_number' => $resultNumber, 'runtime_enabled' => !empty($snapshot['runtime_enabled']), 'server_time' => time(), ]; GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, $payload); } /** * @param array $record game_record 行 */ private static function publishPublicPeriodLocked(array $record): void { $periodNo = is_string($record['period_no'] ?? null) ? $record['period_no'] : ''; $periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT); if ($periodId === false) { $periodId = 0; } GameWebSocketEventBus::publish(self::EVT_PERIOD_LOCKED, [ 'period_id' => $periodId, 'period_no' => $periodNo, 'status' => 'locked', 'server_time' => time(), ]); } private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void { GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [ 'period_no' => $periodNo, 'result_number' => $resultNumber, 'open_time' => $openTime, ]); } /** * 派彩阶段开始(开奖后宽限期内推送) */ private static function publishPublicPeriodPayout(string $periodNo, int $resultNumber, int $payoutUntil): void { GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [ 'period_no' => $periodNo, 'result_number' => $resultNumber, 'payout_until' => $payoutUntil, 'server_time' => time(), ]); } /** * 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished */ private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string { if ($dbStatus === 5) { return 'void'; } if ($dbStatus === 0) { return $betCloseIn > 0 ? 'betting' : 'locked'; } if ($dbStatus === 1) { return 'locked'; } if ($dbStatus === 4) { return 'finished'; } if ($dbStatus === 3) { return 'payouting'; } if ($dbStatus === 2) { return 'settling'; } return 'finished'; } private static function resolveRecord(?int $recordId): ?array { if ($recordId !== null && $recordId > 0) { $row = GameHotDataRedis::gameRecordById($recordId); if ($row) { return $row; } } return GameHotDataRedis::gameRecordActive(); } private static function reloadRecord(int $id): ?array { $row = GameHotDataRedis::gameRecordById($id); return $row ?: null; } /** * 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。 */ private static function ensureAiLocked(int $recordId): void { $record = GameHotDataRedis::gameRecordById($recordId); if (!$record) { return; } $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); $elapsed = max(0, time() - (int) $record['period_start_at']); if ($elapsed < $betSeconds) { return; } $st = (int) $record['status']; if ($st !== 0 && $st !== 1) { return; } $existing = $record['ai_locked_number'] ?? null; if ($existing !== null && $existing !== '' && is_numeric((string) $existing) && (int) $existing > 0) { if ($st === 0) { Db::name('game_record')->where('id', $recordId)->update([ 'status' => 1, 'update_time' => time(), ]); GameHotDataCoordinator::afterGameRecordCommitted($recordId); $record['status'] = 1; self::publishPublicPeriodLocked($record); } return; } $bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray(); $best = self::computeBestNumberFromBets($bets); if ($best === null || $best < 1) { $best = 1; } $update = [ 'ai_locked_number' => $best, 'update_time' => time(), ]; if ($st === 0) { $update['status'] = 1; } Db::name('game_record')->where('id', $recordId)->update($update); GameHotDataCoordinator::afterGameRecordCommitted($recordId); $record = array_merge($record, $update); if ($st === 0) { self::publishPublicPeriodLocked($record); } } /** * @param array> $bets */ private static function computeBestNumberFromBets(array $bets): ?int { $bestLoss = null; $bestNumbers = []; for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) { $loss = self::estimateLossForNumber($bets, $n); if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 2) < 0) { $bestLoss = $loss; $bestNumbers = [$n]; continue; } if (bccomp((string) $loss, (string) $bestLoss, 2) === 0) { $bestNumbers[] = $n; } } return self::pickRandomNumber($bestNumbers); } private static function getConfigInt(string $key, int $default): int { $row = GameHotDataRedis::gameConfigRow($key); if (!$row) { return $default; } $v = $row['config_value'] ?? null; if ($v === null || $v === '') { return $default; } if (!is_numeric((string) $v)) { return $default; } return (int) $v; } private static function getPickMaxNumberCount(): int { $max = self::getConfigInt(self::KEY_PICK_MAX_NUMBER_COUNT, 36); if ($max < 1) { return 1; } if ($max > 36) { return 36; } return $max; } private static function estimateLossForNumber(array $bets, int $number): string { $payout = '0.00'; foreach ($bets as $bet) { $pickNumbers = $bet['pick_numbers']; if (is_string($pickNumbers)) { $decoded = json_decode($pickNumbers, true); $pickNumbers = is_array($decoded) ? $decoded : []; } if (!is_array($pickNumbers)) { $pickNumbers = []; } if (!in_array($number, array_map('intval', $pickNumbers), true)) { continue; } $total = (string) ($bet['total_amount'] ?? '0'); $streak = (int) ($bet['streak_at_bet'] ?? 0); $odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak); $orderPayout = bcmul($total, $odds, 2); $payout = bcadd($payout, $orderPayout, 2); } return $payout; } private static function pickRandomNumber(array $numbers): ?int { if ($numbers === []) { return null; } if (count($numbers) === 1) { return $numbers[0]; } $index = random_int(0, count($numbers) - 1); return $numbers[$index]; } private static function safeInt($value): int { $parsed = filter_var($value, FILTER_VALIDATE_INT); if ($parsed === false) { return 0; } return $parsed; } }