package sim import ( "bytes" "encoding/binary" "encoding/gob" "math" "testing" "halcyon/config" "halcyon/fixed" "halcyon/market" "halcyon/vm" "halcyon/world" ) const mineProgram = ` ldi r0, 1 out 0x30, r0 ; select asteroid 1 out 0x50, r0 ; mine yield jmp -2 ` // ctxAt is a turn context at absolute time now, for calling the container rules directly. func ctxAt(s *State, cfg config.Config, now int64) *tickCtx { pos, vel := s.Station.Orbit.State(now) return newTickCtx(s, cfg, 0, now, pos, vel, &DayResult{}, new([world.NumOre]int64)) } // dockAt puts the ship at the station as it is at absolute time now. func dockAt(s *State, sh *Ship, now int64) { sh.Pos, sh.Vel = s.Station.Orbit.State(now) } func expiredEvents(tc *tickCtx) (n int) { for _, e := range tc.res.Events { if e.Kind == "expired" { n++ } } return n } func towed(s *State, sh *Ship, n int) []*Container { var out []*Container for i := 0; i < n; i++ { c := s.newContainer(sh.Owner, sh.Pos, sh.Vel) c.Towed = sh.ID out = append(out, c) } return out } func TestMiningFillsTowedContainers(t *testing.T) { cfg := testCfg() // With no container in tow there is nowhere to put ore: nothing is mined. s := NewState(cfg) pos, vel := s.Asteroids[0].Orbit.State(0) before := s.Asteroids[0].TotalOre() addShip(t, s, cfg, 1, mustAsm(t, mineProgram), pos, vel) if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if s.Asteroids[0].TotalOre() != before { t.Fatal("ore was taken from the asteroid with no container to hold it") } // With a container in tow, ore goes into it. (Gravity soon carries the ship off // the rails-mounted asteroid, so only the first stretch mines.) s = NewState(cfg) pos, vel = s.Asteroids[0].Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, mineProgram), pos, vel) cs := towed(s, sh, 2) cs[0].Ore[world.Iron] = ContainerCapKg - 15 // nearly full: takes 15 kg, the rest spills into the next if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } mined := before - s.Asteroids[0].TotalOre() if mined <= 15 { t.Fatalf("only %d kg mined", mined) } if cs[0].OreTotal() != ContainerCapKg { t.Fatalf("the first container holds %d kg, want it full (%d)", cs[0].OreTotal(), ContainerCapKg) } if got := cs[0].OreTotal() + cs[1].OreTotal() - (ContainerCapKg - 15); got != mined { t.Fatalf("containers gained %d kg but the asteroid lost %d", got, mined) } } func TestMiningStopsWhenContainersAreFull(t *testing.T) { cfg := testCfg() s := NewState(cfg) pos, vel := s.Asteroids[0].Orbit.State(0) before := s.Asteroids[0].TotalOre() sh := addShip(t, s, cfg, 1, mustAsm(t, mineProgram), pos, vel) c := towed(s, sh, 1)[0] c.Ore[world.Iron] = ContainerCapKg if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if s.Asteroids[0].TotalOre() != before || c.OreTotal() != ContainerCapKg { t.Fatal("a full container should stop the laser") } } func TestOrderContainersAtTheStation(t *testing.T) { cfg := testCfg() s := NewState(cfg) stPos, stVel := s.Station.Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) if n := s.order(sh, 3, ctxAt(s, cfg, 0)); n != 3 || len(s.Containers) != 3 { t.Fatalf("ordered 3, got %d (%d exist)", n, len(s.Containers)) } for i, c := range s.Containers { if c.ID != int64(i+1) || c.OrderedBy != 1 || c.Towed != 0 || c.OreTotal() != 0 || c.Pos != sh.Pos || c.Vel != sh.Vel { t.Fatalf("delivered container %d: %+v (want empty, adrift, ordered by 1, at the ship)", i, c) } } if n := s.order(sh, 25, ctxAt(s, cfg, 0)); n != MaxOrder { t.Fatalf("a request for 25 delivered %d, want the maximum %d", n, MaxOrder) } if n := s.order(sh, 0, ctxAt(s, cfg, 0)); n != 0 { t.Fatal("ordering none delivered some") } if n := s.order(sh, -4, ctxAt(s, cfg, 0)); n != 0 { t.Fatal("a negative order delivered some") } // Never past the per-player ordering allowance. for i := 0; i < 10; i++ { s.order(sh, MaxOrder, ctxAt(s, cfg, 0)) } if len(s.Containers) != MaxContainersPerPlayer { t.Fatalf("%d containers, want the limit %d", len(s.Containers), MaxContainersPerPlayer) } // Another player has their own allowance. other := &Ship{ID: 2, Owner: 2, Pos: stPos, Vel: stVel, Alive: true} if n := s.order(other, 4, ctxAt(s, cfg, 0)); n != 4 { t.Fatalf("second player got %d", n) } // Not near the station: refused. far := NewState(cfg) apos, avel := far.Asteroids[0].Orbit.State(0) fsh := addShip(t, far, cfg, 1, mustAsm(t, "halt"), apos, avel) if n := far.order(fsh, 5, ctxAt(far, cfg, 0)); n != 0 || len(far.Containers) != 0 { t.Fatalf("delivered %d far from the station", n) } // Just inside and just outside the station's reach. edge := NewState(cfg) inside := &Ship{ID: 1, Owner: 1, Pos: stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm - 1)}), Alive: true} outside := &Ship{ID: 2, Owner: 1, Pos: stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm + 1)}), Alive: true} if edge.order(inside, 1, ctxAt(edge, cfg, 0)) != 1 || edge.order(outside, 1, ctxAt(edge, cfg, 0)) != 0 { t.Fatal("the station's reach is not DockRangeKm") } } func TestAttachAndDetach(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) km := func(x int64) fixed.Vec { return fixed.Vec{X: fixed.FromInt(x)} } near1 := s.newContainer(1, pos, vel) near2 := s.newContainer(1, pos.Add(km(ContainerRangeKm-1)), vel) fromElsewhere := s.newContainer(2, pos, vel) // ordered by another player: containers are not owned tooFar := s.newContainer(1, pos.Add(km(ContainerRangeKm+1)), vel) tooFast := s.newContainer(1, pos, vel.Add(fixed.Vec{Y: fixed.FromRatio(200, 1000)})) // 200 m/s away already := s.newContainer(1, pos, vel) already.Towed = 99 // being towed by another ship if n := s.nearbyFree(sh); n != 3 { t.Fatalf("%d containers reported near, want 3", n) } s.attach(sh, ctxAt(s, cfg, 0)) for name, c := range map[string]*Container{"near1": near1, "near2": near2, "fromElsewhere": fromElsewhere} { if c.Towed != sh.ID { t.Errorf("%s was not attached", name) } } if near2.Pos != sh.Pos { // attaching brings it alongside t.Error("an attached container should move to the ship") } for name, c := range map[string]*Container{"tooFar": tooFar, "tooFast": tooFast} { if c.Towed != 0 { t.Errorf("%s should not have been attached", name) } } if already.Towed != 99 { t.Error("took a container that another ship was towing") } if tow := s.towOf(sh.ID); tow.Count != 3 || tow.Mass != 3*ContainerMassKg || tow.Room != 3*ContainerCapKg { t.Fatalf("tow = %+v", tow) } if s.nearbyFree(sh) != 0 { t.Error("nothing should be left to attach") } s.detach(sh, ctxAt(s, cfg, 0)) if near1.Towed != 0 || near2.Towed != 0 || fromElsewhere.Towed != 0 || near1.Dropped != sh.ID { t.Fatalf("detach: %+v %+v", near1, near2) } if already.Towed != 99 { t.Error("detach touched another ship's tow") } if s.nearbyFree(sh) != 3 { t.Error("detached containers next to the ship can be attached again") } } func TestTowedMassReducesAcceleration(t *testing.T) { cfg := testCfg() dvOf := func(containers int, ore int64) (dv float64, mass float64) { s := NewState(cfg) pos, vel := s.Station.Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel) for _, c := range towed(s, sh, containers) { c.Ore[world.Iron] = ore } sh.Throttle = 1000 sh.Azimuth = 0 mass = s.shipMass(sh).Float64() before := sh.Vel // One burning turn; gravity's share of dv is negligible next to the engine's, // and identical in both runs. var res DayResult s.physics(cfg, sh, 60, 0, &res) return sh.Vel.Sub(before).Len().Float64(), mass } dv0, m0 := dvOf(0, 0) dv4, m4 := dvOf(4, ContainerCapKg) if want := float64(CommandShip.DryMass + CommandShip.FuelCap); math.Abs(m0-want) > 1e-6 { t.Fatalf("bare mass = %v, want %v", m0, want) } if want := m0 + 4*(ContainerMassKg+ContainerCapKg); math.Abs(m4-want) > 1e-6 { t.Fatalf("towing mass = %v, want %v", m4, want) } if !(dv4 < dv0) { t.Fatalf("towing four full containers did not slow the acceleration: %v vs %v", dv4, dv0) } if got, want := dv0/dv4, m4/m0; math.Abs(got-want)/want > 0.01 { t.Fatalf("acceleration ratio %.3f, mass ratio %.3f: acceleration should be inversely proportional to mass", got, want) } } func TestTowedContainersTravelWithTheShip(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) cs := towed(s, sh, 3) loose := s.newContainer(1, pos.Add(fixed.Vec{X: fixed.FromInt(3)}), vel) // near, but not attached if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if sh.Vel.Sub(vel).Len() < fixed.FromRatio(1, 10) { t.Fatal("the ship did not burn") } for i, c := range cs { if c.Pos != sh.Pos || c.Vel != sh.Vel { t.Fatalf("towed container %d fell behind: %+v vs ship %+v %+v", i, c.Pos, sh.Pos, sh.Vel) } } if loose.Pos.Sub(sh.Pos).Len() < fixed.FromInt(1000) { t.Fatal("an unattached container should have been left behind by the burn") } } func TestSoldContainersDisappearAndTheDropperIsPaid(t *testing.T) { cfg := testCfg() s := NewState(cfg) stPos, stVel := s.Station.Orbit.State(0) away := stPos.Add(fixed.Vec{Z: fixed.FromInt(50_000)}) // well outside the station's reach a := addShip(t, s, cfg, 1, mustAsm(t, "halt"), away, stVel) b := addShip(t, s, cfg, 2, mustAsm(t, "halt"), away, stVel) b.Owner = 2 // addShip makes every ship player 1's // Player 1's ship let this one go. Player 2 ordered it: that gives them no claim. sold := s.newContainer(2, stPos, stVel) sold.Ore[world.Iron] = 1000 // 2 credits/kg sold.Ore[world.Ice] = 100 // 3 credits/kg sold.Dropped = a.ID inReach := s.newContainer(1, stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm - 1)}), stVel) inReach.Ore[world.Iron] = 10 inReach.Dropped = b.ID tooFar := s.newContainer(1, stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm + 10)}), stVel) tooFar.Ore[world.Iron] = 500 tooFar.Dropped = a.ID inTow := s.newContainer(1, stPos, stVel) inTow.Ore[world.Iron] = 700 inTow.Towed = a.ID // a ship towing ore past the station does not sell it empty := s.newContainer(1, stPos, stVel) empty.Dropped = a.ID // nothing in it: the station does not buy it nobody := s.newContainer(1, stPos, stVel) nobody.Ore[world.Iron] = 40 // no ship ever let it go, so there is no one to pay nobody.Dropped = 0 if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if want := int64(1000*2 + 100*3); s.Credits[1] != want { t.Errorf("player 1 (the ship that let go) is paid %d, want %d", s.Credits[1], want) } if want := int64(10 * 2); s.Credits[2] != want { t.Errorf("player 2 is paid %d, want %d (only for the container their ship let go)", s.Credits[2], want) } if a.Earned != int64(1000*2+100*3) || b.Earned != 20 { t.Errorf("ships earned %d and %d", a.Earned, b.Earned) } // Sold containers are gone, with their ore. Everything else is still there. var left []*Container for _, c := range s.Containers { left = append(left, c) } want := map[*Container]bool{tooFar: true, inTow: true, empty: true, nobody: true} if len(left) != len(want) { t.Fatalf("%d containers left, want %d", len(left), len(want)) } for _, c := range left { if !want[c] { t.Errorf("container %d survived its sale", c.ID) } } if tooFar.OreTotal() != 500 || inTow.OreTotal() != 700 || nobody.OreTotal() != 40 { t.Error("ore that was not sold must stay in its container") } if s.Market.Supply[world.Iron] != 1010 || s.Market.Supply[world.Ice] != 100 { t.Errorf("market supply %+v: sold ore should count", s.Market.Supply) } } // The ordering allowance counts the containers a player ordered that still exist. // A sale removes them, so selling a load frees room to order the next. func TestSaleFreesTheOrderingAllowance(t *testing.T) { cfg := testCfg() s := NewState(cfg) stPos, stVel := s.Station.Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) var res DayResult for i := 0; i < 5; i++ { s.order(sh, MaxOrder, ctxAt(s, cfg, 0)) } if len(s.Containers) != MaxContainersPerPlayer || s.order(sh, 1, ctxAt(s, cfg, 0)) != 0 { t.Fatalf("expected to be at the allowance (%d containers)", len(s.Containers)) } for _, c := range s.Containers[:10] { c.Ore[world.Iron] = 100 c.Dropped = sh.ID c.Pos = stPos } tc := newTickCtx(s, cfg, 0, 0, stPos, stVel, &res, new([world.NumOre]int64)) s.sellNearStation(tc) if len(s.Containers) != MaxContainersPerPlayer-10 { t.Fatalf("%d containers after selling 10", len(s.Containers)) } if n := s.order(sh, MaxOrder, ctxAt(s, cfg, 0)); n != 10 { t.Fatalf("after the sale the player could order %d, want 10", n) } } func TestFreeContainersOrbitAndFallIntoTheStar(t *testing.T) { cfg := testCfg() s := NewState(cfg) pos, vel := s.Station.Orbit.State(0) // A container let go at the station's velocity stays in the station's orbit. drifter := s.newContainer(1, pos, vel) r0 := pos.Len().Float64() // One with no speed, closer in, falls straight into the star within a few days // (well inside the ten a container adrift is allowed). faller := s.newContainer(1, fixed.Vec{X: fixed.FromInt(700_000)}, fixed.Vec{}) faller.Ore[world.Platinum] = 1000 addShip(t, s, cfg, 1, mustAsm(t, "halt"), fixed.Vec{X: fixed.FromInt(10_000_000)}, fixed.Vec{}) var res DayResult for d := 0; d < 6; d++ { r, err := s.RunDay(cfg, DayInput{}) if err != nil { t.Fatal(err) } res.Events = append(res.Events, r.Events...) } if r := drifter.Pos.Len().Float64(); r < r0*0.99 || r > r0*1.01 { t.Fatalf("drifting container left its orbit: %.0f -> %.0f km", r0, r) } lost := false for _, e := range res.Events { lost = lost || e.Kind == "lost" } if !lost || len(s.Containers) != 1 || s.Containers[0] != drifter { t.Fatalf("the container with no speed should have fallen in and been lost (%d left, lost=%v)", len(s.Containers), lost) } } func TestLosingAShipSetsItsContainersAdrift(t *testing.T) { cfg := testCfg() s := NewState(cfg) // A ship inside the star's exclusion zone is destroyed on the first tick. sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), fixed.Vec{X: fixed.FromInt(StarRadiusKm - 1)}, fixed.Vec{}) c := towed(s, sh, 1)[0] if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if sh.Alive { t.Fatal("the ship should have been destroyed") } if c.Towed != 0 && len(s.Containers) != 0 { t.Fatal("a destroyed ship's tow must be released, not left attached to a dead ship") } } func TestLegacyCargoBecomesContainers(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) sh.Cargo = [world.NumOre]int64{world.Iron: 5000, world.Nickel: 1200, world.Platinum: 7} if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } tow := s.towOf(sh.ID) if tow.Count != 4 || tow.OreTotal != 6207 || tow.Ore[world.Iron] != 5000 || tow.Ore[world.Nickel] != 1200 || tow.Ore[world.Platinum] != 7 { t.Fatalf("tow = %+v, want 4 containers holding all 6207 kg", tow) } for _, c := range s.Containers { if c.OreTotal() > ContainerCapKg { t.Fatalf("container %d holds %d kg", c.ID, c.OreTotal()) } } if sh.Cargo != ([world.NumOre]int64{}) { t.Fatal("the old hold should be empty after conversion") } } // Snapshots written before containers existed must still load: their ships // have a Cargo array and a CargoCap in the hull, and no container list. func TestSnapshotFromBeforeContainersStillLoads(t *testing.T) { type legacyHull struct{ DryMass, FuelCap, CargoCap, Thrust, ExhaustVel, MineRate int64 } type legacyShip struct { ID, Owner int64 Hull legacyHull Pos, Vel fixed.Vec Fuel fixed.F Cargo [world.NumOre]int64 CPU *vm.CPU Alive bool Earned int64 Throttle, Azimuth int32 Pitch int32 Target int64 Mining, UplinkNew bool UplinkLen int32 MathX, MathY, MathZ int32 } type legacyState struct { Day int64 Asteroids []world.Asteroid Station world.Station Ships []*legacyShip Credits map[int64]int64 Market market.Market } cfg := testCfg() fresh := NewState(cfg) pos, vel := fresh.Station.Orbit.State(0) cpu, _ := vm.New(mustAsm(t, "halt"), cfg.RAMBytes) old := legacyState{ Day: 5, Asteroids: fresh.Asteroids, Station: fresh.Station, Credits: map[int64]int64{1: 123}, Market: fresh.Market, Ships: []*legacyShip{{ ID: 7, Owner: 1, Hull: legacyHull{DryMass: 8000, FuelCap: 4000, CargoCap: 6000, Thrust: 6000, ExhaustVel: 30000, MineRate: 10}, Pos: pos, Vel: vel, Fuel: fixed.FromInt(4000), CPU: cpu, Alive: true, Cargo: [world.NumOre]int64{world.Iron: 3000}, }}, } var buf bytes.Buffer if err := gob.NewEncoder(&buf).Encode(&old); err != nil { t.Fatal(err) } var s State if err := gob.NewDecoder(&buf).Decode(&s); err != nil { t.Fatalf("an old snapshot no longer decodes: %v", err) } if s.Day != 5 || len(s.Ships) != 1 || s.Ships[0].Cargo[world.Iron] != 3000 || s.Credits[1] != 123 || len(s.Containers) != 0 { t.Fatalf("decoded = day %d, %d ships, cargo %v, credits %v", s.Day, len(s.Ships), s.Ships[0].Cargo, s.Credits) } if _, err := s.RunDay(cfg, DayInput{}); err != nil { t.Fatal(err) } if tow := s.towOf(7); tow.Count != 2 || tow.Ore[world.Iron] != 3000 { t.Fatalf("the old ship's 3000 kg of iron should now ride in 2 containers: %+v", tow) } } // A program that orders, attaches, reads and detaches, through the real ports. const haulProgram = ` .equ P_CONTS 0x51 .equ P_CONTROOM 0x53 .equ P_CONTMASS 0x54 .equ P_ATTACH 0x55 .equ P_DETACH 0x56 .equ P_NEARBY 0x57 .equ P_ORDER 0x66 .equ P_MASS 0x24 .equ TX 1024 ldi r0, 0 ldi r1, 3 out P_ORDER, r1 ; three empty containers, delivered to the ship in r2, P_NEARBY stw r2, [r0+1024] ; 3 nearby before attaching ldi r1, 1 out P_ATTACH, r1 in r2, P_CONTS stw r2, [r0+1028] ; 3 in tow in r2, P_CONTROOM stw r2, [r0+1032] ; 6000 kg of room in r2, P_CONTMASS stw r2, [r0+1036] ; 1500 kg of container in r2, P_NEARBY stw r2, [r0+1040] ; 0 nearby: all attached in r2, P_MASS stw r2, [r0+1044] ; total ship mass out P_DETACH, r1 in r2, P_CONTS stw r2, [r0+1048] ; 0 in tow in r2, P_NEARBY stw r2, [r0+1052] ; 3 nearby again in r2, P_MASS stw r2, [r0+1056] ; mass without them halt ` func TestContainerPorts(t *testing.T) { cfg := testCfg() run := func() (DayResult, *State) { s := NewState(cfg) pos, vel := s.Station.Orbit.State(0) addShip(t, s, cfg, 1, mustAsm(t, haulProgram), pos, vel) res, err := s.RunDay(cfg, DayInput{}) if err != nil { t.Fatal(err) } return res, s } res, s := run() d := res.Downlinks[0].Data w := func(i int) int32 { return int32(binary.LittleEndian.Uint32(d[i*4:])) } base := int32(CommandShip.DryMass + CommandShip.FuelCap) want := []int32{3, 3, 3 * ContainerCapKg, 3 * ContainerMassKg, 0, base + 3*ContainerMassKg, 0, 3, base} for i, v := range want { if w(i) != v { t.Errorf("word %d = %d, want %d", i, w(i), v) } } if len(s.Containers) != 3 { t.Errorf("%d containers exist", len(s.Containers)) } // The same program on the same world is the same day, containers and all. if res2, _ := run(); res2.Hash != res.Hash { t.Error("ordering, attaching and detaching are not deterministic") } } const life = ContainerLifeDays * config.SecondsPerDay func TestContainersAdriftForTenDaysVanish(t *testing.T) { cfg := testCfg() s := NewState(cfg) stPos, stVel := s.Station.Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) t0 := int64(12345) dockAt(s, sh, t0) if n := s.order(sh, 3, ctxAt(s, cfg, t0)); n != 3 { t.Fatalf("ordered %d", n) } if s.Containers[0].ExpiresAt != t0+life { t.Fatalf("a delivered container expires at %d, want %d", s.Containers[0].ExpiresAt, t0+life) } just := ctxAt(s, cfg, t0+life-1) s.expireContainers(just) if len(s.Containers) != 3 || expiredEvents(just) != 0 { t.Fatal("a container vanished before its ten days were up") } end := ctxAt(s, cfg, t0+life) s.expireContainers(end) if len(s.Containers) != 0 || expiredEvents(end) != 3 { t.Fatalf("%d containers left, %d expiry events: after ten days they should be gone", len(s.Containers), expiredEvents(end)) } } func TestTowingStopsTheClockAndLettingGoRestartsIt(t *testing.T) { cfg := testCfg() s := NewState(cfg) stPos, stVel := s.Station.Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) t0 := int64(1000) dockAt(s, sh, t0) s.order(sh, 1, ctxAt(s, cfg, t0)) c := s.Containers[0] s.attach(sh, ctxAt(s, cfg, t0+5*config.SecondsPerDay)) if c.Towed != sh.ID || c.ExpiresAt != 0 { t.Fatalf("attached container: towed=%d expires=%d, want towed with no timer", c.Towed, c.ExpiresAt) } // A month in tow, and it is still here. s.expireContainers(ctxAt(s, cfg, t0+30*config.SecondsPerDay)) if len(s.Containers) != 1 { t.Fatal("a container in tow expired") } // Let go, and the ten days start again from that moment, not from the delivery. letGoAt := t0 + 30*config.SecondsPerDay s.detach(sh, ctxAt(s, cfg, letGoAt)) if c.ExpiresAt != letGoAt+life { t.Fatalf("expires at %d, want %d", c.ExpiresAt, letGoAt+life) } s.expireContainers(ctxAt(s, cfg, letGoAt+life-1)) if len(s.Containers) != 1 { t.Fatal("expired early") } s.expireContainers(ctxAt(s, cfg, letGoAt+life)) if len(s.Containers) != 0 { t.Fatal("did not expire ten days after being let go") } } func TestLosingContainersDoesNotBlockOrdering(t *testing.T) { cfg := testCfg() s := NewState(cfg) stPos, stVel := s.Station.Orbit.State(0) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) for i := 0; i < 5; i++ { s.order(sh, MaxOrder, ctxAt(s, cfg, 0)) } if len(s.Containers) != MaxContainersPerPlayer || s.order(sh, 1, ctxAt(s, cfg, 0)) != 0 { t.Fatalf("not at the allowance: %d containers", len(s.Containers)) } // Lost and forgotten: ten days later they are gone and the allowance is back. s.expireContainers(ctxAt(s, cfg, life)) if len(s.Containers) != 0 { t.Fatalf("%d containers survived", len(s.Containers)) } dockAt(s, sh, life) // the station has moved on in ten days if n := s.order(sh, MaxOrder, ctxAt(s, cfg, life)); n != MaxOrder { t.Fatalf("after they expired the player could order %d, want %d", n, MaxOrder) } } func TestContainerWithNoTimerGetsAFreshTenDays(t *testing.T) { cfg := testCfg() s := NewState(cfg) pos, vel := s.Station.Orbit.State(0) old := s.newContainer(1, pos, vel) // as decoded from a snapshot made before containers expired now := int64(50 * config.SecondsPerDay) s.expireContainers(ctxAt(s, cfg, now)) if len(s.Containers) != 1 || old.ExpiresAt != now+life { t.Fatalf("expires at %d (want %d), %d containers", old.ExpiresAt, now+life, len(s.Containers)) } } func TestLostShipsContainersStartTheClock(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) c := towed(s, sh, 1)[0] s.releaseTow(sh, 777) if c.Towed != 0 || c.ExpiresAt != 777+life { t.Fatalf("released container: towed=%d expires=%d", c.Towed, c.ExpiresAt) } } // Through the real daily loop: an adrift container that nobody collects is // removed on schedule, while one in tow and the day's other rules carry on. func TestExpiryThroughTheDailyRun(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.Add(fixed.Vec{Z: fixed.FromInt(50_000)}), vel) drifting := s.newContainer(1, pos, vel) drifting.ExpiresAt = 3*config.SecondsPerDay + 7200 // two hours into the fourth day inTow := towed(s, sh, 1)[0] var events []Event var alive []bool for d := 0; d < 5; d++ { r, err := s.RunDay(cfg, DayInput{}) if err != nil { t.Fatal(err) } events = append(events, r.Events...) gone := true for _, c := range s.Containers { if c == drifting { gone = false } } alive = append(alive, !gone) } if want := []bool{true, true, true, false, false}; !equalBools(alive, want) { t.Fatalf("drifting container present after each day: %v, want %v", alive, want) } expired := 0 for _, e := range events { if e.Kind == "expired" { expired++ } } if expired != 1 { t.Fatalf("%d expiry events, want 1", expired) } if inTow.Towed != sh.ID { t.Fatal("the container in tow was affected") } } func equalBools(a, b []bool) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // On the very turn its ten days end, a container that is at the station with ore // is bought, not thrown away: the sale comes first. func TestSaleBeatsExpiry(t *testing.T) { cfg := testCfg() s := NewState(cfg) s.Day = 20 now := s.Day * config.SecondsPerDay stPos, stVel := s.Station.Orbit.State(now) sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos.Add(fixed.Vec{Z: fixed.FromInt(50_000)}), stVel) c := s.newContainer(0, stPos, stVel) c.Ore[world.Iron] = 100 c.Dropped = sh.ID c.ExpiresAt = now // due on the first turn of the day res, err := s.RunDay(cfg, DayInput{}) if err != nil { t.Fatal(err) } if s.Credits[1] != 200 { t.Fatalf("credits = %d: the ore should have been sold before the container expired", s.Credits[1]) } for _, e := range res.Events { if e.Kind == "expired" { t.Fatal("the container was reported expired as well as sold") } } }