Files
2026-09-19 20:21:47 +02:00

212 lines
5.4 KiB
Go

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