Files
Halcyon/sim/container_test.go
T
2026-10-01 22:16:48 +02:00

759 lines
25 KiB
Go

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