306 lines
14 KiB
PHP
306 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
function game_special_definitions(array $settings): array
|
|
{
|
|
$definitions = [
|
|
['key'=>'xsy','name'=>'小三元','type'=>1,'positions'=>[2,14,24]],
|
|
['key'=>'dsy','name'=>'大三元','type'=>2,'positions'=>[5,6,11,17,23]],
|
|
['key'=>'dsx','name'=>'大四喜','type'=>3,'positions'=>[10,23,11,5,17]],
|
|
['key'=>'tnsh','name'=>'天女散花','type'=>4,'positions'=>[1,7,13,19]],
|
|
['key'=>'tlbb','name'=>'天龙八部','type'=>5,'positions'=>[12,16,20,24]],
|
|
['key'=>'shuaidao','name'=>'甩刀','type'=>6,'positions'=>[8,9,20,21]],
|
|
['key'=>'huoche','name'=>'开火车','type'=>7,'positions'=>[3,4,15,16]],
|
|
['key'=>'dmg','name'=>'大满贯','type'=>8,'positions'=>range(1,24)],
|
|
];
|
|
foreach ($definitions as &$definition) {
|
|
$key = $definition['key'];
|
|
$definition['rate'] = max(0.0, (float)($settings['special_'.$key.'_rate'] ?? 0));
|
|
$definition['odds'] = max(0.0, (float)($settings['special_'.$key.'_odds'] ?? 0));
|
|
}
|
|
unset($definition);
|
|
return $definitions;
|
|
}
|
|
|
|
function game_board(array $settings): array
|
|
{
|
|
$odds = static fn(string $key, string $level, float $fallback): float => max(0.0, (float)($settings['odds_'.$key.'_'.$level] ?? $fallback));
|
|
return [
|
|
1=>['fruit'=>13,'name'=>'橘子','multiplier'=>$odds('jz','high',10)],
|
|
2=>['fruit'=>2,'name'=>'铃铛','multiplier'=>$odds('ld','high',10)],
|
|
3=>['fruit'=>4,'name'=>'BAR','multiplier'=>$odds('bar','low',50)],
|
|
4=>['fruit'=>4,'name'=>'BAR','multiplier'=>$odds('bar','high',100)],
|
|
5=>['fruit'=>5,'name'=>'苹果','multiplier'=>$odds('pg','high',5)],
|
|
6=>['fruit'=>5,'name'=>'苹果','multiplier'=>$odds('pg','low',2)],
|
|
7=>['fruit'=>19,'name'=>'柠檬','multiplier'=>$odds('nm','high',10)],
|
|
8=>['fruit'=>8,'name'=>'西瓜','multiplier'=>$odds('xg','high',20)],
|
|
9=>['fruit'=>8,'name'=>'西瓜','multiplier'=>$odds('xg','low',2)],
|
|
10=>['fruit'=>0,'name'=>'LUCKY','multiplier'=>0],
|
|
11=>['fruit'=>5,'name'=>'苹果','multiplier'=>$odds('pg','high',5)],
|
|
12=>['fruit'=>13,'name'=>'橘子','multiplier'=>$odds('jz','low',2)],
|
|
13=>['fruit'=>13,'name'=>'橘子','multiplier'=>$odds('jz','high',10)],
|
|
14=>['fruit'=>2,'name'=>'铃铛','multiplier'=>$odds('ld','high',10)],
|
|
15=>['fruit'=>16,'name'=>'77','multiplier'=>$odds('77','low',2)],
|
|
16=>['fruit'=>16,'name'=>'77','multiplier'=>$odds('77','high',20)],
|
|
17=>['fruit'=>5,'name'=>'苹果','multiplier'=>$odds('pg','high',5)],
|
|
18=>['fruit'=>19,'name'=>'柠檬','multiplier'=>$odds('nm','low',2)],
|
|
19=>['fruit'=>19,'name'=>'柠檬','multiplier'=>$odds('nm','high',10)],
|
|
20=>['fruit'=>20,'name'=>'双星','multiplier'=>$odds('xx','high',20)],
|
|
21=>['fruit'=>20,'name'=>'双星','multiplier'=>$odds('xx','low',2)],
|
|
22=>['fruit'=>0,'name'=>'LUCKY','multiplier'=>0],
|
|
23=>['fruit'=>5,'name'=>'苹果','multiplier'=>$odds('pg','high',5)],
|
|
24=>['fruit'=>2,'name'=>'铃铛','multiplier'=>$odds('ld','low',2)],
|
|
];
|
|
}
|
|
|
|
function game_window_stats(PDO $pdo, int $window): array
|
|
{
|
|
$window = min(100000, max(100, $window));
|
|
$sql = "SELECT COALESCE(SUM(bet_amount),0) AS bets, COALESCE(SUM(win_amount),0) AS wins, COUNT(*) AS rounds FROM (SELECT bet_amount, win_amount FROM game_logs WHERE game_type='spin' ORDER BY id DESC LIMIT ".$window.') recent';
|
|
$row = $pdo->query($sql)->fetch() ?: [];
|
|
$bets = (float)($row['bets'] ?? 0);
|
|
$wins = (float)($row['wins'] ?? 0);
|
|
return ['bets'=>$bets,'wins'=>$wins,'rounds'=>(int)($row['rounds'] ?? 0),'rtp'=>$bets > 0 ? $wins / $bets : null];
|
|
}
|
|
|
|
function game_weighted_pick(array $items, string $weightKey = 'weight'): array
|
|
{
|
|
$total = array_sum(array_map(static fn(array $item): float => max(0.0, (float)$item[$weightKey]), $items));
|
|
if ($total <= 0 || !$items) {
|
|
throw new RuntimeException('无可用的开奖结果。');
|
|
}
|
|
$roll = random_int(1, 1000000000) / 1000000000 * $total;
|
|
foreach ($items as $item) {
|
|
$roll -= max(0.0, (float)$item[$weightKey]);
|
|
if ($roll <= 0) return $item;
|
|
}
|
|
return $items[array_key_last($items)];
|
|
}
|
|
|
|
function game_position_weight(array $settings, int $position): float
|
|
{
|
|
return min(1000.0, max(0.01, (float)($settings['normal_weight_'.$position] ?? 1)));
|
|
}
|
|
|
|
function game_lucky_pick(array $settings): int
|
|
{
|
|
return (int)game_weighted_pick([
|
|
['position'=>10, 'weight'=>game_position_weight($settings, 10)],
|
|
['position'=>22, 'weight'=>game_position_weight($settings, 22)],
|
|
])['position'];
|
|
}
|
|
|
|
function game_calibrated_ordinary_positions(array $groups, float $normalReturn, float $ordinaryProbability): array
|
|
{
|
|
$modelAtTilt = static function (float $tilt) use ($groups, $normalReturn): array {
|
|
$positions = [];
|
|
$total = 0.0;
|
|
foreach ($groups as $group) {
|
|
$maxMultiplier = max(array_column($group['positions'], 'multiplier'));
|
|
$logs = [];
|
|
foreach ($group['positions'] as $position => $entry) {
|
|
$logs[$position] = log($entry['weight']) - $tilt * ($entry['multiplier'] / $maxMultiplier);
|
|
}
|
|
$maxLog = max($logs);
|
|
$denominator = 0.0;
|
|
$scores = [];
|
|
foreach ($group['positions'] as $position => $entry) {
|
|
$scores[$position] = exp($logs[$position] - $maxLog);
|
|
$denominator += $scores[$position] * $entry['multiplier'];
|
|
}
|
|
if ($denominator <= 0) {
|
|
throw new RuntimeException('每种普通图案至少需要一个大于 0 的赔率。');
|
|
}
|
|
foreach ($group['positions'] as $position => $entry) {
|
|
$probability = $normalReturn * $scores[$position] / $denominator;
|
|
$positions[$position] = ['probability'=>$probability, 'weight'=>$entry['weight']];
|
|
$total += $probability;
|
|
}
|
|
}
|
|
return ['positions'=>$positions, 'total'=>$total];
|
|
};
|
|
|
|
$low = -100.0;
|
|
$high = 100.0;
|
|
$lowModel = $modelAtTilt($low);
|
|
$highModel = $modelAtTilt($high);
|
|
if ($ordinaryProbability < $lowModel['total'] - 0.0000001 || $ordinaryProbability > $highModel['total'] + 0.0000001) {
|
|
throw new RuntimeException('当前赔率无法同时满足目标 RTP 和 LUCKY 通吃率。');
|
|
}
|
|
for ($iteration = 0; $iteration < 100; $iteration++) {
|
|
$mid = ($low + $high) / 2;
|
|
$model = $modelAtTilt($mid);
|
|
if ($model['total'] < $ordinaryProbability) $low = $mid;
|
|
else $high = $mid;
|
|
}
|
|
$tilt = ($low + $high) / 2;
|
|
$model = $modelAtTilt($tilt);
|
|
$model['tilt'] = $tilt;
|
|
return $model;
|
|
}
|
|
|
|
function game_probability_model(array $settings, ?float $targetRtpPercent = null): array
|
|
{
|
|
$specials = game_special_definitions($settings);
|
|
$specialRate = array_sum(array_column($specials, 'rate')) / 100;
|
|
$luckyProbability = min(1.0, max(0.0, (float)($settings['lucky_eat_rate'] ?? 2) / 100));
|
|
if ($specialRate + $luckyProbability >= 1) {
|
|
throw new RuntimeException('LUCKY 通吃率与特殊玩法总触发率之和必须小于 100%。');
|
|
}
|
|
|
|
$specialExpected = 0.0;
|
|
foreach ($specials as $special) {
|
|
$specialExpected += ($special['rate'] / 100) * $special['odds'];
|
|
}
|
|
|
|
$target = min(2.0, max(0.01, ($targetRtpPercent ?? (float)($settings['target_rtp'] ?? 92)) / 100));
|
|
if ($target <= $specialExpected + 0.0000001) {
|
|
throw new RuntimeException('目标 RTP 必须高于特殊玩法理论 RTP。');
|
|
}
|
|
|
|
$board = game_board($settings);
|
|
$groups = [];
|
|
$luckyPositions = [];
|
|
foreach ($board as $position => $cell) {
|
|
$weight = game_position_weight($settings, $position);
|
|
if ((int)$cell['fruit'] === 0) {
|
|
$luckyPositions[$position] = $weight;
|
|
continue;
|
|
}
|
|
$fruit = (int)$cell['fruit'];
|
|
$groups[$fruit]['positions'][$position] = ['weight'=>$weight, 'multiplier'=>(float)$cell['multiplier']];
|
|
}
|
|
|
|
if (!$luckyPositions) {
|
|
throw new RuntimeException('游戏盘面缺少未中奖格。');
|
|
}
|
|
|
|
$minCoefficient = 0.0;
|
|
$maxCoefficient = 0.0;
|
|
foreach ($groups as $group) {
|
|
$multipliers = array_column($group['positions'], 'multiplier');
|
|
$positiveMultipliers = array_values(array_filter($multipliers, static fn(float $value): bool => $value > 0));
|
|
if (!$positiveMultipliers) {
|
|
throw new RuntimeException('每种普通图案至少需要一个大于 0 的赔率。');
|
|
}
|
|
$minCoefficient += 1 / max($positiveMultipliers);
|
|
$minimum = min($multipliers);
|
|
$maxCoefficient = $minimum <= 0 ? INF : $maxCoefficient + 1 / $minimum;
|
|
}
|
|
|
|
$normalProbability = 1 - $specialRate;
|
|
$ordinaryProbability = $normalProbability - $luckyProbability;
|
|
$minTarget = $specialExpected + (is_infinite($maxCoefficient) ? 0 : $ordinaryProbability / $maxCoefficient);
|
|
$maxTarget = $specialExpected + $ordinaryProbability / $minCoefficient;
|
|
if ($target < $minTarget - 0.0000001 || $target > $maxTarget + 0.0000001) {
|
|
throw new RuntimeException('当前赔率和通吃率可支持的目标 RTP 范围为 '.number_format($minTarget * 100, 2).'% - '.number_format($maxTarget * 100, 2).'%。');
|
|
}
|
|
$normalReturn = $target - $specialExpected;
|
|
$calibrated = game_calibrated_ordinary_positions($groups, $normalReturn, $ordinaryProbability);
|
|
$positions = $calibrated['positions'];
|
|
$luckyWeightTotal = array_sum($luckyPositions);
|
|
foreach ($luckyPositions as $position => $weight) {
|
|
$positions[$position] = [
|
|
'probability'=>$luckyProbability * $weight / $luckyWeightTotal,
|
|
'weight'=>$weight,
|
|
];
|
|
}
|
|
ksort($positions);
|
|
|
|
return [
|
|
'positions'=>$positions,
|
|
'target_rtp'=>$target,
|
|
'min_rtp'=>$minTarget,
|
|
'max_rtp'=>$maxTarget,
|
|
'special_rate'=>$specialRate,
|
|
'special_expected'=>$specialExpected,
|
|
'normal_probability'=>$normalProbability,
|
|
'ordinary_probability'=>$ordinaryProbability,
|
|
'lucky_probability'=>$luckyProbability,
|
|
'calibration_tilt'=>$calibrated['tilt'],
|
|
];
|
|
}
|
|
|
|
function game_effective_probability_model(array $settings, array $windowStats): array
|
|
{
|
|
$configuredTarget = min(2.0, max(0.01, (float)($settings['target_rtp'] ?? 92) / 100));
|
|
$baseModel = game_probability_model($settings);
|
|
$effectiveTarget = $configuredTarget;
|
|
if (($windowStats['rtp'] ?? null) !== null && (int)($windowStats['rounds'] ?? 0) >= 20) {
|
|
$drift = max(-0.15, min(0.15, ($configuredTarget - (float)$windowStats['rtp']) * 0.5));
|
|
$effectiveTarget += $drift;
|
|
}
|
|
$effectiveTarget = max($baseModel['min_rtp'], min($baseModel['max_rtp'], $effectiveTarget));
|
|
$model = game_probability_model($settings, $effectiveTarget * 100);
|
|
$model['configured_target_rtp'] = $configuredTarget;
|
|
return $model;
|
|
}
|
|
|
|
function game_select_outcome(PDO $pdo, float $stake, array $bets, array $settings, ?array $windowStats = null): array
|
|
{
|
|
$specials = game_special_definitions($settings);
|
|
$stats = $windowStats ?? game_window_stats($pdo, (int)($settings['rtp_window'] ?? 1000));
|
|
$model = game_effective_probability_model($settings, $stats);
|
|
|
|
$specialRoll = random_int(1, 1000000) / 10000;
|
|
$cursor = 0.0;
|
|
foreach ($specials as $special) {
|
|
$cursor += $special['rate'];
|
|
if ($special['rate'] > 0 && $specialRoll <= $cursor) {
|
|
if ($special['key'] === 'dsx' && random_int(0,1) === 1) {
|
|
$special['positions'] = [22,11,23,17,5];
|
|
}
|
|
return [
|
|
'kind'=>'special','name'=>$special['name'],'type'=>$special['type'],
|
|
'position'=>game_lucky_pick($settings),'positions'=>$special['positions'],
|
|
'payout'=>round($stake * $special['odds'], 2),'multiplier'=>$special['odds'],
|
|
'special_rate'=>$special['rate'],'special_expected'=>$model['special_expected'],
|
|
'probability_model'=>'fixed_position_v2_lucky',
|
|
];
|
|
}
|
|
}
|
|
|
|
$board = game_board($settings);
|
|
$candidates = [];
|
|
foreach ($board as $position => $cell) {
|
|
$bet = (float)($bets[$cell['fruit']] ?? 0);
|
|
$payout = round($bet * (float)$cell['multiplier'], 2);
|
|
$candidates[] = [
|
|
'position'=>$position,'payout'=>$payout,'multiplier'=>(float)$cell['multiplier'],
|
|
'fruit'=>(int)$cell['fruit'],'name'=>$cell['name'],
|
|
'weight'=>$model['positions'][$position]['probability'],
|
|
];
|
|
}
|
|
$choice = game_weighted_pick($candidates);
|
|
|
|
if ($choice['fruit'] === 0) {
|
|
return [
|
|
'kind'=>'lucky_eat','name'=>'LUCKY通吃','type'=>9,
|
|
'position'=>$choice['position'],'positions'=>[],
|
|
'payout'=>0.0,'multiplier'=>0.0,
|
|
'special_expected'=>$model['special_expected'],
|
|
'position_probability'=>$model['positions'][$choice['position']]['probability'],
|
|
'effective_rtp'=>$model['target_rtp'],
|
|
'probability_model'=>'fixed_position_v2_lucky',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'kind'=>'normal','name'=>$choice['name'],'type'=>0,'position'=>$choice['position'],
|
|
'positions'=>[$choice['position']],'payout'=>$choice['payout'],
|
|
'multiplier'=>$choice['multiplier'],'special_expected'=>$model['special_expected'],
|
|
'position_probability'=>$model['positions'][$choice['position']]['probability'],
|
|
'effective_rtp'=>$model['target_rtp'],
|
|
'probability_model'=>'fixed_position_v2_lucky',
|
|
];
|
|
}
|
|
|
|
function game_theoretical_special_rtp(array $settings): float
|
|
{
|
|
$rtp = 0.0;
|
|
foreach (game_special_definitions($settings) as $special) {
|
|
$rtp += ($special['rate'] / 100) * $special['odds'] * 100;
|
|
}
|
|
return $rtp;
|
|
}
|