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") } }