313 lines
11 KiB
PHP
313 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ticket;
|
|
|
|
use App\Exceptions\TicketOperationException;
|
|
use App\Lottery\ErrorCode;
|
|
use App\Models\OddsItem;
|
|
use App\Models\PlayConfigItem;
|
|
use App\Models\PlayType;
|
|
use Illuminate\Support\Collection;
|
|
|
|
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, PlayType $playType, 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);
|
|
$primaryOdds = $this->pickPrimaryOdds($oddsItems);
|
|
$rebateRate = (float) $primaryOdds->rebate_rate;
|
|
$commissionRate = (float) $primaryOdds->commission_rate;
|
|
$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, $playType),
|
|
'digit_slot' => $digitSlotInt,
|
|
'bet_mode' => $playType->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,
|
|
],
|
|
'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();
|
|
}
|
|
|
|
private function toDimensionInt(?string $dimension, PlayType $playType): ?int
|
|
{
|
|
return match ($dimension) {
|
|
'D2' => 2,
|
|
'D3' => 3,
|
|
'D4' => 4,
|
|
default => $playType->dimension === null ? null : (int) $playType->dimension,
|
|
};
|
|
}
|
|
}
|