init
This commit is contained in:
+219
@@ -0,0 +1,219 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/world"
|
||||
)
|
||||
|
||||
// tickCtx is per-tick shared context; asteroid positions are computed lazily
|
||||
// and cached because several ships may query them in one tick.
|
||||
type tickCtx struct {
|
||||
s *State
|
||||
cfg config.Config
|
||||
tick int
|
||||
now int64
|
||||
stPos fixed.Vec
|
||||
stVel fixed.Vec
|
||||
res *DayResult
|
||||
sold *[world.NumOre]int64
|
||||
|
||||
astPos, astVel []fixed.Vec
|
||||
astDone []bool
|
||||
}
|
||||
|
||||
func newTickCtx(s *State, cfg config.Config, tick int, now int64, stPos, stVel fixed.Vec, res *DayResult, sold *[world.NumOre]int64) *tickCtx {
|
||||
n := len(s.Asteroids)
|
||||
return &tickCtx{s: s, cfg: cfg, tick: tick, now: now, stPos: stPos, stVel: stVel, res: res, sold: sold,
|
||||
astPos: make([]fixed.Vec, n), astVel: make([]fixed.Vec, n), astDone: make([]bool, n)}
|
||||
}
|
||||
|
||||
func (tc *tickCtx) asteroid(i int) (fixed.Vec, fixed.Vec) {
|
||||
if !tc.astDone[i] {
|
||||
tc.astPos[i], tc.astVel[i] = tc.s.Asteroids[i].Orbit.State(tc.now)
|
||||
tc.astDone[i] = true
|
||||
}
|
||||
return tc.astPos[i], tc.astVel[i]
|
||||
}
|
||||
|
||||
// shipBus is one ship's view of its peripherals for one tick.
|
||||
type shipBus struct {
|
||||
tc *tickCtx
|
||||
sh *Ship
|
||||
}
|
||||
|
||||
func sat32(v int64) int32 {
|
||||
if v > 1<<31-1 {
|
||||
return 1<<31 - 1
|
||||
}
|
||||
if v < -1<<31 {
|
||||
return -1 << 31
|
||||
}
|
||||
return int32(v)
|
||||
}
|
||||
|
||||
func kmInt(f fixed.F) int32 { return sat32(f.Floor()) }
|
||||
|
||||
// mps converts km/s to whole m/s.
|
||||
func mps(f fixed.F) int32 { return sat32(f.MulInt(1000).Floor()) }
|
||||
|
||||
func (b *shipBus) target() (pos, vel fixed.Vec, ok bool) {
|
||||
id := b.sh.Target
|
||||
if id < 1 || id > int64(len(b.tc.s.Asteroids)) {
|
||||
return pos, vel, false
|
||||
}
|
||||
pos, vel = b.tc.asteroid(int(id - 1))
|
||||
return pos, vel, true
|
||||
}
|
||||
|
||||
// axis returns component (port-base) of v (0=X, 1=Y, 2=Z) using conv, for
|
||||
// ports laid out as three consecutive X, Y, Z registers.
|
||||
func axis(v fixed.Vec, port, base uint16, conv func(fixed.F) int32) int32 {
|
||||
switch port - base {
|
||||
case 0:
|
||||
return conv(v.X)
|
||||
case 1:
|
||||
return conv(v.Y)
|
||||
}
|
||||
return conv(v.Z)
|
||||
}
|
||||
|
||||
func (b *shipBus) In(port uint16) int32 {
|
||||
sh, tc := b.sh, b.tc
|
||||
switch {
|
||||
case port == PortTick:
|
||||
return int32(tc.tick)
|
||||
case port == PortDay:
|
||||
return sat32(tc.s.Day)
|
||||
case port == PortTicks:
|
||||
return int32(tc.cfg.TicksPerDay)
|
||||
case port >= PortPosX && port <= PortPosZ:
|
||||
return axis(sh.Pos, port, PortPosX, kmInt)
|
||||
case port >= PortVelX && port <= PortVelZ:
|
||||
return axis(sh.Vel, port, PortVelX, mps)
|
||||
case port == PortFuel:
|
||||
return sat32(sh.Fuel.Floor())
|
||||
case port == PortMass:
|
||||
return sat32(sh.Mass().Floor())
|
||||
case port == PortScanNearest:
|
||||
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 {
|
||||
best, bestD = int64(i+1), d
|
||||
}
|
||||
}
|
||||
return int32(best)
|
||||
case port >= PortScanRelX && port <= PortScanDist:
|
||||
p, v, ok := b.target()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel)
|
||||
switch {
|
||||
case port <= PortScanRelZ:
|
||||
return axis(rel, port, PortScanRelX, kmInt)
|
||||
case port <= PortScanRelVZ:
|
||||
return axis(relV, port, PortScanRelVX, mps)
|
||||
}
|
||||
return kmInt(rel.Len())
|
||||
case port >= PortScanOre && port < PortScanOre+8:
|
||||
if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) {
|
||||
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])
|
||||
}
|
||||
return 0
|
||||
case port >= PortStnRelX && port <= PortStnRelVZ:
|
||||
rel, relV := tc.stPos.Sub(sh.Pos), tc.stVel.Sub(sh.Vel)
|
||||
if port <= PortStnRelZ {
|
||||
return axis(rel, port, PortStnRelX, kmInt)
|
||||
}
|
||||
return axis(relV, port, PortStnRelVX, mps)
|
||||
case port == PortCredits:
|
||||
return sat32(sh.Earned)
|
||||
case port == PortUplinkNew:
|
||||
if sh.UplinkNew {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case port == PortUplinkLen:
|
||||
return sh.UplinkLen
|
||||
case port == PortMathAtan2:
|
||||
a := fixed.Atan2(fixed.FromInt(int64(sh.MathY)), fixed.FromInt(int64(sh.MathX)))
|
||||
return sat32(a.MulInt(1000).Floor())
|
||||
case port == PortMathHypot:
|
||||
return sat32(fixed.Hypot(fixed.FromInt(int64(sh.MathX)), fixed.FromInt(int64(sh.MathY))).Floor())
|
||||
case port == PortMathNorm3:
|
||||
return sat32(fixed.Norm3(fixed.FromInt(int64(sh.MathX)), fixed.FromInt(int64(sh.MathY)), fixed.FromInt(int64(sh.MathZ))).Floor())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (b *shipBus) Out(port uint16, v int32) {
|
||||
sh, tc := b.sh, b.tc
|
||||
switch port {
|
||||
case PortThrottle:
|
||||
sh.Throttle = v
|
||||
case PortAzimuth:
|
||||
sh.Azimuth = v
|
||||
case PortPitch:
|
||||
sh.Pitch = v
|
||||
case PortScanSelect:
|
||||
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
|
||||
}
|
||||
value := tc.s.Market.Value(sh.Cargo)
|
||||
if value == 0 {
|
||||
return
|
||||
}
|
||||
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 PortUplinkNew:
|
||||
sh.UplinkNew = false
|
||||
case PortMathX:
|
||||
sh.MathX = v
|
||||
case PortMathY:
|
||||
sh.MathY = v
|
||||
case PortMathZ:
|
||||
sh.MathZ = v
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for v > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Hash returns a digest of the complete simulation state. Two runs from the
|
||||
// same seed and inputs must produce identical hashes.
|
||||
func (s *State) Hash() [32]byte {
|
||||
h := sha256.New()
|
||||
w := func(vs ...int64) {
|
||||
var b [8]byte
|
||||
for _, v := range vs {
|
||||
binary.LittleEndian.PutUint64(b[:], uint64(v))
|
||||
h.Write(b[:])
|
||||
}
|
||||
}
|
||||
w(s.Day, int64(len(s.Asteroids)))
|
||||
for i := range s.Asteroids {
|
||||
a := &s.Asteroids[i]
|
||||
w(a.ID)
|
||||
for _, o := range a.Ore {
|
||||
w(o)
|
||||
}
|
||||
}
|
||||
w(int64(len(s.Ships)))
|
||||
for _, sh := range s.Ships {
|
||||
alive := int64(0)
|
||||
if sh.Alive {
|
||||
alive = 1
|
||||
}
|
||||
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())
|
||||
}
|
||||
owners := make([]int64, 0, len(s.Credits))
|
||||
for o := range s.Credits {
|
||||
owners = append(owners, o)
|
||||
}
|
||||
sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] })
|
||||
for _, o := range owners {
|
||||
w(o, s.Credits[o])
|
||||
}
|
||||
for _, v := range s.Market.Supply {
|
||||
w(v)
|
||||
}
|
||||
var out [32]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package sim
|
||||
|
||||
// Peripheral port numbers. Values are int32; positions are kilometres,
|
||||
// velocities metres/second, angles milliradians, masses kilograms. Axes are
|
||||
// right-handed with Z perpendicular to the ecliptic; azimuth is measured from
|
||||
// +X towards +Y and elevation (pitch) up from the XY plane towards +Z.
|
||||
const (
|
||||
// System.
|
||||
PortTick = 0x00 // in: tick within the current day
|
||||
PortDay = 0x01 // in: day number
|
||||
PortTicks = 0x02 // in: ticks per day
|
||||
|
||||
// Navigation (relative to the star).
|
||||
PortPosX = 0x10
|
||||
PortPosY = 0x11
|
||||
PortPosZ = 0x12
|
||||
PortVelX = 0x13
|
||||
PortVelY = 0x14
|
||||
PortVelZ = 0x15
|
||||
|
||||
// Engine.
|
||||
PortThrottle = 0x20 // out: 0..1000 permille
|
||||
PortAzimuth = 0x21 // out: thrust direction azimuth, milliradians
|
||||
PortPitch = 0x22 // out: thrust direction elevation, milliradians
|
||||
PortFuel = 0x23 // in: fuel kg
|
||||
PortMass = 0x24 // in: total mass kg
|
||||
|
||||
// Scanner.
|
||||
PortScanSelect = 0x30 // out: asteroid id to track (0 clears)
|
||||
PortScanNearest = 0x31 // in: id of nearest asteroid
|
||||
PortScanRelX = 0x32 // in: target position relative to ship, km
|
||||
PortScanRelY = 0x33
|
||||
PortScanRelZ = 0x34
|
||||
PortScanRelVX = 0x35 // in: target velocity relative to ship, m/s
|
||||
PortScanRelVY = 0x36
|
||||
PortScanRelVZ = 0x37
|
||||
PortScanDist = 0x38 // in: distance to target, km
|
||||
PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports)
|
||||
|
||||
// Mining laser and cargo hold.
|
||||
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)
|
||||
|
||||
// Dropoff station.
|
||||
PortStnRelX = 0x60 // in: station position relative to ship, km
|
||||
PortStnRelY = 0x61
|
||||
PortStnRelZ = 0x62
|
||||
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)
|
||||
PortCredits = 0x67 // in: credits earned by this ship (saturating)
|
||||
|
||||
// Comms buffers live in RAM; these ports coordinate them.
|
||||
PortUplinkNew = 0x70 // in: 1 if an uplink arrived this day; out: any value acknowledges
|
||||
PortUplinkLen = 0x71 // in: uplink length in bytes
|
||||
|
||||
// Math coprocessor. Write operands, then read a result.
|
||||
PortMathX = 0x80 // out
|
||||
PortMathY = 0x81 // out
|
||||
PortMathZ = 0x82 // out
|
||||
PortMathAtan2 = 0x83 // in: atan2(y, x) in milliradians
|
||||
PortMathHypot = 0x84 // in: sqrt(x*x + y*y)
|
||||
PortMathNorm3 = 0x85 // in: sqrt(x*x + y*y + z*z)
|
||||
)
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/vm"
|
||||
)
|
||||
|
||||
func testCfg() config.Config {
|
||||
c := config.Default()
|
||||
c.AsteroidCount = 20
|
||||
return c
|
||||
}
|
||||
|
||||
func mustAsm(t *testing.T, src string) []byte {
|
||||
t.Helper()
|
||||
p, err := vm.Assemble(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func addShip(t *testing.T, s *State, cfg config.Config, id int64, prog []byte, pos, vel fixed.Vec) *Ship {
|
||||
t.Helper()
|
||||
cpu, err := vm.New(prog, cfg.RAMBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sh := &Ship{ID: id, Owner: 1, Hull: CommandShip, Pos: pos, Vel: vel,
|
||||
Fuel: fixed.FromInt(CommandShip.FuelCap), CPU: cpu, Alive: true}
|
||||
s.Ships = append(s.Ships, sh)
|
||||
return sh
|
||||
}
|
||||
|
||||
const burner = `
|
||||
ldi r0, 1000
|
||||
out 0x20, r0 ; full throttle
|
||||
ldi r0, 500
|
||||
out 0x21, r0 ; azimuth 0.5 rad
|
||||
ldi r0, 300
|
||||
out 0x22, r0 ; pitch 0.3 rad (out of the ecliptic plane)
|
||||
loop:
|
||||
yield
|
||||
jmp loop
|
||||
`
|
||||
|
||||
func TestDeterministic(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
run := func() [32]byte {
|
||||
s := NewState(cfg)
|
||||
in := DayInput{
|
||||
Launches: []Launch{{ShipID: 1, Owner: 1, Program: mustAsm(t, burner)}},
|
||||
Uplinks: []Uplink{{ShipID: 1, Data: []byte("hello")}},
|
||||
}
|
||||
var res DayResult
|
||||
var err error
|
||||
for d := 0; d < 3; d++ {
|
||||
res, err = s.RunDay(cfg, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in = DayInput{}
|
||||
}
|
||||
return res.Hash
|
||||
}
|
||||
if a, b := run(), run(); a != b {
|
||||
t.Fatal("simulation is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircularOrbitHolds(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
pos, vel := s.Station.Orbit.State(0)
|
||||
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel)
|
||||
r0 := pos.Len().Float64()
|
||||
for d := 0; d < 5; d++ {
|
||||
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := sh.Pos.Len().Float64()
|
||||
if !sh.Alive || r < r0*0.99 || r > r0*1.01 {
|
||||
t.Fatalf("radius drifted from %.0f to %.0f km", r0, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBurnUsesFuelAndChangesVelocity(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
pos, vel := s.Station.Orbit.State(0)
|
||||
sh := addShip(t, s, cfg, 1, mustAsm(t, burner), pos, vel)
|
||||
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sh.Fuel >= fixed.FromInt(CommandShip.FuelCap) || sh.Fuel < 0 {
|
||||
t.Fatalf("fuel = %v", sh.Fuel.Float64())
|
||||
}
|
||||
dv := sh.Vel.Sub(vel)
|
||||
if dv.Len() < fixed.FromRatio(1, 10) {
|
||||
t.Fatalf("velocity barely changed: %v", dv.Len().Float64())
|
||||
}
|
||||
// Thrust at 0.3 rad pitch must push the ship out of the ecliptic: the
|
||||
// station's orbit is in-plane and gravity is central, so any Z velocity
|
||||
// comes from the engine.
|
||||
if dv.Z <= 0 {
|
||||
t.Fatalf("expected upward velocity change, got dz=%v", dv.Z.Float64())
|
||||
}
|
||||
// The burn direction should match azimuth 0.5 / pitch 0.3 (gravity is
|
||||
// small next to a ~10 km/s burn).
|
||||
wantZ := math.Sin(0.3)
|
||||
if got := dv.Z.Float64() / dv.Len().Float64(); math.Abs(got-wantZ) > 0.05 {
|
||||
t.Fatalf("burn elevation: sin = %.3f, want %.3f", got, wantZ)
|
||||
}
|
||||
wantAz := 0.5
|
||||
if got := math.Atan2(dv.Y.Float64(), dv.X.Float64()); math.Abs(got-wantAz) > 0.05 {
|
||||
t.Fatalf("burn azimuth = %.3f, want %.3f", got, wantAz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInclinedOrbitLeavesPlane(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
// Find an asteroid with a noticeable inclination and confirm a ship on its
|
||||
// orbit stays on a plane that is not the ecliptic.
|
||||
var best int
|
||||
for i := range s.Asteroids {
|
||||
if s.Asteroids[i].Orbit.Inc > s.Asteroids[best].Orbit.Inc {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
o := s.Asteroids[best].Orbit
|
||||
if o.Inc < fixed.FromRatio(1, 20) {
|
||||
t.Skip("no inclined asteroid in this seed")
|
||||
}
|
||||
var maxZ fixed.F
|
||||
for k := int64(0); k < 20; k++ {
|
||||
p, _ := o.State(k * o.Period / 20)
|
||||
maxZ = fixed.Max2(maxZ, p.Z.Abs())
|
||||
}
|
||||
if maxZ < fixed.FromInt(1000) {
|
||||
t.Fatalf("inclined orbit never left the plane: max |z| = %v km", maxZ.Float64())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMineAndSell(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
// Sit on asteroid 1, matching its motion for the whole first tick, and mine.
|
||||
pos, vel := s.Asteroids[0].Orbit.State(0)
|
||||
sh := addShip(t, s, cfg, 1, mustAsm(t, `
|
||||
ldi r0, 1
|
||||
out 0x30, r0 ; select asteroid 1
|
||||
out 0x50, r0 ; mine
|
||||
yield
|
||||
jmp -2
|
||||
`), pos, vel)
|
||||
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gravity acts on the ship differently than the rails, so it soon drifts
|
||||
// out of range; it should still have mined something at the start.
|
||||
if sh.CargoTotal() == 0 {
|
||||
t.Fatal("nothing mined")
|
||||
}
|
||||
|
||||
// Now dock at the station and sell.
|
||||
s2 := NewState(cfg)
|
||||
sp, sv := s2.Station.Orbit.State(0)
|
||||
sh2 := addShip(t, s2, cfg, 1, mustAsm(t, "ldi r0, 1\n out 0x66, r0\n halt"), sp, sv)
|
||||
sh2.Cargo[0] = 1000 // iron
|
||||
if _, err := s2.RunDay(cfg, DayInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s2.Credits[1] != 2000 || sh2.CargoTotal() != 0 {
|
||||
t.Fatalf("credits=%d cargo=%d", s2.Credits[1], sh2.CargoTotal())
|
||||
}
|
||||
if s2.Market.Prices[0] >= 2 && s2.Market.Supply[0] != 1000 {
|
||||
t.Fatalf("market not updated: %+v", s2.Market)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommsBuffers(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
pos, vel := s.Station.Orbit.State(0)
|
||||
// Copy the first uplink word into the TX buffer, then ack.
|
||||
prog := mustAsm(t, `
|
||||
.equ TX 1024
|
||||
in r1, 0x70
|
||||
ldi r2, 0
|
||||
beq r1, r2, done
|
||||
ldw r3, [r2]
|
||||
stw r3, [r2+TX]
|
||||
out 0x70, r1
|
||||
done:
|
||||
halt
|
||||
`)
|
||||
addShip(t, s, cfg, 1, prog, pos, vel)
|
||||
res, err := s.RunDay(cfg, DayInput{Uplinks: []Uplink{{ShipID: 1, Data: []byte("PING")}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Downlinks) != 1 || string(res.Downlinks[0].Data[:4]) != "PING" {
|
||||
t.Fatalf("downlink = %q", res.Downlinks)
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// Package sim is the deterministic core: given a State, the day's inputs and
|
||||
// a Config it advances the world by one day. It performs no I/O.
|
||||
package sim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/market"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
)
|
||||
|
||||
// Hull describes the physical properties of a ship design.
|
||||
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
|
||||
}
|
||||
|
||||
// CommandShip is the only hull for now.
|
||||
var CommandShip = Hull{
|
||||
DryMass: 8000,
|
||||
FuelCap: 4000,
|
||||
CargoCap: 6000,
|
||||
Thrust: 6000,
|
||||
ExhaustVel: 30000,
|
||||
MineRate: 10,
|
||||
}
|
||||
|
||||
// Docking / mining limits.
|
||||
const (
|
||||
MineRangeKm = 5 // max distance to an asteroid for mining
|
||||
DockRangeKm = 20 // max distance to the station for selling
|
||||
MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking
|
||||
StarRadiusKm = 200_000
|
||||
)
|
||||
|
||||
type Ship struct {
|
||||
ID int64
|
||||
Owner int64
|
||||
Hull Hull
|
||||
Pos fixed.Vec
|
||||
Vel fixed.Vec
|
||||
Fuel fixed.F
|
||||
Cargo [world.NumOre]int64
|
||||
CPU *vm.CPU
|
||||
Alive bool
|
||||
Earned int64 // credits earned so far
|
||||
|
||||
// Peripheral state.
|
||||
Throttle int32
|
||||
Azimuth int32
|
||||
Pitch int32
|
||||
Target int64
|
||||
Mining bool
|
||||
UplinkNew bool
|
||||
UplinkLen int32
|
||||
MathX int32
|
||||
MathY int32
|
||||
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
|
||||
}
|
||||
|
||||
// State is everything that persists between daily runs.
|
||||
type State struct {
|
||||
Day int64
|
||||
Asteroids []world.Asteroid // sorted by ID
|
||||
Station world.Station
|
||||
Ships []*Ship // sorted by ID
|
||||
Credits map[int64]int64
|
||||
Market market.Market
|
||||
}
|
||||
|
||||
// NewState builds the initial world from the config seed.
|
||||
func NewState(cfg config.Config) *State {
|
||||
asts, st := world.Generate(cfg)
|
||||
return &State{
|
||||
Asteroids: asts,
|
||||
Station: st,
|
||||
Credits: map[int64]int64{},
|
||||
Market: market.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) asteroid(id int64) *world.Asteroid {
|
||||
// Asteroids are numbered 1..N in slice order.
|
||||
if id >= 1 && id <= int64(len(s.Asteroids)) {
|
||||
return &s.Asteroids[id-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Launch describes a ship entering the belt at the start of a day.
|
||||
type Launch struct {
|
||||
ShipID int64
|
||||
Owner int64
|
||||
Program []byte
|
||||
}
|
||||
|
||||
// Uplink is a message delivered to a ship's comm buffer at the start of a day.
|
||||
type Uplink struct {
|
||||
ShipID int64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// DayInput is everything external the simulation consumes for one day.
|
||||
type DayInput struct {
|
||||
Launches []Launch // processed in slice order (must be deterministic)
|
||||
Uplinks []Uplink
|
||||
}
|
||||
|
||||
// Downlink is the content of a ship's transmit buffer at the end of the day.
|
||||
type Downlink struct {
|
||||
ShipID int64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Tick int
|
||||
ShipID int64
|
||||
Kind string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type DayResult struct {
|
||||
Day int64
|
||||
Downlinks []Downlink
|
||||
Events []Event
|
||||
Hash [32]byte
|
||||
}
|
||||
|
||||
func (e Event) String() string {
|
||||
return fmt.Sprintf("t%04d ship %d %s %s", e.Tick, e.ShipID, e.Kind, e.Detail)
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"wh/config"
|
||||
"wh/fixed"
|
||||
"wh/vm"
|
||||
"wh/world"
|
||||
)
|
||||
|
||||
// RunDay simulates one full day, mutating the state, and returns the result.
|
||||
func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return DayResult{}, err
|
||||
}
|
||||
res := DayResult{Day: s.Day}
|
||||
dayStart := s.Day * config.SecondsPerDay
|
||||
dt := int64(cfg.TickSeconds())
|
||||
|
||||
// Launches: ships appear at the station, matching its velocity.
|
||||
stPos, stVel := s.Station.Orbit.State(dayStart)
|
||||
for _, l := range in.Launches {
|
||||
if len(l.Program) > cfg.ProgramBytes {
|
||||
return res, fmt.Errorf("ship %d: program of %d bytes exceeds limit %d", l.ShipID, len(l.Program), cfg.ProgramBytes)
|
||||
}
|
||||
cpu, err := vm.New(l.Program, cfg.RAMBytes)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("ship %d: %w", l.ShipID, err)
|
||||
}
|
||||
s.Ships = append(s.Ships, &Ship{
|
||||
ID: l.ShipID, Owner: l.Owner, Hull: CommandShip,
|
||||
Pos: stPos, Vel: stVel, Fuel: fixed.FromInt(CommandShip.FuelCap),
|
||||
CPU: cpu, Alive: true,
|
||||
})
|
||||
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 })
|
||||
|
||||
// Uplinks land in the RX buffer at the start of RAM.
|
||||
for _, u := range in.Uplinks {
|
||||
sh := s.ship(u.ShipID)
|
||||
if sh == nil || !sh.Alive {
|
||||
continue
|
||||
}
|
||||
n := len(u.Data)
|
||||
if n > cfg.CommBytes {
|
||||
n = cfg.CommBytes
|
||||
}
|
||||
copy(sh.CPU.RAM[:cfg.CommBytes], u.Data[:n])
|
||||
sh.UplinkNew, sh.UplinkLen = true, int32(n)
|
||||
}
|
||||
|
||||
var sold [world.NumOre]int64
|
||||
for tick := 0; tick < cfg.TicksPerDay; tick++ {
|
||||
now := dayStart + int64(tick)*dt
|
||||
stPos, stVel = s.Station.Orbit.State(now)
|
||||
tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sh := range s.Ships {
|
||||
if sh.Alive {
|
||||
res.Downlinks = append(res.Downlinks, Downlink{
|
||||
ShipID: sh.ID,
|
||||
Data: append([]byte(nil), sh.CPU.RAM[cfg.CommBytes:2*cfg.CommBytes]...),
|
||||
})
|
||||
}
|
||||
sh.UplinkNew = false
|
||||
}
|
||||
s.Market.EndOfDay(sold)
|
||||
s.Day++
|
||||
res.Hash = s.Hash()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *State) ship(id int64) *Ship {
|
||||
i := sort.Search(len(s.Ships), func(i int) bool { return s.Ships[i].ID >= id })
|
||||
if i < len(s.Ships) && s.Ships[i].ID == id {
|
||||
return s.Ships[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// physics applies gravity and engine thrust for one tick (semi-implicit Euler).
|
||||
func (s *State) physics(cfg config.Config, sh *Ship, dt int64, tick int, res *DayResult) {
|
||||
dtF := fixed.FromInt(dt)
|
||||
// Engine.
|
||||
if sh.Throttle > 0 && sh.Fuel > 0 {
|
||||
th := int64(sh.Throttle)
|
||||
if th > 1000 {
|
||||
th = 1000
|
||||
}
|
||||
thrust := sh.Hull.Thrust * th / 1000
|
||||
burn := fixed.FromInt(thrust * dt).Div(fixed.FromInt(sh.Hull.ExhaustVel))
|
||||
if burn > sh.Fuel {
|
||||
// Partial burn: scale the impulse by the remaining fuel.
|
||||
thrust = thrust * int64(sh.Fuel) / int64(burn)
|
||||
burn = sh.Fuel
|
||||
}
|
||||
// dv (km/s) = thrust*dt / mass / 1000.
|
||||
dv := fixed.FromInt(thrust * dt).Div(sh.Mass()).DivInt(1000)
|
||||
sh.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv))
|
||||
sh.Fuel -= burn
|
||||
}
|
||||
// The star pulls with acceleration v^2/r, so dv = v*v*dt/r toward it.
|
||||
r := sh.Pos.Len()
|
||||
if r < fixed.FromInt(StarRadiusKm) {
|
||||
sh.Alive = false
|
||||
res.Events = append(res.Events, Event{tick, sh.ID, "destroyed", "fell into the star"})
|
||||
return
|
||||
}
|
||||
g := cfg.OrbitSpeed.Mul(cfg.OrbitSpeed).Mul(dtF).Div(r)
|
||||
sh.Vel = sh.Vel.Sub(sh.Pos.Unit().Scale(g))
|
||||
sh.Pos = sh.Pos.Add(sh.Vel.Scale(dtF))
|
||||
}
|
||||
|
||||
func (s *State) canReach(sh *Ship, pos, vel fixed.Vec, rangeKm int64) bool {
|
||||
rel := pos.Sub(sh.Pos)
|
||||
relV := vel.Sub(sh.Vel)
|
||||
return rel.Len() <= fixed.FromInt(rangeKm) &&
|
||||
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)
|
||||
// at which range is evaluated.
|
||||
func (s *State) mine(sh *Ship, at int64) {
|
||||
a := s.asteroid(sh.Target)
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
total := a.TotalOre()
|
||||
room := sh.Hull.CargoCap - sh.CargoTotal()
|
||||
amt := min(sh.Hull.MineRate, room, total)
|
||||
if amt <= 0 {
|
||||
return
|
||||
}
|
||||
pos, vel := a.Orbit.State(at)
|
||||
if !s.canReach(sh, pos, vel, MineRangeKm) {
|
||||
return
|
||||
}
|
||||
var taken [world.NumOre]int64
|
||||
var sum int64
|
||||
for o := range taken {
|
||||
taken[o] = amt * a.Ore[o] / total
|
||||
sum += taken[o]
|
||||
}
|
||||
for o := range taken {
|
||||
extra := min(amt-sum, a.Ore[o]-taken[o])
|
||||
taken[o] += extra
|
||||
sum += extra
|
||||
}
|
||||
for o, kg := range taken {
|
||||
a.Ore[o] -= kg
|
||||
sh.Cargo[o] += kg
|
||||
}
|
||||
}
|
||||
|
||||
// thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector.
|
||||
func thrustDir(sh *Ship) fixed.Vec {
|
||||
az := fixed.FromRatio(int64(sh.Azimuth), 1000)
|
||||
el := fixed.FromRatio(int64(sh.Pitch), 1000)
|
||||
return fixed.FromSpherical(az, el)
|
||||
}
|
||||
Reference in New Issue
Block a user