898 lines
34 KiB
PHP
898 lines
34 KiB
PHP
<?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);
|
||
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;
|
||
}
|
||
}
|