Files
webman-buildadmin/app/common/service/GameLiveService.php
2026-04-24 15:25:15 +08:00

916 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\game\StreakWinReward;
use app\common\model\UserWalletRecord;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use support\think\Db;
use Throwable;
final class GameLiveService
{
private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated';
/** 与《36字花-移动端接口设计草案》7.1 对齐:公共对局频道 */
private const CHANNEL_PUBLIC_GAME_PERIOD = 'public-game-period';
private const EVT_PERIOD_TICK = 'period.tick';
private const EVT_PERIOD_LOCKED = 'period.locked';
private const EVT_PERIOD_OPENED = 'period.opened';
private const EVT_PERIOD_PAYOUT = 'period.payout';
private const KEY_PERIOD_SECONDS = 'period_seconds';
private const KEY_BET_SECONDS = 'bet_seconds';
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
/** 开奖结果号码池1 至此上限(与单注可选号码个数配置无关) */
private const DRAW_NUMBER_MAX = 36;
/** 开奖后派彩展示宽限期(秒),之后再创建下一期 */
private const PAYOUT_GRACE_SECONDS = 3;
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<string, mixed>
*/
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 {
$refundedUserIds = self::refundPendingBetsForPeriodLocked($rid, $now);
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<int>
*/
private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
{
$userIdSet = [];
$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;
}
$out = [];
foreach (array_keys($userIdSet) as $uid) {
$out[] = (int) $uid;
}
return $out;
}
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<string, mixed> $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<int, array<string, mixed>> $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;
}
}