339 lines
12 KiB
PHP
339 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Ticket;
|
||
|
||
use App\Models\OddsItem;
|
||
use App\Lottery\ErrorCode;
|
||
use App\Models\PlayConfigItem;
|
||
use Illuminate\Support\Collection;
|
||
use App\Exceptions\TicketOperationException;
|
||
|
||
final class PlayRuleEngine
|
||
{
|
||
public function __construct(
|
||
private readonly NumberNormalizer $normalizer,
|
||
) {}
|
||
|
||
/**
|
||
* @param array<string, mixed> $line
|
||
* @param Collection<int, OddsItem> $oddsItems
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function evaluateLine(array $line, PlayConfigItem $playConfig, Collection $oddsItems): array
|
||
{
|
||
$playCode = (string) $line['play_code'];
|
||
$dimension = $line['dimension'] ?? null;
|
||
$digitSlot = $line['digit_slot'] ?? null;
|
||
$amount = (int) $line['amount'];
|
||
$number = $this->normalizer->normalize($playCode, (string) $line['number'], is_string($dimension) ? $dimension : null);
|
||
|
||
if ($amount < (int) $playConfig->min_bet_amount || $amount > (int) $playConfig->max_bet_amount) {
|
||
throw new TicketOperationException('bet_amount_out_of_range', ErrorCode::WalletAmountExceedsLimit->value);
|
||
}
|
||
|
||
if (in_array($playCode, ['odd', 'even', 'digit_big', 'digit_small'], true) && ! is_string($dimension)) {
|
||
throw new TicketOperationException('dimension_required', ErrorCode::BetInvalidPlayInput->value);
|
||
}
|
||
|
||
if (in_array($playCode, ['digit_big', 'digit_small'], true) && ! is_int($digitSlot) && ! ctype_digit((string) $digitSlot)) {
|
||
throw new TicketOperationException('digit_slot_required', ErrorCode::BetInvalidPlayInput->value);
|
||
}
|
||
|
||
$digitSlotInt = $digitSlot === null ? null : (int) $digitSlot;
|
||
$combos = $this->expandToCombinations($playCode, $number, is_string($dimension) ? $dimension : null, $digitSlotInt);
|
||
$combinationCount = count($combos);
|
||
if ($combinationCount < 1) {
|
||
throw new TicketOperationException('empty_combinations', ErrorCode::BetInvalidPlayInput->value);
|
||
}
|
||
|
||
$unitBetAmount = $this->resolveUnitBetAmount($playCode, $amount, $combinationCount);
|
||
$totalBetAmount = $this->resolveTotalBetAmount($playCode, $amount, $unitBetAmount, $combinationCount);
|
||
$dimensionInt = $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig);
|
||
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
|
||
$rebateRate = (float) $primaryOdds->rebate_rate;
|
||
// 佣金按维度(2D/3D/4D)配置:从 odds_items 中查找匹配维度的佣金率
|
||
$commissionRate = $this->pickCommissionByDimension($oddsItems, $dimensionInt);
|
||
$actualDeductAmount = max(0, (int) floor($totalBetAmount * (1 - $rebateRate)));
|
||
$maxOdds = $oddsItems->max(fn (OddsItem $row) => (int) $row->odds_value) ?? 0;
|
||
$estimatedPayoutPerCombo = (int) floor($unitBetAmount * ($maxOdds / 10000));
|
||
$estimatedMaxPayout = $estimatedPayoutPerCombo * $combinationCount;
|
||
|
||
return [
|
||
'original_number' => (string) $line['number'],
|
||
'normalized_number' => $number,
|
||
'play_code' => $playCode,
|
||
'dimension' => $this->toDimensionInt(is_string($dimension) ? $dimension : null, $playConfig),
|
||
'digit_slot' => $digitSlotInt,
|
||
'bet_mode' => $playConfig->bet_mode,
|
||
'unit_bet_amount' => $unitBetAmount,
|
||
'total_bet_amount' => $totalBetAmount,
|
||
'rebate_rate_snapshot' => number_format($rebateRate, 4, '.', ''),
|
||
'commission_rate_snapshot' => number_format($commissionRate, 4, '.', ''),
|
||
'actual_deduct_amount' => $actualDeductAmount,
|
||
'combination_count' => $combinationCount,
|
||
'estimated_max_payout' => $estimatedMaxPayout,
|
||
'odds_snapshot_json' => $oddsItems->map(fn (OddsItem $row) => [
|
||
'prize_scope' => $row->prize_scope,
|
||
'odds_value' => (int) $row->odds_value,
|
||
'rebate_rate' => (string) $row->rebate_rate,
|
||
'commission_rate' => (string) $row->commission_rate,
|
||
])->values()->all(),
|
||
'rule_snapshot_json' => [
|
||
'play_code' => $playCode,
|
||
'dimension' => $dimension,
|
||
'digit_slot' => $digitSlotInt,
|
||
'combination_count' => $combinationCount,
|
||
'rounding_refund_amount' => $playCode === 'mbox'
|
||
? max(0, $amount - $totalBetAmount)
|
||
: 0,
|
||
],
|
||
'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array {
|
||
return [
|
||
'combination_no' => $index + 1,
|
||
'number_4d' => $combo,
|
||
'bet_amount' => $unitBetAmount,
|
||
'estimated_payout' => $estimatedPayoutPerCombo,
|
||
];
|
||
})->all(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function expandToCombinations(string $playCode, string $number, ?string $dimension, ?int $digitSlot): array
|
||
{
|
||
return match ($playCode) {
|
||
'big', 'small', 'pos_4a', 'pos_4b', 'pos_4c', 'pos_4d', 'pos_4e', 'straight' => [$number],
|
||
'ibox', 'mbox', 'box' => $this->uniquePermutations($number),
|
||
'roll' => $this->expandRoll($number),
|
||
'pos_3a', 'pos_3b', 'pos_3c', 'pos_3abc' => $this->expandSuffix($number, 3),
|
||
'pos_2a', 'pos_2b', 'pos_2c', 'pos_2abc' => $this->expandSuffix($number, 2),
|
||
'head' => $this->expandHeadTail(true),
|
||
'tail' => $this->expandHeadTail(false),
|
||
'odd' => $this->expandOddEven($dimension, true),
|
||
'even' => $this->expandOddEven($dimension, false),
|
||
'digit_big' => $this->expandDigitSize($dimension, $digitSlot, true),
|
||
'digit_small' => $this->expandDigitSize($dimension, $digitSlot, false),
|
||
default => throw new TicketOperationException('unsupported_play_expand', ErrorCode::BetPlayUnsupported->value),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function uniquePermutations(string $digits): array
|
||
{
|
||
$results = [];
|
||
$this->permute(str_split($digits), 0, $results);
|
||
$values = array_values(array_unique($results));
|
||
sort($values);
|
||
|
||
return $values;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $chars
|
||
* @param array<int, string> $results
|
||
*/
|
||
private function permute(array $chars, int $index, array &$results): void
|
||
{
|
||
if ($index === count($chars) - 1) {
|
||
$results[] = implode('', $chars);
|
||
|
||
return;
|
||
}
|
||
|
||
$seen = [];
|
||
for ($i = $index; $i < count($chars); $i++) {
|
||
if (isset($seen[$chars[$i]])) {
|
||
continue;
|
||
}
|
||
$seen[$chars[$i]] = true;
|
||
[$chars[$index], $chars[$i]] = [$chars[$i], $chars[$index]];
|
||
$this->permute($chars, $index + 1, $results);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function expandRoll(string $pattern): array
|
||
{
|
||
$results = [''];
|
||
foreach (str_split($pattern) as $char) {
|
||
$next = [];
|
||
if ($char === 'R') {
|
||
foreach ($results as $prefix) {
|
||
for ($i = 0; $i <= 9; $i++) {
|
||
$next[] = $prefix.$i;
|
||
}
|
||
}
|
||
} else {
|
||
foreach ($results as $prefix) {
|
||
$next[] = $prefix.$char;
|
||
}
|
||
}
|
||
$results = $next;
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function expandSuffix(string $suffix, int $length): array
|
||
{
|
||
$results = [];
|
||
$prefixLength = 4 - $length;
|
||
$max = 10 ** $prefixLength;
|
||
for ($i = 0; $i < $max; $i++) {
|
||
$results[] = str_pad((string) $i, $prefixLength, '0', STR_PAD_LEFT).$suffix;
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function expandHeadTail(bool $isHead): array
|
||
{
|
||
$results = [];
|
||
$start = $isHead ? 5 : 0;
|
||
$end = $isHead ? 9 : 4;
|
||
for ($first = $start; $first <= $end; $first++) {
|
||
for ($rest = 0; $rest < 1000; $rest++) {
|
||
$results[] = $first.str_pad((string) $rest, 3, '0', STR_PAD_LEFT);
|
||
}
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function expandOddEven(?string $dimension, bool $odd): array
|
||
{
|
||
$digits = $odd ? ['1', '3', '5', '7', '9'] : ['0', '2', '4', '6', '8'];
|
||
$suffixLength = match ($dimension) {
|
||
'D2' => 2,
|
||
'D3' => 3,
|
||
default => 4,
|
||
};
|
||
$prefixLength = 4 - $suffixLength;
|
||
$prefixMax = 10 ** max($prefixLength, 0);
|
||
$middleLength = max($suffixLength - 1, 0);
|
||
$middleMax = 10 ** $middleLength;
|
||
$results = [];
|
||
|
||
for ($prefix = 0; $prefix < $prefixMax; $prefix++) {
|
||
for ($mid = 0; $mid < $middleMax; $mid++) {
|
||
foreach ($digits as $last) {
|
||
$results[] = str_pad((string) $prefix, $prefixLength, '0', STR_PAD_LEFT)
|
||
.str_pad((string) $mid, $middleLength, '0', STR_PAD_LEFT)
|
||
.$last;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function expandDigitSize(?string $dimension, ?int $digitSlot, bool $isBig): array
|
||
{
|
||
if ($digitSlot === null) {
|
||
throw new TicketOperationException('digit_slot_missing', ErrorCode::BetInvalidPlayInput->value);
|
||
}
|
||
|
||
$validSlots = match ($dimension) {
|
||
'D2' => [2, 3],
|
||
'D3' => [1, 2, 3],
|
||
default => [0, 1, 2, 3],
|
||
};
|
||
if (! in_array($digitSlot, $validSlots, true)) {
|
||
throw new TicketOperationException('digit_slot_invalid', ErrorCode::BetInvalidPlayInput->value);
|
||
}
|
||
|
||
$targetDigits = $isBig ? ['5', '6', '7', '8', '9'] : ['0', '1', '2', '3', '4'];
|
||
$results = [];
|
||
for ($i = 0; $i < 10000; $i++) {
|
||
$number = str_pad((string) $i, 4, '0', STR_PAD_LEFT);
|
||
if (in_array($number[$digitSlot], $targetDigits, true)) {
|
||
$results[] = $number;
|
||
}
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
private function resolveUnitBetAmount(string $playCode, int $amount, int $combinationCount): int
|
||
{
|
||
return match ($playCode) {
|
||
'mbox' => max(0, intdiv($amount, $combinationCount)),
|
||
default => $amount,
|
||
};
|
||
}
|
||
|
||
private function resolveTotalBetAmount(string $playCode, int $rawAmount, int $unitBetAmount, int $combinationCount): int
|
||
{
|
||
return match ($playCode) {
|
||
'ibox', 'roll' => $rawAmount * $combinationCount,
|
||
'mbox' => $unitBetAmount * $combinationCount,
|
||
default => $rawAmount,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, OddsItem> $oddsItems
|
||
*/
|
||
private function pickPrimaryOdds(Collection $oddsItems): OddsItem
|
||
{
|
||
foreach (['first', 'default', 'second', 'third', 'starter', 'consolation'] as $scope) {
|
||
$hit = $oddsItems->firstWhere('prize_scope', $scope);
|
||
if ($hit !== null) {
|
||
return $hit;
|
||
}
|
||
}
|
||
|
||
return $oddsItems->firstOrFail();
|
||
}
|
||
|
||
/**
|
||
* 按维度(2D/3D/4D)获取佣金率
|
||
*
|
||
* @param Collection<int, OddsItem> $oddsItems
|
||
*/
|
||
private function pickCommissionByDimension(Collection $oddsItems, ?int $dimension): float
|
||
{
|
||
if ($dimension === null) {
|
||
// 如果维度为空,返回第一个 odds item 的佣金率(向后兼容)
|
||
return (float) ($oddsItems->first()?->commission_rate ?? 0);
|
||
}
|
||
|
||
// 查找匹配维度的 odds item
|
||
$dimensionItem = $oddsItems->firstWhere('dimension', $dimension);
|
||
if ($dimensionItem !== null) {
|
||
return (float) $dimensionItem->commission_rate;
|
||
}
|
||
|
||
// 如果没有找到匹配维度的,返回第一个的佣金率(向后兼容)
|
||
return (float) ($oddsItems->first()?->commission_rate ?? 0);
|
||
}
|
||
|
||
private function toDimensionInt(?string $dimension, PlayConfigItem $playConfig): ?int
|
||
{
|
||
return match ($dimension) {
|
||
'D2' => 2,
|
||
'D3' => 3,
|
||
'D4' => 4,
|
||
default => $playConfig->dimension === null ? null : (int) $playConfig->dimension,
|
||
};
|
||
}
|
||
}
|