diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..9847506 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,14 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "chatroom" + +[setup] +script = "" + +[[actions]] +name = "启动ws" +icon = "tool" +command = ''' +php artisan reverb:start +php artisan horizon +''' diff --git a/.gitignore b/.gitignore index b6c0520..fb4ee22 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /.junie /.github /.gemini +/.agents /auth.json /node_modules /public/build diff --git a/app/Events/HorseRaceSettled.php b/app/Events/HorseRaceSettled.php index 85bdd7b..dbeb26e 100644 --- a/app/Events/HorseRaceSettled.php +++ b/app/Events/HorseRaceSettled.php @@ -13,6 +13,8 @@ namespace App\Events; +use App\Models\GameConfig; +use App\Models\HorseBet; use App\Models\HorseRace; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PresenceChannel; @@ -20,6 +22,12 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +/** + * 类功能:赛马结算广播事件 + * + * 向房间公共频道广播最终赛果,并附带前端展示个人奖金所需的 + * 奖池分配参数,避免结算弹窗只能显示固定的 0 金币。 + */ class HorseRaceSettled implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; @@ -56,6 +64,24 @@ class HorseRaceSettled implements ShouldBroadcastNow */ public function broadcastWith(): array { + $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', $this->race->id) + ->groupBy('horse_id') + ->selectRaw('horse_id, SUM(amount) as pool') + ->pluck('pool', 'horse_id') + ->map(fn ($pool) => (int) $pool) + ->toArray(); + + $winnerPool = (int) ($horsePools[$this->race->winner_horse_id] ?? 0); + $distributablePool = (int) round( + HorseRace::calcDistributablePool($horsePools, $houseTake, $seedPool, $winnerPool) + ); + // 找出获胜马匹的名称 $horses = $this->race->horses ?? []; $winnerName = '未知'; @@ -71,6 +97,8 @@ class HorseRaceSettled implements ShouldBroadcastNow 'winner_horse_id' => $this->race->winner_horse_id, 'winner_name' => $winnerName, 'total_pool' => (int) $this->race->total_pool, + 'winner_pool' => $winnerPool, + 'distributable_pool' => $distributablePool, 'settled_at' => $this->race->settled_at?->toIso8601String(), ]; } diff --git a/app/Http/Controllers/HorseRaceController.php b/app/Http/Controllers/HorseRaceController.php index bf97723..ddb0626 100644 --- a/app/Http/Controllers/HorseRaceController.php +++ b/app/Http/Controllers/HorseRaceController.php @@ -80,6 +80,15 @@ class HorseRaceController extends Controller ]; }, $horses); + // 押注阶段实时总池 = 当前记录的基础池(通常为种子池)+ 实时下注总额; + // 跑马/结算阶段 total_pool 已写回最终值,不能再重复叠加下注额。 + $basePool = $race->status === 'betting' + ? max((int) $race->total_pool, $seedPool) + : (int) $race->total_pool; + $displayTotalPool = $race->status === 'betting' + ? $basePool + array_sum(array_values($horsePools)) + : $basePool; + return response()->json([ 'race' => [ 'id' => $race->id, @@ -89,7 +98,7 @@ class HorseRaceController extends Controller ? max(0, (int) now()->diffInSeconds($race->bet_closes_at, false)) : 0, 'horses' => $horsesWithBets, - 'total_pool' => $race->total_pool + array_sum(array_values($horsePools)), + 'total_pool' => $displayTotalPool, 'my_bet' => $myBet ? [ 'horse_id' => $myBet->horse_id, 'amount' => $myBet->amount, diff --git a/resources/views/chat/partials/games/horse-race-panel.blade.php b/resources/views/chat/partials/games/horse-race-panel.blade.php index d43c476..ccd3a2c 100644 --- a/resources/views/chat/partials/games/horse-race-panel.blade.php +++ b/resources/views/chat/partials/games/horse-race-panel.blade.php @@ -552,8 +552,12 @@ // 判断本人是否中奖 if (this.myBet && this.myBetHorseId === data.winner_horse_id) { this.myWon = true; - // 赔付前端显示估算(实际以后端为准,后端 WebSocket 无返回赔付金额) - this.myPayout = 0; // 无法前端计算,等用户看下一次余额或后端私信 + // 结算广播已携带冠军注池与可派奖池,这里按后端同公式还原个人赔付展示值。 + const winnerPool = Number(data.winner_pool || 0); + const distributablePool = Number(data.distributable_pool || 0); + this.myPayout = winnerPool > 0 + ? Math.round(distributablePool * (Number(this.myBetAmount || 0) / winnerPool)) + : 0; } else { this.myWon = false; this.myPayout = 0; diff --git a/tests/Feature/HorseRaceControllerTest.php b/tests/Feature/HorseRaceControllerTest.php index bee8b1e..068d225 100644 --- a/tests/Feature/HorseRaceControllerTest.php +++ b/tests/Feature/HorseRaceControllerTest.php @@ -1,7 +1,15 @@ assertEquals($race->id, $response->json('race.id')); } + /** + * 方法功能:验证当前场次总注池会包含种子池金额。 + */ public function test_current_race_total_pool_includes_seed_pool(): void { GameConfig::updateOrCreate( @@ -101,6 +124,52 @@ class HorseRaceControllerTest extends TestCase $this->assertSame(10000, $response->json('race.total_pool')); } + /** + * 方法功能:验证跑马阶段的总注池不会重复叠加下注金额。 + */ + public function test_current_race_running_total_pool_is_not_double_counted(): void + { + /** @var \App\Models\User $user */ + $user = User::factory()->create(); + + $race = HorseRace::create([ + 'status' => 'running', + 'bet_opens_at' => now()->subMinutes(2), + 'bet_closes_at' => now()->subMinute(), + 'race_starts_at' => now()->subSeconds(30), + 'horses' => [ + ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'], + ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'], + ], + 'total_bets' => 2, + 'total_pool' => 10000, + ]); + + HorseBet::create([ + 'race_id' => $race->id, + 'user_id' => User::factory()->create()->id, + 'horse_id' => 1, + 'amount' => 5000, + 'status' => 'pending', + ]); + + HorseBet::create([ + 'race_id' => $race->id, + 'user_id' => User::factory()->create()->id, + 'horse_id' => 2, + 'amount' => 5000, + 'status' => 'pending', + ]); + + $response = $this->actingAs($user)->getJson(route('horse-race.current')); + + $response->assertOk(); + $this->assertSame(10000, $response->json('race.total_pool')); + } + + /** + * 方法功能:验证用户可以成功下注。 + */ public function test_can_bet() { Event::fake(); @@ -138,6 +207,9 @@ class HorseRaceControllerTest extends TestCase ]); } + /** + * 方法功能:验证超出配置范围的下注会被拦截。 + */ public function test_cannot_bet_out_of_range() { /** @var \App\Models\User $user */ @@ -165,6 +237,9 @@ class HorseRaceControllerTest extends TestCase $response->assertJson(['ok' => false]); } + /** + * 方法功能:验证同一用户同一场只能下注一次。 + */ public function test_cannot_bet_twice_in_same_race() { /** @var \App\Models\User $user */ @@ -200,6 +275,9 @@ class HorseRaceControllerTest extends TestCase $response->assertJson(['ok' => false]); } + /** + * 方法功能:验证可读取最近的赛马历史记录。 + */ public function test_can_get_history() { /** @var \App\Models\User $user */ @@ -226,6 +304,9 @@ class HorseRaceControllerTest extends TestCase $this->assertCount(1, $response->json('history')); } + /** + * 方法功能:验证单个赢家至少拿回本金,并可获得种子池补贴。 + */ public function test_single_winner_receives_seed_pool_and_does_not_lose_principal(): void { Event::fake(); @@ -287,4 +368,49 @@ class HorseRaceControllerTest extends TestCase $this->assertSame(105, $bet->payout); $this->assertSame(505, $user->fresh()->jjb); } + + /** + * 方法功能:验证结算广播会携带前端展示奖金所需的奖池参数。 + */ + public function test_settled_broadcast_contains_pool_data_for_payout_display(): void + { + $race = HorseRace::create([ + 'status' => 'settled', + 'bet_opens_at' => now()->subMinutes(2), + 'bet_closes_at' => now()->subMinute(), + 'race_starts_at' => now()->subSeconds(30), + 'race_ends_at' => now()->subSecond(), + 'settled_at' => now(), + 'winner_horse_id' => 1, + 'horses' => [ + ['id' => 1, 'name' => 'Horse A', 'emoji' => '🐎'], + ['id' => 2, 'name' => 'Horse B', 'emoji' => '🏇'], + ], + 'total_pool' => 10000, + ]); + + HorseBet::create([ + 'race_id' => $race->id, + 'user_id' => User::factory()->create()->id, + 'horse_id' => 1, + 'amount' => 5000, + 'status' => 'won', + 'payout' => 9500, + ]); + + HorseBet::create([ + 'race_id' => $race->id, + 'user_id' => User::factory()->create()->id, + 'horse_id' => 2, + 'amount' => 5000, + 'status' => 'lost', + 'payout' => 0, + ]); + + $payload = (new HorseRaceSettled($race))->broadcastWith(); + + $this->assertSame(10000, $payload['total_pool']); + $this->assertSame(5000, $payload['winner_pool']); + $this->assertSame(9500, $payload['distributable_pool']); + } }