This commit is contained in:
root
2026-10-01 22:16:48 +02:00
parent b6d1909d7f
commit 9dfdf79fa7
39 changed files with 4560 additions and 181 deletions
+30 -22
View File
@@ -94,12 +94,13 @@ func (b *shipBus) In(port uint16) int32 {
case port == PortFuel:
return sat32(sh.Fuel.Floor())
case port == PortMass:
return sat32(sh.Mass().Floor())
return sat32(tc.s.shipMass(sh).Floor())
case port == PortScanNearest:
scanRange := fixed.FromInt(ScanRangeKm)
best, bestD := int64(0), fixed.Max
for i := range tc.s.Asteroids {
p, _ := tc.asteroid(i)
if d := p.Sub(sh.Pos).Len(); d < bestD {
if d := p.Sub(sh.Pos).Len(); d <= scanRange && d < bestD {
best, bestD = int64(i+1), d
}
}
@@ -110,6 +111,9 @@ func (b *shipBus) In(port uint16) int32 {
return 0
}
rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel)
if rel.Len() > fixed.FromInt(ScanRangeKm) {
return 0
}
switch {
case port <= PortScanRelZ:
return axis(rel, port, PortScanRelX, kmInt)
@@ -119,16 +123,25 @@ func (b *shipBus) In(port uint16) int32 {
return kmInt(rel.Len())
case port >= PortScanOre && port < PortScanOre+8:
if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) {
if p, _, _ := b.target(); p.Sub(sh.Pos).Len() > fixed.FromInt(ScanRangeKm) {
return 0
}
return sat32(a.Ore[port-PortScanOre])
}
return 0
case port == PortCargo:
return sat32(sh.CargoTotal())
case port == PortCargoCap:
return sat32(sh.Hull.CargoCap)
case port >= PortCargoOre && port < PortCargoOre+8:
if port-PortCargoOre < uint16(world.NumOre) {
return sat32(sh.Cargo[port-PortCargoOre])
case port == PortConts:
return int32(tc.s.towOf(sh.ID).Count)
case port == PortContKg:
return sat32(tc.s.towOf(sh.ID).OreTotal)
case port == PortContRoom:
return sat32(tc.s.towOf(sh.ID).Room)
case port == PortContMass:
return sat32(tc.s.towOf(sh.ID).Mass)
case port == PortNearby:
return int32(tc.s.nearbyFree(sh))
case port >= PortContOre && port < PortContOre+8:
if port-PortContOre < uint16(world.NumOre) {
return sat32(tc.s.towOf(sh.ID).Ore[port-PortContOre])
}
return 0
case port >= PortStnRelX && port <= PortStnRelVZ:
@@ -170,21 +183,16 @@ func (b *shipBus) Out(port uint16, v int32) {
sh.Target = int64(v)
case PortMine:
sh.Mining = v != 0
case PortSell:
if v == 0 || !tc.s.canReach(sh, tc.stPos, tc.stVel, DockRangeKm) {
return
case PortAttach:
if v != 0 {
tc.s.attach(sh, tc)
}
value := tc.s.Market.Value(sh.Cargo)
if value == 0 {
return
case PortDetach:
if v != 0 {
tc.s.detach(sh, tc)
}
tc.s.Credits[sh.Owner] += value
sh.Earned += value
for o, kg := range sh.Cargo {
tc.sold[o] += kg
sh.Cargo[o] = 0
}
tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "sold", itoa(value) + " credits"})
case PortOrder:
tc.s.order(sh, int(v), tc)
case PortUplinkNew:
sh.UplinkNew = false
case PortMathX:
+289
View File
@@ -0,0 +1,289 @@
package sim
import (
"halcyon/config"
"halcyon/fixed"
"halcyon/world"
)
// Ore travels in containers. A ship has no hold of its own: it tows containers
// behind it, and every kilogram of them (the container and the ore in it) adds
// to the mass its engine has to push. Containers belong to no one: any ship can
// tow any container that is adrift near it.
const (
ContainerMassKg = 500 // an empty container
ContainerCapKg = 2000 // ore one container holds
ContainerRangeKm = 10 // "near the ship": how close a container must be to be attached
MaxOrder = 10 // empty containers the station delivers per request
MaxContainersPerPlayer = 50 // the station will not deliver past this many of a player's ordered containers
ContainerLifeDays = 10 // a container adrift this long vanishes, so lost ones cannot pile up
)
// Container is a free-floating body. It has no owner.
type Container struct {
ID int64
// OrderedBy is the player who had the station deliver it. It is bookkeeping for
// the ordering allowance only (MaxContainersPerPlayer): it gives no right to the
// container, which anyone may tow, and a sale ends it.
OrderedBy int64
Pos fixed.Vec
Vel fixed.Vec
Ore [world.NumOre]int64 // kg of each ore inside
Towed int64 // the ship towing it, or 0 while it drifts on its own
// Dropped is the ship that last let it go: when the container is sold, that ship
// and its player are paid.
Dropped int64
// ExpiresAt is when a container that stays adrift vanishes (seconds since the
// world began); 0 while it is in tow.
ExpiresAt int64
}
func (c *Container) OreTotal() int64 {
var t int64
for _, v := range c.Ore {
t += v
}
return t
}
// Mass is the container plus the ore in it, in kg.
func (c *Container) Mass() int64 { return ContainerMassKg + c.OreTotal() }
// Room is how much more ore fits, in kg.
func (c *Container) Room() int64 { return ContainerCapKg - c.OreTotal() }
// Tow summarises the containers a ship is towing.
type Tow struct {
Count int
Ore [world.NumOre]int64
OreTotal int64
Room int64
Mass int64
}
func (s *State) towOf(shipID int64) Tow {
var t Tow
for _, c := range s.Containers {
if c.Towed != shipID {
continue
}
t.Count++
for o, kg := range c.Ore {
t.Ore[o] += kg
}
t.OreTotal += c.OreTotal()
t.Room += c.Room()
t.Mass += c.Mass()
}
return t
}
// shipMass is everything the engine pushes: hull, fuel and towed containers.
func (s *State) shipMass(sh *Ship) fixed.F {
return sh.baseMass() + fixed.FromInt(s.towOf(sh.ID).Mass)
}
func (s *State) newContainer(orderedBy int64, pos, vel fixed.Vec) *Container {
s.NextContainer++
c := &Container{ID: s.NextContainer, OrderedBy: orderedBy, Pos: pos, Vel: vel}
s.Containers = append(s.Containers, c) // IDs only grow, so the slice stays sorted
return c
}
// canAttach reports whether container c is adrift and near enough to take in tow.
func (s *State) canAttach(sh *Ship, c *Container) bool {
return c.Towed == 0 && s.canReach(sh, c.Pos, c.Vel, ContainerRangeKm)
}
// nearbyFree counts the drifting containers the ship could attach now.
func (s *State) nearbyFree(sh *Ship) int {
n := 0
for _, c := range s.Containers {
if s.canAttach(sh, c) {
n++
}
}
return n
}
// attach takes every drifting container near the ship in tow.
func (s *State) attach(sh *Ship, tc *tickCtx) {
n := 0
for _, c := range s.Containers {
if s.canAttach(sh, c) {
c.Towed, c.Pos, c.Vel, c.ExpiresAt = sh.ID, sh.Pos, sh.Vel, 0
n++
}
}
if n > 0 {
tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "attach", itoa(int64(n)) + " containers"})
}
}
// detach lets go of everything the ship is towing. The containers keep drifting
// with the ship's velocity.
func (s *State) detach(sh *Ship, tc *tickCtx) {
n := 0
for _, c := range s.Containers {
if c.Towed == sh.ID {
s.letGo(c, sh.ID, tc.now)
n++
}
}
if n > 0 {
tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "detach", itoa(int64(n)) + " containers"})
}
}
// order has the station deliver up to n empty containers to the ship's position,
// provided the ship is close to the station. It returns how many were delivered.
func (s *State) order(sh *Ship, n int, tc *tickCtx) int {
if n > MaxOrder {
n = MaxOrder
}
if n <= 0 || sh.Pos.Sub(tc.stPos).Len() > fixed.FromInt(DockRangeKm) {
return 0
}
owned := 0
for _, c := range s.Containers {
if c.OrderedBy == sh.Owner {
owned++
}
}
n = min(n, MaxContainersPerPlayer-owned)
for i := 0; i < n; i++ {
c := s.newContainer(sh.Owner, sh.Pos, sh.Vel)
c.ExpiresAt = tc.now + ContainerLifeDays*config.SecondsPerDay // delivered adrift
}
if n > 0 {
tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "delivery", itoa(int64(n)) + " containers"})
}
return n
}
// fillTow pours mined ore into the ship's containers, first come first filled.
// The caller has checked that it all fits.
func (s *State) fillTow(shipID int64, taken [world.NumOre]int64) {
for o, kg := range taken {
for _, c := range s.Containers {
if kg == 0 {
break
}
if c.Towed != shipID {
continue
}
put := min(kg, c.Room())
c.Ore[o] += put
kg -= put
}
}
}
// syncTow keeps a ship's containers where the ship is.
func (s *State) syncTow(sh *Ship) {
for _, c := range s.Containers {
if c.Towed == sh.ID {
c.Pos, c.Vel = sh.Pos, sh.Vel
}
}
}
// releaseTow sets a lost ship's containers adrift.
func (s *State) releaseTow(sh *Ship, now int64) {
for _, c := range s.Containers {
if c.Towed == sh.ID {
s.letGo(c, sh.ID, now)
}
}
}
// letGo sets a towed container adrift and starts its ten days.
func (s *State) letGo(c *Container, ship, now int64) {
c.Towed, c.Dropped = 0, ship
c.ExpiresAt = now + ContainerLifeDays*config.SecondsPerDay
}
// expireContainers removes containers that have been adrift for their ten days,
// with whatever ore they hold. A container adrift with no timer at all (from a
// snapshot made before there was one) is given a full ten days from now.
func (s *State) expireContainers(tc *tickCtx) {
kept := s.Containers[:0]
for _, c := range s.Containers {
if c.Towed == 0 {
if c.ExpiresAt == 0 {
c.ExpiresAt = tc.now + ContainerLifeDays*config.SecondsPerDay
} else if tc.now >= c.ExpiresAt {
tc.res.Events = append(tc.res.Events, Event{tc.tick, c.Dropped, "expired", "container " + itoa(c.ID) + " adrift for " + itoa(ContainerLifeDays) + " days"})
continue
}
}
kept = append(kept, c)
}
s.Containers = kept
}
// sellNearStation buys every drifting container that has ore in it and is close
// to the station, at the tick's positions. The station takes the container along
// with the ore, so a sold container is gone. The ship that last let it go, and its
// player, are paid. Empty containers are not bought.
func (s *State) sellNearStation(tc *tickCtx) {
kept := s.Containers[:0]
for _, c := range s.Containers {
payee := s.ship(c.Dropped)
if c.Towed != 0 || c.OreTotal() == 0 || payee == nil || c.Pos.Sub(tc.stPos).Len() > fixed.FromInt(DockRangeKm) {
kept = append(kept, c)
continue
}
value := s.Market.Value(c.Ore)
s.Credits[payee.Owner] += value
payee.Earned += value
for o, kg := range c.Ore {
tc.sold[o] += kg
}
tc.res.Events = append(tc.res.Events, Event{tc.tick, payee.ID, "sold", itoa(value) + " credits (container " + itoa(c.ID) + " taken)"})
}
s.Containers = kept
}
// driftContainers moves the containers nobody is towing. The star pulls them
// exactly as it pulls ships; one that falls in is lost with its ore.
func (s *State) driftContainers(orbitSpeed fixed.F, dt int64, tick int, res *DayResult) {
dtF := fixed.FromInt(dt)
kept := s.Containers[:0]
for _, c := range s.Containers {
if c.Towed == 0 {
r := c.Pos.Len()
if r < fixed.FromInt(StarRadiusKm) {
res.Events = append(res.Events, Event{tick, c.Dropped, "lost", "container " + itoa(c.ID) + " fell into the star"})
continue
}
g := orbitSpeed.Mul(orbitSpeed).Mul(dtF).Div(r)
c.Vel = c.Vel.Sub(c.Pos.Unit().Scale(g))
c.Pos = c.Pos.Add(c.Vel.Scale(dtF))
}
kept = append(kept, c)
}
s.Containers = kept
}
// migrateLegacyCargo turns ore that a ship still carries in the cargo hold of
// the earlier design into towed containers, so nothing is lost across the change.
func (s *State) migrateLegacyCargo() {
for _, sh := range s.Ships {
var left int64
for _, kg := range sh.Cargo {
left += kg
}
for left > 0 {
c := s.newContainer(0, sh.Pos, sh.Vel) // not counted against anyone's ordering allowance
c.Towed = sh.ID
for o := range sh.Cargo {
put := min(sh.Cargo[o], c.Room())
c.Ore[o] += put
sh.Cargo[o] -= put
left -= put
}
}
sh.Cargo = [world.NumOre]int64{}
}
}
+758
View File
@@ -0,0 +1,758 @@
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")
}
}
}
+8 -3
View File
@@ -34,11 +34,16 @@ func (s *State) Hash() [32]byte {
w(sh.ID, sh.Owner, alive, int64(sh.Pos.X), int64(sh.Pos.Y), int64(sh.Pos.Z),
int64(sh.Vel.X), int64(sh.Vel.Y), int64(sh.Vel.Z),
int64(sh.Fuel), sh.Earned, int64(sh.Throttle), int64(sh.Azimuth), int64(sh.Pitch), sh.Target)
for _, c := range sh.Cargo {
w(c)
}
h.Write(sh.CPU.MarshalState())
}
w(int64(len(s.Containers)), s.NextContainer)
for _, c := range s.Containers {
w(c.ID, c.OrderedBy, int64(c.Pos.X), int64(c.Pos.Y), int64(c.Pos.Z),
int64(c.Vel.X), int64(c.Vel.Y), int64(c.Vel.Z), c.Towed, c.Dropped, c.ExpiresAt)
for _, o := range c.Ore {
w(o)
}
}
owners := make([]int64, 0, len(s.Credits))
for o := range s.Credits {
owners = append(owners, o)
+10 -5
View File
@@ -37,11 +37,16 @@ const (
PortScanDist = 0x38 // in: distance to target, km
PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports)
// Mining laser and cargo hold.
// Mining laser and the containers in tow.
PortMine = 0x50 // out: 1 to mine the selected asteroid, 0 to stop
PortCargo = 0x51 // in: total cargo kg
PortCargoCap = 0x52 // in: cargo capacity kg
PortCargoOre = 0x58 // in: PortCargoOre+ore = kg of ore carried (8 ports)
PortConts = 0x51 // in: number of containers in tow
PortContKg = 0x52 // in: kg of ore in the containers in tow
PortContRoom = 0x53 // in: kg of ore the containers in tow can still take
PortContMass = 0x54 // in: mass of the containers in tow, ore included, kg
PortAttach = 0x55 // out: non-zero: take every one of your containers near the ship in tow
PortDetach = 0x56 // out: non-zero: let go of every container in tow
PortNearby = 0x57 // in: how many of your drifting containers are near enough to attach
PortContOre = 0x58 // in: PortContOre+ore = kg of that ore in the containers in tow (8 ports)
// Dropoff station.
PortStnRelX = 0x60 // in: station position relative to ship, km
@@ -50,7 +55,7 @@ const (
PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s
PortStnRelVY = 0x64
PortStnRelVZ = 0x65
PortSell = 0x66 // out: 1 to sell all cargo (needs to be docked)
PortOrder = 0x66 // out: 1..10: the station delivers that many empty containers to the ship (near the station)
PortCredits = 0x67 // in: credits earned by this ship (saturating)
// Comms buffers live in RAM; these ports coordinate them.
+66 -37
View File
@@ -7,6 +7,7 @@ import (
"halcyon/config"
"halcyon/fixed"
"halcyon/vm"
"halcyon/world"
)
func testCfg() config.Config {
@@ -147,43 +148,6 @@ func TestInclinedOrbitLeavesPlane(t *testing.T) {
}
}
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)
@@ -209,3 +173,68 @@ func TestCommsBuffers(t *testing.T) {
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")
}
}
+19 -21
View File
@@ -16,7 +16,6 @@ import (
type Hull struct {
DryMass int64 // kg
FuelCap int64 // kg
CargoCap int64 // kg
Thrust int64 // newtons at full throttle
ExhaustVel int64 // m/s
MineRate int64 // kg mined per tick
@@ -26,7 +25,6 @@ type Hull struct {
var CommandShip = Hull{
DryMass: 8000,
FuelCap: 4000,
CargoCap: 6000,
Thrust: 6000,
ExhaustVel: 30000,
MineRate: 10,
@@ -35,18 +33,21 @@ var CommandShip = Hull{
// Docking / mining limits.
const (
MineRangeKm = 5 // max distance to an asteroid for mining
DockRangeKm = 20 // max distance to the station for selling
DockRangeKm = 20 // how close counts as "near the station"
MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking
StarRadiusKm = 200_000
ScanRangeKm = 500_000 // the scanner sees nothing farther away than this
)
type Ship struct {
ID int64
Owner int64
Hull Hull
Pos fixed.Vec
Vel fixed.Vec
Fuel fixed.F
ID int64
Owner int64
Hull Hull
Pos fixed.Vec
Vel fixed.Vec
Fuel fixed.F
// Cargo is the hold of the earlier design, before ore travelled in containers.
// It is only read to convert what old ships still carry (migrateLegacyCargo).
Cargo [world.NumOre]int64
CPU *vm.CPU
Alive bool
@@ -65,16 +66,9 @@ type Ship struct {
MathZ int32
}
func (s *Ship) CargoTotal() int64 {
var t int64
for _, v := range s.Cargo {
t += v
}
return t
}
func (s *Ship) Mass() fixed.F {
return fixed.FromInt(s.Hull.DryMass+s.CargoTotal()) + s.Fuel
// baseMass is the hull and its fuel; towed containers add to it (State.shipMass).
func (s *Ship) baseMass() fixed.F {
return fixed.FromInt(s.Hull.DryMass) + s.Fuel
}
// State is everything that persists between daily runs.
@@ -83,8 +77,11 @@ type State struct {
Asteroids []world.Asteroid // sorted by ID
Station world.Station
Ships []*Ship // sorted by ID
Credits map[int64]int64
Market market.Market
// Containers are sorted by ID; NextContainer is the last ID handed out.
Containers []*Container
NextContainer int64
Credits map[int64]int64
Market market.Market
}
// NewState builds the initial world from the config seed.
@@ -123,6 +120,7 @@ type Uplink struct {
type DayInput struct {
Launches []Launch // processed in slice order (must be deterministic)
Uplinks []Uplink
Scraps []int64 // ships to remove from the world before the day starts, in slice order
}
// Downlink is the content of a ship's transmit buffer at the end of the day.
+32 -7
View File
@@ -37,6 +37,22 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
res.Events = append(res.Events, Event{0, l.ShipID, "launch", ""})
}
sort.Slice(s.Ships, func(i, j int) bool { return s.Ships[i].ID < s.Ships[j].ID })
s.migrateLegacyCargo()
// Scrapped ships vanish; whatever they were towing is left adrift where it is.
for _, id := range in.Scraps {
for i, sh := range s.Ships {
if sh.ID != id {
continue
}
if sh.Alive {
s.releaseTow(sh, dayStart)
}
s.Ships = append(s.Ships[:i], s.Ships[i+1:]...)
res.Events = append(res.Events, Event{0, id, "scrapped", ""})
break
}
}
// Uplinks land in the RX buffer at the start of RAM.
for _, u := range in.Uplinks {
@@ -57,16 +73,24 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
now := dayStart + int64(tick)*dt
stPos, stVel = s.Station.Orbit.State(now)
tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold)
s.sellNearStation(tc) // ore in containers left near the station is sold...
s.expireContainers(tc) // ...and containers adrift for ten days vanish
for _, sh := range s.Ships {
if !sh.Alive {
continue
}
sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick)
s.physics(cfg, sh, dt, tick, &res)
if sh.Alive && sh.Mining {
s.mine(sh, tc.now+dt)
if sh.Alive {
s.syncTow(sh)
if sh.Mining {
s.mine(sh, tc.now+dt)
}
} else {
s.releaseTow(sh, tc.now)
}
}
s.driftContainers(cfg.OrbitSpeed, dt, tick, &res)
}
for _, sh := range s.Ships {
@@ -109,7 +133,7 @@ func (s *State) physics(cfg config.Config, sh *Ship, dt int64, tick int, res *Da
burn = sh.Fuel
}
// dv (km/s) = thrust*dt / mass / 1000.
dv := fixed.FromInt(thrust * dt).Div(sh.Mass()).DivInt(1000)
dv := fixed.FromInt(thrust * dt).Div(s.shipMass(sh)).DivInt(1000)
sh.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv))
sh.Fuel -= burn
}
@@ -132,8 +156,9 @@ func (s *State) canReach(sh *Ship, pos, vel fixed.Vec, rangeKm int64) bool {
relV.Len() <= fixed.FromRatio(MaxRelSpeedM, 1000)
}
// mine moves ore from the target asteroid into the ship's hold, split
// proportionally to the asteroid's composition. at is the absolute time (s)
// mine moves ore from the target asteroid into the containers the ship is towing,
// split proportionally to the asteroid's composition. With no container in tow, or
// none with room, nothing is mined. at is the absolute time (s)
// at which range is evaluated.
func (s *State) mine(sh *Ship, at int64) {
a := s.asteroid(sh.Target)
@@ -141,7 +166,7 @@ func (s *State) mine(sh *Ship, at int64) {
return
}
total := a.TotalOre()
room := sh.Hull.CargoCap - sh.CargoTotal()
room := s.towOf(sh.ID).Room
amt := min(sh.Hull.MineRate, room, total)
if amt <= 0 {
return
@@ -163,8 +188,8 @@ func (s *State) mine(sh *Ship, at int64) {
}
for o, kg := range taken {
a.Ore[o] -= kg
sh.Cargo[o] += kg
}
s.fillTow(sh.ID, taken)
}
// thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector.