null, 'bets' => [], 'candidate_numbers' => [], 'ai_default_number' => null, 'calc_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, 'can_calculate' => false, 'can_draw' => false, 'server_time' => time(), ]; } $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); $bets = Db::name('bet_order') ->where('period_id', (int) $record['id']) ->order('id', 'desc') ->limit(200) ->select() ->toArray(); $candidates = []; $bestNumber = null; $bestLoss = null; $bestNumbers = []; $status = (int) $record['status']; $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, ]; if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) { $bestLoss = $loss; $bestNumbers = [$n]; continue; } if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) { $bestNumbers[] = $n; } } $bestNumber = self::pickRandomNumber($bestNumbers); } 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'], 'unit_amount' => (string) $row['unit_amount'], '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' => $bestNumber, 'calc_number' => $bestNumber, '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, 'can_calculate' => $canCalculate, 'can_draw' => $canCalculate, 'server_time' => time(), ]; } public static function calculateResult(?int $recordId, ?int $manualNumber = null): array { $record = self::resolveRecord($recordId); if (!$record) { return ['ok' => false, 'msg' => '未找到进行中的对局']; } if (!in_array((int) $record['status'], [0, 1], true)) { return ['ok' => false, 'msg' => '当前对局状态不可计算']; } $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' => '下注开放时长未结束,暂不可计算']; } if ((int) $record['status'] === 0) { Db::name('game_record')->where('id', (int) $record['id'])->update([ 'status' => 1, 'update_time' => time(), ]); $record['status'] = 1; } $pickMax = self::getPickMaxNumberCount(); if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX)) { return ['ok' => false, 'msg' => '手动开奖号码超出允许范围']; } $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, 4) < 0) { $bestLoss = $loss; $bestNumbers = [$n]; continue; } if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) { $bestNumbers[] = $n; } } $bestNumber = self::pickRandomNumber($bestNumbers); $finalNumber = $manualNumber ?? $bestNumber; $finalLoss = '0.0000'; if ($finalNumber !== null) { $finalLoss = self::estimateLossForNumber($bets, $finalNumber); } return [ 'ok' => true, 'msg' => '计算完成', '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' => $bestNumber, 'final_number' => $finalNumber, 'final_estimated_loss' => $finalLoss, ]; } public static function drawResult(?int $recordId, ?int $manualNumber = null): array { $calc = self::calculateResult($recordId, $manualNumber); if (!($calc['ok'] ?? false)) { return $calc; } $record = $calc['record']; $finalNumber = (int) $calc['final_number']; $now = time(); Db::startTrans(); try { Db::name('game_record')->where('id', (int) $record['id'])->update([ 'status' => 4, 'result_number' => $finalNumber, 'draw_mode' => $manualNumber === null ? 0 : 1, 'update_time' => $now, ]); GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber); GameRecordService::createNextRecordAfterDraw(); Db::commit(); GameRecordStatService::refreshForRecordId((int) $record['id']); } catch (Throwable $e) { Db::rollback(); return ['ok' => false, 'msg' => $e->getMessage()]; } self::publishSnapshot(null); return [ 'ok' => true, 'msg' => '开奖完成', 'result_number' => $finalNumber, 'estimated_loss' => $calc['final_estimated_loss'], ]; } 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']); if ($elapsed >= $betSeconds && (int) $record['status'] === 0) { Db::name('game_record')->where('id', (int) $record['id'])->update([ 'status' => 1, 'update_time' => time(), ]); $record['status'] = 1; } if ($elapsed < $periodSeconds) { return; } self::drawResult((int) $record['id'], null); } public static function publishSnapshot(?int $recordId = null): void { try { $payload = self::buildSnapshot($recordId); $api = new Api( str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')), (string) config('plugin.webman.push.app.app_key'), (string) config('plugin.webman.push.app.app_secret') ); $api->trigger(self::CHANNEL, self::EVENT, $payload); } catch (Throwable) { } } private static function resolveRecord(?int $recordId): ?array { if ($recordId !== null && $recordId > 0) { $row = Db::name('game_record')->where('id', $recordId)->find(); if ($row) { return $row; } } return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find(); } private static function getConfigInt(string $key, int $default): int { $row = Db::name('game_config')->where('config_key', $key)->find(); 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.0000'; 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; } $unit = (string) ($bet['unit_amount'] ?? '0'); $streak = (int) ($bet['streak_at_bet'] ?? 0); $odds = (string) (($streak + 1) * self::BASE_ODDS); $orderPayout = bcmul($unit, $odds, 4); $payout = bcadd($payout, $orderPayout, 4); } 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]; } }