Files
shuiguoji/includes/game_engine.php
T
2026-07-23 08:58:13 +08:00

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;
}