From ecfc0a6e95cbe23d106ce606804b29ad51ef3370 Mon Sep 17 00:00:00 2001 From: R0ck Date: Wed, 9 Sep 2026 14:03:31 +0100 Subject: [PATCH] session: the snapshot's regions follow the scenario, every readout Regions applied to a running mesh reached the scenario - the firmware was provisioned from them and relayed scoped traffic normally - but vanished from nodes.list within a tick, so the one place the manual says regions can be read answered 'no regions' on a mesh where every node held one. infer.apply and nodes.regions write both the scenario and the published row, and something on a warmed, playing mesh rebuilt the rows without the regions; I could not isolate which rebuild, and neither could the reporter. Rather than chase a transition that hides, the readout now re-projects the scenario's regions and default scope onto the rows every readout, the way it already re-projects the scores, trails and stats from the engine. The scenario is authoritative - it is what the verbs write and what the firmware runs on - so the row cannot drift from it for longer than a readout, whatever rebuilt it. Closes #737 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- internal/app/session/readouts.go | 29 ++++++++++++++++ internal/app/session/syncregions_test.go | 42 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 internal/app/session/syncregions_test.go diff --git a/internal/app/session/readouts.go b/internal/app/session/readouts.go index c87a79a1..bd54b1c6 100644 --- a/internal/app/session/readouts.go +++ b/internal/app/session/readouts.go @@ -59,6 +59,7 @@ func (s *Sim) refreshReadouts(w *state.World, index map[string]int) { w.Counts = s.eventCounts() w.Scores = s.scores() w.Stats = s.nodeStats(w.Events) + s.syncRegions(w) if s.history == nil { s.history = newNodeHistory() } @@ -70,3 +71,31 @@ func (s *Sim) refreshReadouts(w *state.World, index map[string]int) { } } } + +// syncRegions re-projects the scenario's regions and default scope onto the +// snapshot rows, the way the scores and trails above are re-projected from the +// engine. +// +// The scenario is authoritative: it is what nodes.regions and infer.apply +// write and what the firmware was provisioned from, so a mesh that relays +// scoped traffic holds its regions there. The row is a published copy, and a +// change applied while the mesh runs reaches the scenario but was seen to be +// lost from the row - nodes.list, the one place the manual says regions can be +// read, then answered "no regions" on a mesh where every node held one. Copied +// here every readout, the row cannot drift from the scenario for longer than a +// readout, whatever rebuilt it. +func (s *Sim) syncRegions(w *state.World) { + if len(s.nodes) == 0 || len(w.Nodes) == 0 { + return + } + byName := make(map[string]int, len(w.Nodes)) + for i := range w.Nodes { + byName[w.Nodes[i].Name] = i + } + for i := range s.nodes { + if j, ok := byName[s.nodes[i].Name]; ok { + w.Nodes[j].Regions = s.nodes[i].Regions + w.Nodes[j].DefaultScope = s.nodes[i].DefaultScope + } + } +} diff --git a/internal/app/session/syncregions_test.go b/internal/app/session/syncregions_test.go new file mode 100644 index 00000000..af86ecb4 --- /dev/null +++ b/internal/app/session/syncregions_test.go @@ -0,0 +1,42 @@ +package session + +import ( + "testing" + + "github.com/MeshBench/meshbench/internal/app/state" + "github.com/MeshBench/meshbench/internal/world/scenario" +) + +// The snapshot's region columns are re-projected from the scenario every +// readout, so a region applied while the mesh runs cannot vanish from +// nodes.list even if whatever rebuilds the rows drops it (#737). The scenario +// is authoritative: it is what the firmware was provisioned from. +func TestSyncRegionsMakesTheSnapshotFollowTheScenario(t *testing.T) { + s := &Sim{nodes: []scenario.Node{ + {Name: "A", Regions: []string{"fif"}, DefaultScope: "fif"}, + {Name: "B"}, + }} + // The snapshot has drifted: A holds the regions in the scenario but the + // row does not, which is the state the bug leaves. + w := &state.World{Nodes: []state.Node{{Name: "A"}, {Name: "B"}}} + + s.syncRegions(w) + + if got := w.Nodes[0].Regions; len(got) != 1 || got[0] != "fif" { + t.Errorf("A's row has regions %v, want [fif] from the scenario", got) + } + if w.Nodes[0].DefaultScope != "fif" { + t.Errorf("A's row default scope is %q, want fif", w.Nodes[0].DefaultScope) + } + if len(w.Nodes[1].Regions) != 0 { + t.Errorf("B holds no region in the scenario, so its row should hold none: %v", w.Nodes[1].Regions) + } + + // A row with no scenario node of that name is left alone rather than + // cleared: the scenario decides what exists. + w2 := &state.World{Nodes: []state.Node{{Name: "ghost", Regions: []string{"x"}}}} + s.syncRegions(w2) + if len(w2.Nodes[0].Regions) != 1 { + t.Error("a row with no matching scenario node was cleared") + } +}