440 lines
16 KiB
PHP
440 lines
16 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use support\think\Db;
|
||
use Throwable;
|
||
use Webman\Push\Api;
|
||
|
||
final class GameLiveService
|
||
{
|
||
private const BASE_ODDS = 33;
|
||
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 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;
|
||
|
||
public static function buildSnapshot(?int $recordId = null): array
|
||
{
|
||
$record = self::resolveRecord($recordId);
|
||
if (!$record) {
|
||
return [
|
||
'record' => 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'],
|
||
'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::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
||
|
||
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;
|
||
self::publishPublicPeriodLocked($record);
|
||
}
|
||
if ($elapsed < $periodSeconds) {
|
||
return;
|
||
}
|
||
self::drawResult((int) $record['id'], null);
|
||
}
|
||
|
||
public static function publishSnapshot(?int $recordId = null): void
|
||
{
|
||
try {
|
||
$payload = self::buildSnapshot($recordId);
|
||
$api = self::createPushApi();
|
||
$api->trigger(self::CHANNEL, self::EVENT, $payload);
|
||
self::publishPublicPeriodTick($payload, $api);
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
|
||
private static function createPushApi(): Api
|
||
{
|
||
return 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')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义)
|
||
*/
|
||
private static function publishPublicPeriodTick(array $snapshot, Api $api): void
|
||
{
|
||
$record = $snapshot['record'] ?? null;
|
||
$serverTime = (int) ($snapshot['server_time'] ?? time());
|
||
$remaining = (int) ($snapshot['remaining_seconds'] ?? 0);
|
||
$betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0);
|
||
$periodNo = '';
|
||
$dbStatus = 0;
|
||
$resultNumber = null;
|
||
if (is_array($record)) {
|
||
$periodNo = (string) ($record['period_no'] ?? '');
|
||
$dbStatus = (int) ($record['status'] ?? 0);
|
||
$rn = $record['result_number'] ?? null;
|
||
$resultNumber = is_numeric((string) $rn) ? (int) $rn : null;
|
||
}
|
||
if ($record === null || $periodNo === '') {
|
||
$status = 'idle';
|
||
} else {
|
||
$status = self::mapPublicPeriodStatus($dbStatus, $betCloseIn);
|
||
}
|
||
$payload = [
|
||
'server_time' => $serverTime,
|
||
'period_no' => $periodNo,
|
||
'status' => $status,
|
||
'countdown' => $remaining,
|
||
'bet_close_in'=> $betCloseIn,
|
||
];
|
||
if ($periodNo !== '' && $record !== null) {
|
||
$start = (int) ($record['period_start_at'] ?? 0);
|
||
$betSeconds = (int) ($snapshot['bet_seconds'] ?? 20);
|
||
$periodSeconds = (int) ($snapshot['period_seconds'] ?? 30);
|
||
if ($start > 0) {
|
||
$payload['lock_at'] = $start + $betSeconds;
|
||
$payload['open_at'] = $start + $periodSeconds;
|
||
}
|
||
}
|
||
if ($resultNumber !== null) {
|
||
$payload['result_number'] = $resultNumber;
|
||
}
|
||
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_TICK, $payload);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $record game_record 行
|
||
*/
|
||
private static function publishPublicPeriodLocked(array $record): void
|
||
{
|
||
try {
|
||
$start = (int) ($record['period_start_at'] ?? 0);
|
||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||
$periodNo = (string) ($record['period_no'] ?? '');
|
||
$payload = [
|
||
'period_no' => $periodNo,
|
||
'lock_at' => $start > 0 ? $start + $betSeconds : time(),
|
||
];
|
||
$api = self::createPushApi();
|
||
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_LOCKED, $payload);
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
|
||
private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void
|
||
{
|
||
try {
|
||
$payload = [
|
||
'period_no' => $periodNo,
|
||
'result_number' => $resultNumber,
|
||
'open_time' => $openTime,
|
||
];
|
||
$api = self::createPushApi();
|
||
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_OPENED, $payload);
|
||
} catch (Throwable) {
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished
|
||
*/
|
||
private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
|
||
{
|
||
if ($dbStatus === 0) {
|
||
return $betCloseIn > 0 ? 'betting' : 'locked';
|
||
}
|
||
if ($dbStatus === 1) {
|
||
return 'locked';
|
||
}
|
||
if ($dbStatus === 4) {
|
||
return 'finished';
|
||
}
|
||
if ($dbStatus === 2 || $dbStatus === 3) {
|
||
return 'settling';
|
||
}
|
||
|
||
return 'finished';
|
||
}
|
||
|
||
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;
|
||
}
|
||
$total = (string) ($bet['total_amount'] ?? '0');
|
||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
||
$orderPayout = bcmul($total, $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];
|
||
}
|
||
}
|