// 台球核心自测:物理稳定性 + AI 自对弈能正常打完 package billiards import ( "math" "testing" ) // TestPhysicsStable 大力开球后所有球必须停下且不出界 func TestPhysicsStable(t *testing.T) { g := NewGame() res, err := g.Shoot(0, 0, 100) if err != nil { t.Fatalf("击球失败: %v", err) } if len(res.Frames) < 2 { t.Fatal("模拟帧数过少") } for _, b := range g.Balls { if !b.On { continue } if b.X < BallR-1 || b.X > TableW-BallR+1 || b.Y < BallR-1 || b.Y > TableH-BallR+1 { t.Fatalf("球 %d 停在界外: (%.1f, %.1f)", b.ID, b.X, b.Y) } if math.Hypot(b.vx, b.vy) > 0.01 { t.Fatalf("球 %d 模拟结束仍在运动", b.ID) } } } // TestAIGame 两个 AI 互打 20 盘:每盘必须在 500 杆内分出胜负 func TestAIGame(t *testing.T) { for round := 0; round < 20; round++ { g := NewGame() for shot := 0; shot < 500; shot++ { if g.Phase == PhaseOver { break } seat := g.Turn angle, power := AIShot(g, seat, "hard") if _, err := g.Shoot(seat, angle, power); err != nil { t.Fatalf("第 %d 盘第 %d 杆失败: %v", round, shot, err) } } if g.Phase != PhaseOver { // 统计剩余球数辅助定位 left := 0 for _, b := range g.Balls { if b.On && b.ID != 0 { left++ } } t.Fatalf("第 %d 盘 500 杆仍未结束,桌面剩 %d 球", round, left) } if g.Winner != 0 && g.Winner != 1 { t.Fatalf("第 %d 盘获胜座位非法: %d", round, g.Winner) } } } // TestSpinFollowDraw 高低杆:正对目标球满击后,高杆跟进、低杆回拉,停点应明显分化 // 场景:低力度短距离正碰,目标球滚动距离不足以撞库弹回,避免二次碰撞干扰停点 func TestSpinFollowDraw(t *testing.T) { mk := func(spinY float64) float64 { g := NewGame() for _, b := range g.Balls { if b.ID != 0 && b.ID != 1 { b.On = false } } g.Balls[0].X, g.Balls[0].Y = 60, 200 g.Balls[1].X, g.Balls[1].Y = 160, 200 if _, err := g.ShootSpin(0, 0, 30, 0, spinY); err != nil { t.Fatalf("击球失败: %v", err) } return g.Balls[0].X } center := mk(0) follow := mk(1) draw := mk(-1) if !(follow > center+30 && draw < center-30) { t.Fatalf("高低杆效果异常: 中杆停点 %.1f 高杆 %.1f 低杆 %.1f", center, follow, draw) } } // TestSpinEnglish 左右塞:白球正打右库,右塞反弹后向下偏、左塞向上偏 func TestSpinEnglish(t *testing.T) { mk := func(spinX float64) float64 { g := NewGame() for _, b := range g.Balls { if b.ID != 0 { b.On = false } } g.Balls[0].X, g.Balls[0].Y = 400, 200 if _, err := g.ShootSpin(0, 0, 50, spinX, 0); err != nil { t.Fatalf("击球失败: %v", err) } return g.Balls[0].Y } right := mk(1) left := mk(-1) if !(right < 195 && left > 205) { t.Fatalf("左右塞效果异常: 右塞停点 Y=%.1f 左塞停点 Y=%.1f(期望右塞<195 左塞>205)", right, left) } } // TestFoulRespot 白球落袋后重置回开球点附近且不与其他球重叠 func TestFoulRespot(t *testing.T) { g := NewGame() // 构造直接把白球打进左上角袋的场景 g.Balls[0].X, g.Balls[0].Y = 60, 60 res, err := g.Shoot(0, math.Atan2(-60, -60), 80) if err != nil { t.Fatalf("击球失败: %v", err) } if !g.Balls[0].On { t.Fatal("白球落袋后应被重置回桌面") } if !res.Foul { t.Fatal("白球落袋应判犯规") } if g.Turn != 1 { t.Fatal("犯规后击球权应交给对方") } }