fix(bf-u7ds): forward simulator registry positions to fusion engine

The simulator→fleet-registry path landed node positions in SQLite but never
reached the blob-producing 3D fusion engine at runtime — SyncToRegistry wrote
through fleetRegistryAdapter, which had no forwarding hook, so the engine's
nodePos mirror stayed stale and Fuse localized against wrong/missing geometry
(seeded correctly only at startup).

Add a forwardPos callback to fleetRegistryAdapter (AddVirtualNode /
SetNodePosition) and wire it to fleetMgr.ForwardNodePosition in main, which
already routes through the nodePositionSink → fusion.Engine.SetNodePosition.
This converges all three registry-write paths (PATCH /position, OnNodeConnected,
simulator sync) on the single ForwardNodePosition accessor.

Lock-in: TestSimulatorHandlerToRegistry_PositionsReachFusionEngine recreates
the full chain behind the SimulatorHandler (real Registry + Manager sink →
fusion.Engine) and asserts the engine mirror updates on both AddNode and
UpdateNode — mirroring the two bf-3p6g lock-ins for the simulator path.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-06 12:43:03 -04:00
parent fd7918f7fc
commit dbb5cd0cc4
2 changed files with 141 additions and 7 deletions

View file

@ -358,16 +358,40 @@ func (a *fleetRoomConfigAdapter) GetRoom() (width, height, depth float64) {
// lets the simulator API drive an immediate SyncToRegistry into the live fleet
// registry (bf-5lii), and unblocks the periodic sync that previously passed
// *fleet.Registry to a method expecting the simulator interface (bf-4pqj).
//
// bf-u7ds: AddVirtualNode/SetNodePosition also forward the new position through
// forwardPos — wired to fleet.Manager.ForwardNodePosition in main — so a
// simulator-driven registry write reaches the blob-producing 3D fusion engine
// (internal/fusion.Engine) at runtime, not only at startup seeding. Without
// this, SyncToRegistry lands the position in the DB but the engine's nodePos
// mirror stays stale and Fuse localizes against the wrong geometry. This mirrors
// the write the REST handler does after registry.SetNodePosition
// (Handler.updateNodePosition → ForwardNodePosition) and the connect/register
// write (Manager.OnNodeConnected → ForwardNodePosition), converging all three
// registry-write paths on the single ForwardNodePosition accessor.
type fleetRegistryAdapter struct {
reg *fleet.Registry
reg *fleet.Registry
forwardPos func(mac string, x, y, z float64)
}
func (a *fleetRegistryAdapter) AddVirtualNode(mac, name string, x, y, z float64) error {
return a.reg.AddVirtualNode(mac, name, x, y, z)
if err := a.reg.AddVirtualNode(mac, name, x, y, z); err != nil {
return err
}
if a.forwardPos != nil {
a.forwardPos(mac, x, y, z)
}
return nil
}
func (a *fleetRegistryAdapter) SetNodePosition(mac string, x, y, z float64) error {
return a.reg.SetNodePosition(mac, x, y, z)
if err := a.reg.SetNodePosition(mac, x, y, z); err != nil {
return err
}
if a.forwardPos != nil {
a.forwardPos(mac, x, y, z)
}
return nil
}
func (a *fleetRegistryAdapter) SetNodeRole(mac, role string) error {
@ -4276,8 +4300,14 @@ func main() {
// (converting fleet.NodeRecord <-> simulator.NodeRecord). Shared by the
// periodic sync below and by the immediate syncs the simulator API triggers
// on node create/update, so both paths write through the same registry.
// bf-u7ds: forwardPos routes each position write to the fusion engine via
// the manager sink, so simulator-created/updated nodes reach the
// accumulation grid without waiting for the next startup seeding.
if fleetReg != nil {
registryAdapter = &fleetRegistryAdapter{reg: fleetReg}
registryAdapter = &fleetRegistryAdapter{
reg: fleetReg,
forwardPos: fleetMgr.ForwardNodePosition,
}
}
// Start periodic sync from virtual node store to fleet registry (every 30 seconds).

View file

@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/fleet"
"github.com/spaxel/mothership/internal/fusion"
"github.com/spaxel/mothership/internal/simulator"
)
@ -324,16 +325,35 @@ func TestImmediateSyncOnAddAndUpdate(t *testing.T) {
// registry is what makes the tests below genuinely end-to-end: a position
// written by the bridge is round-tripped through SQLite, not merely recorded by
// a fake. (bf-69ym)
//
// bf-u7ds: AddVirtualNode/SetNodePosition also forward the new position through
// forwardPos (mirroring the production adapter) so a simulator-driven registry
// write reaches the blob-producing fusion engine at runtime, not only at
// startup seeding. Tests set forwardPos to fleet.Manager.ForwardNodePosition,
// exactly as cmd/mothership/main.go does.
type fleetRegistryTestAdapter struct {
reg *fleet.Registry
reg *fleet.Registry
forwardPos func(mac string, x, y, z float64)
}
func (a *fleetRegistryTestAdapter) AddVirtualNode(mac, name string, x, y, z float64) error {
return a.reg.AddVirtualNode(mac, name, x, y, z)
if err := a.reg.AddVirtualNode(mac, name, x, y, z); err != nil {
return err
}
if a.forwardPos != nil {
a.forwardPos(mac, x, y, z)
}
return nil
}
func (a *fleetRegistryTestAdapter) SetNodePosition(mac string, x, y, z float64) error {
return a.reg.SetNodePosition(mac, x, y, z)
if err := a.reg.SetNodePosition(mac, x, y, z); err != nil {
return err
}
if a.forwardPos != nil {
a.forwardPos(mac, x, y, z)
}
return nil
}
func (a *fleetRegistryTestAdapter) SetNodeRole(mac, role string) error {
@ -487,6 +507,90 @@ func TestSimulatorHandlerToRegistry_CreateAndUpdate(t *testing.T) {
}
}
// singleEnginePosition returns the fusion engine's single registered node
// position, failing the test unless exactly one node is positioned. Used to
// assert what the accumulation grid will localize against.
func singleEnginePosition(t *testing.T, engine *fusion.Engine) simulator.Point {
t.Helper()
positions := engine.NodePositions()
if len(positions) != 1 {
t.Fatalf("expected exactly 1 fusion engine node position, got %d: %+v", len(positions), positions)
}
p := positions[0]
return simulator.Point{X: p.X, Y: p.Y, Z: p.Z}
}
// TestSimulatorHandlerToRegistry_PositionsReachFusionEngine (bf-u7ds) is the
// end-to-end lock-in for the third registry-write path: a simulator-driven node
// create/update must reach the blob-producing 3D fusion engine, not just the
// fleet DB. The production wiring (cmd/mothership/main.go) builds the adapter
// with forwardPos = fleetMgr.ForwardNodePosition, and the manager's
// nodePositionSink is wired to fusion.Engine.SetNodePosition; this test
// recreates that exact chain behind the SimulatorHandler and asserts the
// engine's nodePos mirror updates on both AddNode and UpdateNode.
//
// Without the forwardPos hook, SyncToRegistry lands the position in SQLite but
// the engine mirror stays stale and Fuse localizes against wrong/missing
// geometry — the regression this bead guards against. Mirrors the two bf-3p6g
// lock-ins (PATCH /position and OnNodeConnected) for the simulator path.
func TestSimulatorHandlerToRegistry_PositionsReachFusionEngine(t *testing.T) {
reg := newFleetRegistry(t)
mgr := fleet.NewManager(reg)
engine := fusion.NewEngine(nil)
mgr.SetNodePositionSink(func(mac string, x, y, z float64) {
engine.SetNodePosition(mac, x, y, z)
})
adapter := &fleetRegistryTestAdapter{
reg: reg,
forwardPos: mgr.ForwardNodePosition,
}
h := newWiredHandler(t, adapter)
space := simulator.DefaultSpace()
// Sanity: before any node is added the engine holds nothing to localize on.
if engine.NodeCount() != 0 {
t.Fatalf("engine should hold no positions before AddNode, got %d", engine.NodeCount())
}
// AddNode with no position: the bridge assigns spread geometry and the
// immediate sync must push it through to the engine's nodePos mirror.
postNode(t, h, map[string]interface{}{
"id": "node-A",
"name": "Node A",
})
wantSpread := simulator.DefaultNodePositions(space, 1)
if len(wantSpread) != 1 {
t.Fatalf("DefaultNodePositions(1) returned %d slots, want 1", len(wantSpread))
}
got := singleEnginePosition(t, engine)
if got != wantSpread[0] {
t.Fatalf("AddNode: fusion engine position %v != spread %v", got, wantSpread[0])
}
if got.X == 0 && got.Y == 0 && got.Z == 0 {
t.Fatalf("AddNode: fusion engine landed at (0,0,0) — engine mirror not seeded")
}
// UpdateNode with a new explicit position: the engine mirror must move with it.
newPos := simulator.Point{X: 2.5, Y: 3.0, Z: 1.5}
putNode(t, h, "node-A", map[string]interface{}{
"id": "node-A",
"name": "Node A",
"position": map[string]interface{}{
"x": newPos.X, "y": newPos.Y, "z": newPos.Z,
},
})
got = singleEnginePosition(t, engine)
if got != newPos {
t.Fatalf("UpdateNode: fusion engine position %v != new position %v", got, newPos)
}
// Still a single positioned node — an update must not orphan the old entry.
if engine.NodeCount() != 1 {
t.Errorf("UpdateNode: expected 1 fusion engine node, got %d", engine.NodeCount())
}
}
// TestSimulatorHandlerToRegistry_OriginNodesGetSpreadGeometry is the
// end-to-end analogue of the bridge-level
// TestSyncToRegistry_OriginNodesGetSpreadGeometry, but driven through the REST