Fix horse race seed pool payouts

This commit is contained in:
2026-04-11 16:11:00 +08:00
parent cc1dd017ce
commit 44db0d7853
7 changed files with 103 additions and 21 deletions

View File

@@ -51,6 +51,7 @@ class HorseRaceController extends Controller
// 计算各马匹当前注额
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$horsePools = HorseBet::query()
->where('race_id', $race->id)
@@ -59,13 +60,13 @@ class HorseRaceController extends Controller
->pluck('pool', 'horse_id')
->toArray();
$oddsMap = HorseRace::calcOdds($horsePools, $houseTake, $seedPool);
// 计算实时赔率
$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);
$totalPool = array_sum(array_values($horsePools));
$netPool = $totalPool * (1 - $houseTake / 100);
$odds = $horsePool > 0 ? round($netPool / $horsePool, 2) : null;
$odds = $horsePool > 0 ? ($oddsMap[$horse['id']] ?? null) : null;
return [
'id' => $horse['id'],

View File

@@ -70,6 +70,7 @@ class CloseHorseRaceJob implements ShouldQueue
$config = GameConfig::forGame('horse_racing')?->params ?? [];
$houseTake = (int) ($config['house_take_percent'] ?? 5);
$seedPool = (int) ($config['seed_pool'] ?? 0);
$winnerId = (int) $race->winner_horse_id;
// 按马匹统计各匹下注金额
@@ -82,7 +83,7 @@ class CloseHorseRaceJob implements ShouldQueue
$totalPool = array_sum($horsePools);
$winnerPool = (int) ($horsePools[$winnerId] ?? 0);
$netPool = (int) ($totalPool * (1 - $houseTake / 100));
$distributablePool = HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool);
// 结算:遍历所有下注记录
$bets = HorseBet::query()
@@ -93,7 +94,7 @@ class CloseHorseRaceJob implements ShouldQueue
$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) {
if ((int) $bet->horse_id !== $winnerId) {
// 未中奖(本金已在下注时扣除)
@@ -104,7 +105,7 @@ class CloseHorseRaceJob implements ShouldQueue
// 中奖:按注额比例分配净注池
if ($winnerPool > 0) {
$payout = (int) round($netPool * ($bet->amount / $winnerPool));
$payout = (int) round($distributablePool * ($bet->amount / $winnerPool));
} else {
$payout = 0;
}

View File

@@ -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 => 赔率(含本金)
*/
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);
if ($totalPool <= 0) {
// 尚无下注,返回等额赔率
$count = count($horseBetAmounts);
return array_map(fn () => 1.0, $horseBetAmounts);
}
$netPool = $totalPool * (1 - $housePercent / 100);
$distributablePool = static::calcDistributablePool($horseBetAmounts, $housePercent, $seedPool);
$odds = [];
foreach ($horseBetAmounts as $horseId => $amount) {
if ($amount <= 0) {
// 无人押注的马,赔率设为理论最大值
$odds[$horseId] = round($netPool, 2);
$odds[$horseId] = round($distributablePool, 2);
} else {
// 赔率 = 净注池 / 该马注额(含本金返还)
$odds[$horseId] = round($netPool / $amount, 2);
$odds[$horseId] = round($distributablePool / $amount, 2);
}
}

View File

@@ -97,6 +97,7 @@ class GameConfigSeeder extends Seeder
'horse_count' => 4, // 参赛马匹数量
'min_bet' => 100, // 最低押注
'max_bet' => 100000, // 最高押注
'seed_pool' => 10000, // 系统初始化资金池
'house_take_percent' => 5, // 庄家抽水(%
],
],

View File

@@ -93,10 +93,12 @@
@php
$params = $game->params ?? [];
$labels = gameParamLabels($game->game_key);
$paramKeys = array_values(array_unique(array_merge(array_keys($labels), array_keys($params))));
@endphp
<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
<div>
<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],
'min_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' => [
'label' => '庄家抽水',
'type' => 'number',

View File

@@ -136,7 +136,8 @@
return [
'投注范围 ' . number_format($p['min_bet'] ?? 100) . '' . number_format($p['max_bet'] ?? 100000) . ' 金币',
'每 ' . ($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) . '%,中奖时按占比瓜分派奖池',
];
},
],

View File

@@ -2,12 +2,15 @@
namespace Tests\Feature;
use App\Jobs\CloseHorseRaceJob;
use App\Models\GameConfig;
use App\Models\HorseBet;
use App\Models\HorseRace;
use App\Models\User;
use App\Services\ChatStateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class HorseRaceControllerTest extends TestCase
@@ -182,4 +185,66 @@ class HorseRaceControllerTest extends TestCase
$response->assertJsonStructure(['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);
}
}