Fix horse race seed pool payouts
This commit is contained in:
@@ -51,6 +51,7 @@ class HorseRaceController extends Controller
|
|||||||
// 计算各马匹当前注额
|
// 计算各马匹当前注额
|
||||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||||
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
||||||
|
$seedPool = (int) ($config['seed_pool'] ?? 0);
|
||||||
|
|
||||||
$horsePools = HorseBet::query()
|
$horsePools = HorseBet::query()
|
||||||
->where('race_id', $race->id)
|
->where('race_id', $race->id)
|
||||||
@@ -59,13 +60,13 @@ class HorseRaceController extends Controller
|
|||||||
->pluck('pool', 'horse_id')
|
->pluck('pool', 'horse_id')
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
|
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
|
||||||
|
|
||||||
// 计算实时赔率
|
// 计算实时赔率
|
||||||
$horses = $race->horses ?? [];
|
$horses = $race->horses ?? [];
|
||||||
$horsesWithBets = array_map(function ($horse) use ($horsePools, $houseTake) {
|
$horsesWithBets = array_map(function ($horse) use ($horsePools, $oddsMap) {
|
||||||
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
|
$horsePool = (int) ($horsePools[$horse['id']] ?? 0);
|
||||||
$totalPool = array_sum(array_values($horsePools));
|
$odds = $horsePool > 0 ? ($oddsMap[$horse['id']] ?? null) : null;
|
||||||
$netPool = $totalPool * (1 - $houseTake / 100);
|
|
||||||
$odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $horse['id'],
|
'id' => $horse['id'],
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
|
|
||||||
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
$config = GameConfig::forGame('horse_racing')?->params ?? [];
|
||||||
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
$houseTake = (int) ($config['house_take_percent'] ?? 5);
|
||||||
|
$seedPool = (int) ($config['seed_pool'] ?? 0);
|
||||||
$winnerId = (int) $race->winner_horse_id;
|
$winnerId = (int) $race->winner_horse_id;
|
||||||
|
|
||||||
// 按马匹统计各匹下注金额
|
// 按马匹统计各匹下注金额
|
||||||
@@ -82,7 +83,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
|
|
||||||
$totalPool = array_sum($horsePools);
|
$totalPool = array_sum($horsePools);
|
||||||
$winnerPool = (int) ($horsePools[$winnerId] ?? 0);
|
$winnerPool = (int) ($horsePools[$winnerId] ?? 0);
|
||||||
$netPool = (int) ($totalPool * (1 - $houseTake / 100));
|
$distributablePool = HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool);
|
||||||
|
|
||||||
// 结算:遍历所有下注记录
|
// 结算:遍历所有下注记录
|
||||||
$bets = HorseBet::query()
|
$bets = HorseBet::query()
|
||||||
@@ -93,7 +94,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
|
|
||||||
$totalPayout = 0;
|
$totalPayout = 0;
|
||||||
|
|
||||||
DB::transaction(function () use ($bets, $winnerId, $netPool, $winnerPool, $currency, &$totalPayout) {
|
DB::transaction(function () use ($bets, $winnerId, $distributablePool, $winnerPool, $currency, &$totalPayout) {
|
||||||
foreach ($bets as $bet) {
|
foreach ($bets as $bet) {
|
||||||
if ((int) $bet->horse_id !== $winnerId) {
|
if ((int) $bet->horse_id !== $winnerId) {
|
||||||
// 未中奖(本金已在下注时扣除)
|
// 未中奖(本金已在下注时扣除)
|
||||||
@@ -104,7 +105,7 @@ class CloseHorseRaceJob implements ShouldQueue
|
|||||||
|
|
||||||
// 中奖:按注额比例分配净注池
|
// 中奖:按注额比例分配净注池
|
||||||
if ($winnerPool > 0) {
|
if ($winnerPool > 0) {
|
||||||
$payout = (int) round($netPool * ($bet->amount / $winnerPool));
|
$payout = (int) round($distributablePool * ($bet->amount / $winnerPool));
|
||||||
} else {
|
} else {
|
||||||
$payout = 0;
|
$payout = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-12
@@ -114,33 +114,43 @@ class HorseRace extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据注池计算各马匹实时赔率(彩池制,扣除庄家抽水后按比例分配)。
|
* 计算本场可派奖总池。
|
||||||
*
|
*
|
||||||
* @param int $horseBetAmounts 各马匹的注额数组 [horse_id => amount]
|
* 可派奖池 = 系统初始化资金池 + 玩家总下注 - 抽水,
|
||||||
* @param int $housePercent 庄家抽水百分比
|
* 且至少不低于中奖马匹总下注,避免出现“押中反亏”的体验。
|
||||||
|
*
|
||||||
|
* @param array<int, int|float> $horseBetAmounts
|
||||||
|
*/
|
||||||
|
public static function calcDistributablePool(array $horseBetAmounts, int $housePercent, int $seedPool = 0, int $winnerPool = 0): float
|
||||||
|
{
|
||||||
|
$totalPool = array_sum($horseBetAmounts);
|
||||||
|
$netPlayerPool = $totalPool * (1 - $housePercent / 100);
|
||||||
|
|
||||||
|
return max($netPlayerPool + max(0, $seedPool), $winnerPool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据注池计算各马匹实时赔率(含系统初始化资金池)。
|
||||||
|
*
|
||||||
|
* @param array<int, int|float> $horseBetAmounts 各马匹的注额数组 [horse_id => amount]
|
||||||
* @return array<int, float> horse_id => 赔率(含本金)
|
* @return array<int, float> horse_id => 赔率(含本金)
|
||||||
*/
|
*/
|
||||||
public static function calcOdds(array $horseBetAmounts, int $housePercent): array
|
public static function calcOdds(array $horseBetAmounts, int $housePercent, int $seedPool = 0): array
|
||||||
{
|
{
|
||||||
$totalPool = array_sum($horseBetAmounts);
|
$totalPool = array_sum($horseBetAmounts);
|
||||||
|
|
||||||
if ($totalPool <= 0) {
|
if ($totalPool <= 0) {
|
||||||
// 尚无下注,返回等额赔率
|
|
||||||
$count = count($horseBetAmounts);
|
|
||||||
|
|
||||||
return array_map(fn () => 1.0, $horseBetAmounts);
|
return array_map(fn () => 1.0, $horseBetAmounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
$netPool = $totalPool * (1 - $housePercent / 100);
|
$distributablePool = static::calcDistributablePool($horseBetAmounts, $housePercent, $seedPool);
|
||||||
$odds = [];
|
$odds = [];
|
||||||
|
|
||||||
foreach ($horseBetAmounts as $horseId => $amount) {
|
foreach ($horseBetAmounts as $horseId => $amount) {
|
||||||
if ($amount <= 0) {
|
if ($amount <= 0) {
|
||||||
// 无人押注的马,赔率设为理论最大值
|
$odds[$horseId] = round($distributablePool, 2);
|
||||||
$odds[$horseId] = round($netPool, 2);
|
|
||||||
} else {
|
} else {
|
||||||
// 赔率 = 净注池 / 该马注额(含本金返还)
|
$odds[$horseId] = round($distributablePool / $amount, 2);
|
||||||
$odds[$horseId] = round($netPool / $amount, 2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class GameConfigSeeder extends Seeder
|
|||||||
'horse_count' => 4, // 参赛马匹数量
|
'horse_count' => 4, // 参赛马匹数量
|
||||||
'min_bet' => 100, // 最低押注
|
'min_bet' => 100, // 最低押注
|
||||||
'max_bet' => 100000, // 最高押注
|
'max_bet' => 100000, // 最高押注
|
||||||
|
'seed_pool' => 10000, // 系统初始化资金池
|
||||||
'house_take_percent' => 5, // 庄家抽水(%)
|
'house_take_percent' => 5, // 庄家抽水(%)
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -93,10 +93,12 @@
|
|||||||
@php
|
@php
|
||||||
$params = $game->params ?? [];
|
$params = $game->params ?? [];
|
||||||
$labels = gameParamLabels($game->game_key);
|
$labels = gameParamLabels($game->game_key);
|
||||||
|
$paramKeys = array_values(array_unique(array_merge(array_keys($labels), array_keys($params))));
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
@foreach ($params as $paramKey => $paramValue)
|
@foreach ($paramKeys as $paramKey)
|
||||||
|
@php $paramValue = $params[$paramKey] ?? ($labels[$paramKey]['default'] ?? '') @endphp
|
||||||
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
|
@php $meta = $labels[$paramKey] ?? ['label' => $paramKey, 'type' => 'number', 'unit' => ''] @endphp
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-bold text-gray-600 mb-1">
|
<label class="block text-xs font-bold text-gray-600 mb-1">
|
||||||
@@ -618,6 +620,7 @@
|
|||||||
'horse_count' => ['label' => '参赛马匹数', 'type' => 'number', 'unit' => '匹', 'min' => 2, 'max' => 8],
|
'horse_count' => ['label' => '参赛马匹数', 'type' => 'number', 'unit' => '匹', 'min' => 2, 'max' => 8],
|
||||||
'min_bet' => ['label' => '最低押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
'min_bet' => ['label' => '最低押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||||
'max_bet' => ['label' => '最高押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
'max_bet' => ['label' => '最高押注', 'type' => 'number', 'unit' => '金币', 'min' => 1],
|
||||||
|
'seed_pool' => ['label' => '系统初始化资金池', 'type' => 'number', 'unit' => '金币', 'min' => 0],
|
||||||
'house_take_percent' => [
|
'house_take_percent' => [
|
||||||
'label' => '庄家抽水',
|
'label' => '庄家抽水',
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
|
|||||||
@@ -136,7 +136,8 @@
|
|||||||
return [
|
return [
|
||||||
'投注范围 ' . number_format($p['min_bet'] ?? 100) . '~' . number_format($p['max_bet'] ?? 100000) . ' 金币',
|
'投注范围 ' . number_format($p['min_bet'] ?? 100) . '~' . number_format($p['max_bet'] ?? 100000) . ' 金币',
|
||||||
'每 ' . ($p['interval_minutes'] ?? 30) . ' 分钟一场,投注窗口 ' . ($p['bet_window_seconds'] ?? 90) . ' 秒',
|
'每 ' . ($p['interval_minutes'] ?? 30) . ' 分钟一场,投注窗口 ' . ($p['bet_window_seconds'] ?? 90) . ' 秒',
|
||||||
'平台抽成 ' . ($p['house_take_percent'] ?? 5) . '%,剩余按得票比例派奖',
|
'系统初始化资金池 ' . number_format($p['seed_pool'] ?? 0) . ' 金币,连同玩家注池一起派奖',
|
||||||
|
'平台抽成 ' . ($p['house_take_percent'] ?? 5) . '%,中奖时按占比瓜分派奖池',
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Jobs\CloseHorseRaceJob;
|
||||||
use App\Models\GameConfig;
|
use App\Models\GameConfig;
|
||||||
use App\Models\HorseBet;
|
use App\Models\HorseBet;
|
||||||
use App\Models\HorseRace;
|
use App\Models\HorseRace;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ChatStateService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class HorseRaceControllerTest extends TestCase
|
class HorseRaceControllerTest extends TestCase
|
||||||
@@ -182,4 +185,66 @@ class HorseRaceControllerTest extends TestCase
|
|||||||
$response->assertJsonStructure(['history']);
|
$response->assertJsonStructure(['history']);
|
||||||
$this->assertCount(1, $response->json('history'));
|
$this->assertCount(1, $response->json('history'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_single_winner_receives_seed_pool_and_does_not_lose_principal(): void
|
||||||
|
{
|
||||||
|
Event::fake();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
GameConfig::updateOrCreate(
|
||||||
|
['game_key' => 'horse_racing'],
|
||||||
|
[
|
||||||
|
'name' => 'Horse Racing',
|
||||||
|
'icon' => 'horse',
|
||||||
|
'description' => 'Horse Racing Game',
|
||||||
|
'enabled' => true,
|
||||||
|
'params' => [
|
||||||
|
'min_bet' => 100,
|
||||||
|
'max_bet' => 100000,
|
||||||
|
'house_take_percent' => 5,
|
||||||
|
'seed_pool' => 10,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var \App\Models\User $user */
|
||||||
|
$user = User::factory()->create(['jjb' => 500]);
|
||||||
|
|
||||||
|
$race = HorseRace::create([
|
||||||
|
'status' => 'running',
|
||||||
|
'bet_opens_at' => now()->subMinutes(2),
|
||||||
|
'bet_closes_at' => now()->subMinute(),
|
||||||
|
'race_starts_at' => now()->subSeconds(30),
|
||||||
|
'winner_horse_id' => 1,
|
||||||
|
'horses' => [
|
||||||
|
['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'],
|
||||||
|
['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
HorseBet::create([
|
||||||
|
'race_id' => $race->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'horse_id' => 1,
|
||||||
|
'amount' => 100,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->decrement('jjb', 100);
|
||||||
|
|
||||||
|
$chatState = $this->createMock(ChatStateService::class);
|
||||||
|
$chatState->method('nextMessageId')->willReturn(1);
|
||||||
|
$chatState->method('pushMessage');
|
||||||
|
|
||||||
|
app()->instance(ChatStateService::class, $chatState);
|
||||||
|
|
||||||
|
(new CloseHorseRaceJob($race))->handle(app('App\Services\UserCurrencyService'), $chatState);
|
||||||
|
|
||||||
|
$bet = HorseBet::query()->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($bet);
|
||||||
|
$this->assertSame('won', $bet->status);
|
||||||
|
$this->assertSame(105, $bet->payout);
|
||||||
|
$this->assertSame(505, $user->fresh()->jjb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user