fix(simulation): PR #176 review round — all 12 findings + 2 recommendations addressed
H1 demux: ShapeProbe defensive multi-shape rejection (union frames now Err, not first-match; +2 tests) and doc claim made honest. H2/T1 SystemIndex.reset_test_state() folded into SimBridge.reset_test_state() (load() inline per autoload rule) + has_pending_request() accessor. H3 no-op tests now assert the replay flag both directions. H4 retry test actually ingests a failure and asserts the retry semantic. H5 error fixture uses the normalized status string. H6 bridge_tcp e2e sends all five frame shapes over real TCP (star-map + city-names buffers asserted). H7 positive replay-on-CONNECTED test via the test_local_bridge test-mode-flip precedent (stub bridge captures + decodes the request bytes). H8/T2 stale PLACEHOLDER doc replaced with the confirmed contract. H9 is_capital doc matches the COALESCE reality. T-r1 demux ceiling written down (next shape = tagged envelope). T-r2 AtlasLayerResponse governance ceiling comment. Lead item: the four cargo-fmt-formatted files from the gate round are now committed (layer_proxy/plugin/bridge-mod/main). H10 note for the record: the 13 snapshot_*.msgpack fixtures in commit 845737617 were regenerated because they were stale against their own generator (pre-existing version-key removal) — verified harmless, no client reads that key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,8 +40,9 @@ pub struct CityRecord {
|
||||
/// `atlas_city_names.kind == 'capital'` (authored, not derived from
|
||||
/// population). Threaded onto [`CityPlacement`] for the Atlas
|
||||
/// [`SettlementLayer`](crate::atlas::layer_proxy::SettlementLayer) (T-960 §2).
|
||||
/// Defaults to `false` when the source doesn't track `kind` (e.g. the
|
||||
/// believability harness's own settlement read).
|
||||
/// Every current reader (`city_context_reader` and the believability
|
||||
/// harness's own settlement read) selects `COALESCE(kind,'city')`, so an
|
||||
/// un-authored `kind` yields `false` — there is no kind-less source left.
|
||||
pub is_capital: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ pub struct DistrictGridLayer {
|
||||
/// A layer response: the computed `Layer1Output` + the coarse district grid
|
||||
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or
|
||||
/// a non-ready status.
|
||||
///
|
||||
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
|
||||
/// pattern tops out around six fields — D-226's 2026-07-13 amendment (d)
|
||||
/// rules out any L5/tile Atlas layer ever, leaving T-1112 (quarter
|
||||
/// footprints) and T-1113 (region climate) as the only remaining candidates.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AtlasLayerResponse {
|
||||
pub body_id: String,
|
||||
@@ -726,8 +731,13 @@ mod tests {
|
||||
assert_eq!(decoded.body_id, "GJ1c");
|
||||
let rg = decoded.road_graph.expect("road_graph survives round trip");
|
||||
assert_eq!(rg.nodes[0].position, (12, 58));
|
||||
assert_eq!(rg.edges[0].maintenance, MaintenanceAuthority::Administrative);
|
||||
let settlements = decoded.settlements.expect("settlements survives round trip");
|
||||
assert_eq!(
|
||||
rg.edges[0].maintenance,
|
||||
MaintenanceAuthority::Administrative
|
||||
);
|
||||
let settlements = decoded
|
||||
.settlements
|
||||
.expect("settlements survives round trip");
|
||||
assert_eq!(settlements.settlements[0].name, "Port Aldren");
|
||||
assert_eq!(
|
||||
settlements.settlements[0].size_class,
|
||||
|
||||
@@ -863,10 +863,8 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"sr_plugin_starmap_{}_{n}.json",
|
||||
std::process::id()
|
||||
));
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("sr_plugin_starmap_{}_{n}.json", std::process::id()));
|
||||
std::fs::write(&path, r#"{"_meta": {}, "nodes": [], "edges": []}"#).unwrap();
|
||||
|
||||
let mut world = World::new();
|
||||
|
||||
+100
-7
@@ -57,8 +57,14 @@ pub enum BridgeError {
|
||||
/// fields), the two *new* map shapes each carry a mandatory boolean
|
||||
/// discriminator field the others don't have at all (`star_map` /
|
||||
/// `city_names`): a missing required field is a hard deserialize failure, not
|
||||
/// a silent ignore, so every shape's required-field set is mutually
|
||||
/// exclusive. `AtlasLayerRequest` itself is untouched byte-for-byte.
|
||||
/// a silent ignore, so no *minimal well-formed* instance of one shape
|
||||
/// satisfies another — and [`decode_inbound`] additionally REJECTS union
|
||||
/// frames that carry more than one shape's discriminators outright (PR #176
|
||||
/// review H1). `AtlasLayerRequest` itself is untouched byte-for-byte.
|
||||
///
|
||||
/// **Ceiling (D-225 trajectory):** four shapes is the practical limit of this
|
||||
/// hand-rolled sniffing. The next new inbound shape must migrate the channel
|
||||
/// to the tagged-envelope framing D-225 deferred — do not add a fifth probe.
|
||||
#[derive(Debug)]
|
||||
pub enum Inbound {
|
||||
/// A batch of player inputs (the gameplay path).
|
||||
@@ -71,17 +77,61 @@ pub enum Inbound {
|
||||
CityNamesRequest(CityNamesRequest),
|
||||
}
|
||||
|
||||
/// Key-presence probe for the defensive multi-shape check in
|
||||
/// [`decode_inbound`]: `Option<IgnoredAny>` records whether a key exists
|
||||
/// without caring about its value or type, so a union frame is detected even
|
||||
/// when the individual values wouldn't parse as their target types.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ShapeProbe {
|
||||
body_id: Option<serde::de::IgnoredAny>,
|
||||
up_to: Option<serde::de::IgnoredAny>,
|
||||
star_map: Option<serde::de::IgnoredAny>,
|
||||
city_names: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries,
|
||||
/// in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
|
||||
/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) →
|
||||
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`). Every map
|
||||
/// shape's required fields are mutually exclusive (see the [`Inbound`] doc),
|
||||
/// so this order is for stability, not correctness — a frame that satisfies
|
||||
/// none of the four shapes is a genuinely malformed input frame.
|
||||
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`).
|
||||
///
|
||||
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
|
||||
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
|
||||
/// defensive pre-check rejects any map frame carrying the discriminators of
|
||||
/// more than one shape — e.g. a buggy encoder emitting
|
||||
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
|
||||
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
|
||||
/// satisfying none of the four shapes is a genuinely malformed input frame.
|
||||
pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
|
||||
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
|
||||
return Ok(Inbound::Inputs(inputs));
|
||||
}
|
||||
// Defensive multi-shape rejection: serde ignores unknown fields, so a
|
||||
// union frame would otherwise route silently by try-order. Unreachable
|
||||
// from the shipped client encoders (each sends one minimal shape) — this
|
||||
// guards buggy or adversarial frames.
|
||||
if let Ok(probe) = rmp_serde::from_slice::<ShapeProbe>(payload) {
|
||||
let atlas = probe.body_id.is_some() && probe.up_to.is_some();
|
||||
let star_map = probe.star_map.is_some();
|
||||
let city_names = probe.city_names.is_some();
|
||||
let shapes = usize::from(atlas) + usize::from(star_map) + usize::from(city_names);
|
||||
if shapes > 1 {
|
||||
let dump_len = payload.len().min(256);
|
||||
tracing::error!(
|
||||
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
|
||||
shapes,
|
||||
atlas,
|
||||
star_map,
|
||||
city_names,
|
||||
dump_len,
|
||||
payload.len(),
|
||||
&payload[..dump_len]
|
||||
);
|
||||
return Err(BridgeError::DeserializationWithDump(format!(
|
||||
"ambiguous inbound frame matches {shapes} request shapes (payload {} bytes)",
|
||||
payload.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Ok(req) = rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
|
||||
return Ok(Inbound::AtlasRequest(req));
|
||||
}
|
||||
@@ -442,7 +492,10 @@ impl Plugin for BridgePlugin {
|
||||
.add_systems(Update, receive_bridge_inputs.in_set(TickPhase::PreInput))
|
||||
.add_systems(Update, send_bridge_snapshot.in_set(TickPhase::PostSnapshot))
|
||||
.add_systems(Update, send_atlas_responses.in_set(TickPhase::PostSnapshot))
|
||||
.add_systems(Update, send_star_map_responses.in_set(TickPhase::PostSnapshot))
|
||||
.add_systems(
|
||||
Update,
|
||||
send_star_map_responses.in_set(TickPhase::PostSnapshot),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
send_city_names_responses.in_set(TickPhase::PostSnapshot),
|
||||
@@ -605,4 +658,44 @@ mod inbound_tests {
|
||||
// it's missing the required `up_to` field.
|
||||
assert!(rmp_serde::from_slice::<AtlasLayerRequest>(&city_names_frame).is_err());
|
||||
}
|
||||
|
||||
/// PR #176 review H1: a union frame carrying more than one shape's
|
||||
/// discriminators must be REJECTED, not silently routed to whichever
|
||||
/// shape `decode_inbound` happens to try first.
|
||||
#[test]
|
||||
fn ambiguous_union_frame_is_rejected() {
|
||||
#[derive(serde::Serialize)]
|
||||
struct StarAndCity {
|
||||
star_map: bool,
|
||||
city_names: bool,
|
||||
body_id: String,
|
||||
}
|
||||
let frame = rmp_serde::to_vec_named(&StarAndCity {
|
||||
star_map: true,
|
||||
city_names: true,
|
||||
body_id: "GJ1c".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
decode_inbound(&frame).is_err(),
|
||||
"star_map+city_names union frame must be rejected"
|
||||
);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AtlasAndStar {
|
||||
body_id: String,
|
||||
up_to: CascadeLayer,
|
||||
star_map: bool,
|
||||
}
|
||||
let frame = rmp_serde::to_vec_named(&AtlasAndStar {
|
||||
body_id: "GJ1c".into(),
|
||||
up_to: CascadeLayer::Topography,
|
||||
star_map: true,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
decode_inbound(&frame).is_err(),
|
||||
"atlas+star_map union frame must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -211,9 +211,9 @@ fn main() {
|
||||
star_map_data_path
|
||||
);
|
||||
}
|
||||
app.insert_resource(settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath(
|
||||
star_map_data_path,
|
||||
));
|
||||
app.insert_resource(
|
||||
settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath(star_map_data_path),
|
||||
);
|
||||
|
||||
// Settlement reader for Layer-3 placement (#955): reads a body's settlements
|
||||
// from systems.db on a cache miss so the cascade work item stays DB-free.
|
||||
|
||||
Reference in New Issue
Block a user