241 lines
6.3 KiB
Go
241 lines
6.3 KiB
Go
package sim
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
|
|
"halcyon/config"
|
|
"halcyon/fixed"
|
|
"halcyon/vm"
|
|
"halcyon/world"
|
|
)
|
|
|
|
func testCfg() config.Config {
|
|
c := config.Default()
|
|
c.AsteroidCount = 20
|
|
return c
|
|
}
|
|
|
|
func mustAsm(t *testing.T, src string) []byte {
|
|
t.Helper()
|
|
p, err := vm.Assemble(src)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func addShip(t *testing.T, s *State, cfg config.Config, id int64, prog []byte, pos, vel fixed.Vec) *Ship {
|
|
t.Helper()
|
|
cpu, err := vm.New(prog, cfg.RAMBytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sh := &Ship{ID: id, Owner: 1, Hull: CommandShip, Pos: pos, Vel: vel,
|
|
Fuel: fixed.FromInt(CommandShip.FuelCap), CPU: cpu, Alive: true}
|
|
s.Ships = append(s.Ships, sh)
|
|
return sh
|
|
}
|
|
|
|
const burner = `
|
|
ldi r0, 1000
|
|
out 0x20, r0 ; full throttle
|
|
ldi r0, 500
|
|
out 0x21, r0 ; azimuth 0.5 rad
|
|
ldi r0, 300
|
|
out 0x22, r0 ; pitch 0.3 rad (out of the ecliptic plane)
|
|
loop:
|
|
yield
|
|
jmp loop
|
|
`
|
|
|
|
func TestDeterministic(t *testing.T) {
|
|
cfg := testCfg()
|
|
run := func() [32]byte {
|
|
s := NewState(cfg)
|
|
in := DayInput{
|
|
Launches: []Launch{{ShipID: 1, Owner: 1, Program: mustAsm(t, burner)}},
|
|
Uplinks: []Uplink{{ShipID: 1, Data: []byte("hello")}},
|
|
}
|
|
var res DayResult
|
|
var err error
|
|
for d := 0; d < 3; d++ {
|
|
res, err = s.RunDay(cfg, in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in = DayInput{}
|
|
}
|
|
return res.Hash
|
|
}
|
|
if a, b := run(), run(); a != b {
|
|
t.Fatal("simulation is not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestCircularOrbitHolds(t *testing.T) {
|
|
cfg := testCfg()
|
|
s := NewState(cfg)
|
|
pos, vel := s.Station.Orbit.State(0)
|
|
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel)
|
|
r0 := pos.Len().Float64()
|
|
for d := 0; d < 5; d++ {
|
|
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
r := sh.Pos.Len().Float64()
|
|
if !sh.Alive || r < r0*0.99 || r > r0*1.01 {
|
|
t.Fatalf("radius drifted from %.0f to %.0f km", r0, r)
|
|
}
|
|
}
|
|
|
|
func TestBurnUsesFuelAndChangesVelocity(t *testing.T) {
|
|
cfg := testCfg()
|
|
s := NewState(cfg)
|
|
pos, vel := s.Station.Orbit.State(0)
|
|
sh := addShip(t, s, cfg, 1, mustAsm(t, burner), pos, vel)
|
|
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sh.Fuel >= fixed.FromInt(CommandShip.FuelCap) || sh.Fuel < 0 {
|
|
t.Fatalf("fuel = %v", sh.Fuel.Float64())
|
|
}
|
|
dv := sh.Vel.Sub(vel)
|
|
if dv.Len() < fixed.FromRatio(1, 10) {
|
|
t.Fatalf("velocity barely changed: %v", dv.Len().Float64())
|
|
}
|
|
// Thrust at 0.3 rad pitch must push the ship out of the ecliptic: the
|
|
// station's orbit is in-plane and gravity is central, so any Z velocity
|
|
// comes from the engine.
|
|
if dv.Z <= 0 {
|
|
t.Fatalf("expected upward velocity change, got dz=%v", dv.Z.Float64())
|
|
}
|
|
// The burn direction should match azimuth 0.5 / pitch 0.3 (gravity is
|
|
// small next to a ~10 km/s burn).
|
|
wantZ := math.Sin(0.3)
|
|
if got := dv.Z.Float64() / dv.Len().Float64(); math.Abs(got-wantZ) > 0.05 {
|
|
t.Fatalf("burn elevation: sin = %.3f, want %.3f", got, wantZ)
|
|
}
|
|
wantAz := 0.5
|
|
if got := math.Atan2(dv.Y.Float64(), dv.X.Float64()); math.Abs(got-wantAz) > 0.05 {
|
|
t.Fatalf("burn azimuth = %.3f, want %.3f", got, wantAz)
|
|
}
|
|
}
|
|
|
|
func TestInclinedOrbitLeavesPlane(t *testing.T) {
|
|
cfg := testCfg()
|
|
s := NewState(cfg)
|
|
// Find an asteroid with a noticeable inclination and confirm a ship on its
|
|
// orbit stays on a plane that is not the ecliptic.
|
|
var best int
|
|
for i := range s.Asteroids {
|
|
if s.Asteroids[i].Orbit.Inc > s.Asteroids[best].Orbit.Inc {
|
|
best = i
|
|
}
|
|
}
|
|
o := s.Asteroids[best].Orbit
|
|
if o.Inc < fixed.FromRatio(1, 20) {
|
|
t.Skip("no inclined asteroid in this seed")
|
|
}
|
|
var maxZ fixed.F
|
|
for k := int64(0); k < 20; k++ {
|
|
p, _ := o.State(k * o.Period / 20)
|
|
maxZ = fixed.Max2(maxZ, p.Z.Abs())
|
|
}
|
|
if maxZ < fixed.FromInt(1000) {
|
|
t.Fatalf("inclined orbit never left the plane: max |z| = %v km", maxZ.Float64())
|
|
}
|
|
}
|
|
|
|
func TestCommsBuffers(t *testing.T) {
|
|
cfg := testCfg()
|
|
s := NewState(cfg)
|
|
pos, vel := s.Station.Orbit.State(0)
|
|
// Copy the first uplink word into the TX buffer, then ack.
|
|
prog := mustAsm(t, `
|
|
.equ TX 1024
|
|
in r1, 0x70
|
|
ldi r2, 0
|
|
beq r1, r2, done
|
|
ldw r3, [r2]
|
|
stw r3, [r2+TX]
|
|
out 0x70, r1
|
|
done:
|
|
halt
|
|
`)
|
|
addShip(t, s, cfg, 1, prog, pos, vel)
|
|
res, err := s.RunDay(cfg, DayInput{Uplinks: []Uplink{{ShipID: 1, Data: []byte("PING")}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Downlinks) != 1 || string(res.Downlinks[0].Data[:4]) != "PING" {
|
|
t.Fatalf("downlink = %q", res.Downlinks)
|
|
}
|
|
}
|
|
|
|
func TestScannerRange(t *testing.T) {
|
|
cfg := testCfg()
|
|
s := NewState(cfg)
|
|
stPos, stVel := s.Station.Orbit.State(0)
|
|
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt\n"), stPos, stVel)
|
|
tc := newTickCtx(s, cfg, 0, 0, stPos, stVel, &DayResult{}, new([world.NumOre]int64))
|
|
b := &shipBus{tc: tc, sh: sh}
|
|
|
|
// Find the true nearest asteroid and its distance from the ship.
|
|
var near int
|
|
var nd fixed.F = fixed.Max
|
|
for i := range s.Asteroids {
|
|
p, _ := tc.asteroid(i)
|
|
if d := p.Sub(sh.Pos).Len(); d < nd {
|
|
near, nd = i+1, d
|
|
}
|
|
}
|
|
limit := fixed.FromInt(ScanRangeKm)
|
|
if nd <= limit {
|
|
if got := b.In(PortScanNearest); got != int32(near) {
|
|
t.Fatalf("nearest = %d, want %d", got, near)
|
|
}
|
|
} else if got := b.In(PortScanNearest); got != 0 {
|
|
t.Fatalf("nearest = %d beyond range, want 0", got)
|
|
}
|
|
|
|
// Move the ship out of range of everything: the scanner goes blind.
|
|
sh.Pos = fixed.Vec{X: fixed.FromInt(50_000_000)}
|
|
if got := b.In(PortScanNearest); got != 0 {
|
|
t.Fatalf("nearest out of range = %d, want 0", got)
|
|
}
|
|
b.Out(PortScanSelect, int32(near))
|
|
for _, p := range []uint16{PortScanRelX, PortScanRelVX, PortScanDist, PortScanOre} {
|
|
if got := b.In(p); got != 0 {
|
|
t.Fatalf("port %#x = %d out of range, want 0", p, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScrapRemovesShipAndReleasesTow(t *testing.T) {
|
|
cfg := testCfg()
|
|
s := NewState(cfg)
|
|
stPos, stVel := s.Station.Orbit.State(0)
|
|
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt\n"), stPos, stVel)
|
|
s.Containers = append(s.Containers, &Container{ID: 1, Towed: sh.ID, Pos: stPos, Vel: stVel})
|
|
res, err := s.RunDay(cfg, DayInput{Scraps: []int64{1, 42}}) // an unknown id is ignored
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(s.Ships) != 0 {
|
|
t.Fatalf("%d ships left after scrapping", len(s.Ships))
|
|
}
|
|
c := s.Containers[0]
|
|
if c.Towed != 0 || c.Dropped != 1 {
|
|
t.Fatalf("container after scrapping: towed %d dropped %d, want adrift", c.Towed, c.Dropped)
|
|
}
|
|
found := false
|
|
for _, e := range res.Events {
|
|
found = found || (e.Kind == "scrapped" && e.ShipID == 1)
|
|
}
|
|
if !found {
|
|
t.Fatal("no scrapped event")
|
|
}
|
|
}
|