package sim import ( "math" "testing" "wh/config" "wh/fixed" "wh/vm" ) 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 TestMineAndSell(t *testing.T) { cfg := testCfg() s := NewState(cfg) // Sit on asteroid 1, matching its motion for the whole first tick, and mine. pos, vel := s.Asteroids[0].Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, ` ldi r0, 1 out 0x30, r0 ; select asteroid 1 out 0x50, r0 ; mine yield jmp -2 `), pos, vel) if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } // Gravity acts on the ship differently than the rails, so it soon drifts // out of range; it should still have mined something at the start. if sh.CargoTotal() == 0 { t.Fatal("nothing mined") } // Now dock at the station and sell. s2 := NewState(cfg) sp, sv := s2.Station.Orbit.State(0) sh2 := addShip(t, s2, cfg, 1, mustAsm(t, "ldi r0, 1\n out 0x66, r0\n halt"), sp, sv) sh2.Cargo[0] = 1000 // iron if _, err := s2.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if s2.Credits[1] != 2000 || sh2.CargoTotal() != 0 { t.Fatalf("credits=%d cargo=%d", s2.Credits[1], sh2.CargoTotal()) } if s2.Market.Prices[0] >= 2 && s2.Market.Supply[0] != 1000 { t.Fatalf("market not updated: %+v", s2.Market) } } 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) } }