128 lines
3.4 KiB
Go
128 lines
3.4 KiB
Go
// 台球核心自测:物理稳定性 + 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("犯规后击球权应交给对方")
|
||
}
|
||
}
|