This commit is contained in:
root
2026-09-19 20:21:47 +02:00
commit 0798933b05
62 changed files with 7658 additions and 0 deletions
+219
View File
@@ -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
View File
@@ -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
}
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}