feat(simulation): road-graph hub refinement — scaled-cap hubs, co-location collapse, minor-attach edge split, RailHeadFacing (T-1076)
is_standalone_hq threaded through CityRecord/CityPlacement via corporations LEFT JOIN (both readers); hubs = top-cap by population among non-HQ settlements (HUB_SPACING_DIAG_PX=64, HUB_CAP_MIN=6); exact-name collapse keeping lowest city_id with loud warn; nearest-point-on-polyline snap (SNAP_MAX_PX=8) with Junction edge-split preserving from<to via norm_edge, else A*-spur to nearest hub; FoundingOrientation::RailHeadFacing{bearing_degrees} assigned at degree>=3 junctions (octant bearing toward dominant incident edge), consumed by skeleton_gen railhead_edge() through the D-234b flush-frontage machinery; believability reader now honours baked settlement_class (stale hardcode since T-1075). 11 new targeted tests; existing suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,16 @@ pub struct CityRecord {
|
||||
/// 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,
|
||||
/// `true` if this settlement is a D-242 Standalone corp-HQ company town —
|
||||
/// its `atlas_city_names` row matches a `corporations` row with
|
||||
/// `hq_placement = 'Standalone'`, `headquarters_body = body_id`, and
|
||||
/// `proper_name = name` (both readers join it identically). Threaded onto
|
||||
/// [`CityPlacement`] for the T-1076 road-graph hub rule: standalone HQs
|
||||
/// are minor nodes regardless of population — "hubs are the significant
|
||||
/// cities, standalone HQs are typically minor nodes" (D-242). Placement
|
||||
/// itself (`match_cities`) does NOT read this flag — an HQ competes for
|
||||
/// attractors on equal terms (the PR #177 invariant tests below hold).
|
||||
pub is_standalone_hq: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,13 +79,18 @@ pub struct CityPlacement {
|
||||
/// Spatial arrangement governing district adjacency (D-215).
|
||||
pub arrangement_pattern: ArrangementPattern,
|
||||
/// Primary street-grid axis (D-213). Derived from the anchoring attractor
|
||||
/// type; pioneer/open-terrain bearings are seed-varied.
|
||||
/// type; pioneer/open-terrain bearings are seed-varied. The road-graph
|
||||
/// pass may override it to `RailHeadFacing` post-hoc (T-1076 §4,
|
||||
/// `road_graph::assign_railhead_orientations`).
|
||||
pub founding_orientation: FoundingOrientation,
|
||||
/// Carried straight from [`CityRecord::population`] (T-960 §2 — the Atlas
|
||||
/// `SettlementLayer` derives its coarse size class from this).
|
||||
pub population: i64,
|
||||
/// Carried straight from [`CityRecord::is_capital`] (T-960 §2).
|
||||
pub is_capital: bool,
|
||||
/// Carried straight from [`CityRecord::is_standalone_hq`] (T-1076 §1 —
|
||||
/// the road-graph hub rule reads it off the placement).
|
||||
pub is_standalone_hq: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -396,6 +411,7 @@ pub fn match_cities(
|
||||
founding_orientation: orientation,
|
||||
population: cities[ci].population,
|
||||
is_capital: cities[ci].is_capital,
|
||||
is_standalone_hq: cities[ci].is_standalone_hq,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -466,6 +482,7 @@ pub fn match_cities(
|
||||
founding_orientation: orientation,
|
||||
population: cities[ci].population,
|
||||
is_capital: cities[ci].is_capital,
|
||||
is_standalone_hq: cities[ci].is_standalone_hq,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -503,6 +520,7 @@ pub fn match_cities(
|
||||
founding_orientation: orientation,
|
||||
population: city.population,
|
||||
is_capital: city.is_capital,
|
||||
is_standalone_hq: city.is_standalone_hq,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -707,6 +725,7 @@ mod tests {
|
||||
population: pop,
|
||||
economic_role: "manufacturing".to_string(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,6 +956,7 @@ mod tests {
|
||||
population: 60_000,
|
||||
economic_role: "agricultural".to_string(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
@@ -945,6 +965,7 @@ mod tests {
|
||||
population: 80_000,
|
||||
economic_role: "transit_hub".to_string(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
];
|
||||
let attractors = vec![
|
||||
|
||||
@@ -534,28 +534,45 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
|
||||
Ok(snapshot.into_body_world_state())
|
||||
}
|
||||
|
||||
/// Read a body's settlements from `atlas_city_names`. Per-city `settlement_class` is
|
||||
/// unset in the committed pool, so it defaults to `PopulationBudget` (what the
|
||||
/// city-context read-set assumes).
|
||||
/// Read a body's settlements from `atlas_city_names`, mirroring
|
||||
/// `CityContextReader::read_body_settlements` (keep the two in lockstep):
|
||||
/// baked `settlement_class` is honoured (D-242/T-1075 bakes it on every row;
|
||||
/// unknown/NULL falls back to `PopulationBudget`), and the T-1076
|
||||
/// `is_standalone_hq` flag comes from the identical `corporations` LEFT JOIN.
|
||||
fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
|
||||
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0),
|
||||
COALESCE(kind,'city')
|
||||
FROM atlas_city_names WHERE body_id = ?1 ORDER BY id",
|
||||
"SELECT acn.id, acn.name, COALESCE(acn.economic_role,'service_mixed'),
|
||||
COALESCE(acn.population,0), COALESCE(acn.kind,'city'),
|
||||
acn.settlement_class, c.corp_id IS NOT NULL
|
||||
FROM atlas_city_names AS acn
|
||||
LEFT JOIN corporations AS c
|
||||
ON c.hq_placement = 'Standalone'
|
||||
AND c.headquarters_body = acn.body_id
|
||||
AND c.proper_name = acn.name
|
||||
WHERE acn.body_id = ?1 ORDER BY acn.id",
|
||||
)
|
||||
.map_err(|e| format!("prepare city query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([body_id], |r| {
|
||||
let kind: String = r.get(4)?;
|
||||
let sclass: Option<String> = r.get(5)?;
|
||||
let settlement_class = match sclass.as_deref() {
|
||||
Some("NameLocked") => SettlementClass::NameLocked,
|
||||
Some("EconomicTriggered") => SettlementClass::EconomicTriggered,
|
||||
Some("OrganicGrowth") => SettlementClass::OrganicGrowth,
|
||||
// "PopulationBudget", NULL, or unknown → the default class.
|
||||
_ => SettlementClass::PopulationBudget,
|
||||
};
|
||||
Ok(CityRecord {
|
||||
city_id: r.get::<_, i64>(0)? as u64,
|
||||
name: r.get(1)?,
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
settlement_class,
|
||||
economic_role: r.get(2)?,
|
||||
population: r.get(3)?,
|
||||
is_capital: kind == "capital",
|
||||
is_standalone_hq: r.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("city query: {e}"))?
|
||||
|
||||
@@ -311,6 +311,16 @@ pub fn run_cascade_from_heightmap(
|
||||
&territorial_status,
|
||||
&[],
|
||||
);
|
||||
// RailHeadFacing pass (T-1076 §4, D-213 amended): settlements
|
||||
// that are high-connectivity junctions (degree ≥ 3) get their
|
||||
// founding_orientation overridden toward the dominant incident
|
||||
// edge. Mutates the Layer-3 placements post-hoc — orientation
|
||||
// is a Layer-3 output, but rail-head facing is only knowable
|
||||
// once Layer 2 exists. Deterministic: a pure function of the
|
||||
// (already deterministic) graph.
|
||||
if let Some(l3) = snapshot.layer3.as_mut() {
|
||||
road_graph::assign_railhead_orientations(&mut l3.placements, &graph);
|
||||
}
|
||||
snapshot.road_graph = Some(graph);
|
||||
}
|
||||
}
|
||||
@@ -527,6 +537,7 @@ mod tests {
|
||||
population: 2_000_000,
|
||||
economic_role: "financial".into(),
|
||||
is_capital: true,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
@@ -535,6 +546,7 @@ mod tests {
|
||||
population: 120_000,
|
||||
economic_role: "agricultural".into(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
},
|
||||
];
|
||||
let run = || {
|
||||
@@ -621,6 +633,7 @@ mod tests {
|
||||
population: *pop,
|
||||
economic_role: "manufacturing".into(),
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -265,6 +265,15 @@ impl CityContextReader {
|
||||
/// Unlike [`read_set`](Self::read_set) (the strict D-199 path that aborts on
|
||||
/// any malformed field), this method is best-effort: it never fails on row
|
||||
/// content, only on a DB/connection error.
|
||||
///
|
||||
/// `is_standalone_hq` (T-1076 §1): a LEFT JOIN against `corporations`
|
||||
/// marks the rows that are D-242 Standalone corp-HQ company towns
|
||||
/// (`hq_placement = 'Standalone'`, `headquarters_body = body_id`,
|
||||
/// `proper_name = name` — the exact shape `populate_standalone_hq_settlements`
|
||||
/// emits them with). The road-graph hub rule demotes these to minor nodes
|
||||
/// regardless of population. The believability harness's own settlement
|
||||
/// read (`believability::read_cities`) performs the identical join — keep
|
||||
/// the two in lockstep.
|
||||
pub fn read_body_settlements(
|
||||
&self,
|
||||
body_id: &str,
|
||||
@@ -275,11 +284,16 @@ impl CityContextReader {
|
||||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, economic_role, population, settlement_class,
|
||||
COALESCE(kind, 'city')
|
||||
FROM atlas_city_names
|
||||
WHERE body_id = ?1
|
||||
ORDER BY id",
|
||||
"SELECT acn.id, acn.name, acn.economic_role, acn.population,
|
||||
acn.settlement_class, COALESCE(acn.kind, 'city'),
|
||||
c.corp_id IS NOT NULL
|
||||
FROM atlas_city_names AS acn
|
||||
LEFT JOIN corporations AS c
|
||||
ON c.hq_placement = 'Standalone'
|
||||
AND c.headquarters_body = acn.body_id
|
||||
AND c.proper_name = acn.name
|
||||
WHERE acn.body_id = ?1
|
||||
ORDER BY acn.id",
|
||||
)
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
@@ -291,13 +305,14 @@ impl CityContextReader {
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, bool>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (id, name, role, population, sclass, kind) =
|
||||
let (id, name, role, population, sclass, kind, is_standalone_hq) =
|
||||
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
let city_id = id as u64;
|
||||
let settlement_class = match sclass.as_deref() {
|
||||
@@ -318,6 +333,7 @@ impl CityContextReader {
|
||||
population,
|
||||
economic_role: role.unwrap_or_else(|| "residential".to_string()),
|
||||
is_capital: kind == "capital",
|
||||
is_standalone_hq,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1039,7 +1055,11 @@ mod tests {
|
||||
// ─── read_body_settlements (#955) ────────────────────────────────────────
|
||||
|
||||
/// Build a db with several settlements on one body, returning its path. Some
|
||||
/// rows have a NULL `settlement_class` (the pre-placement state).
|
||||
/// rows have a NULL `settlement_class` (the pre-placement state). Includes
|
||||
/// an EMPTY `corporations` table — `read_body_settlements`' T-1076
|
||||
/// standalone-HQ LEFT JOIN references it, so the fixture schema must carry
|
||||
/// it (empty ⇒ every row reads `is_standalone_hq = false`). Use
|
||||
/// `add_standalone_corp` to mark one.
|
||||
fn make_settlements_db(rows: &[(&str, &str, i64, Option<&str>)]) -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("sr_ctxst_{}_{n}.db", std::process::id()));
|
||||
@@ -1054,6 +1074,12 @@ mod tests {
|
||||
economic_role TEXT,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT
|
||||
);
|
||||
CREATE TABLE corporations (
|
||||
corp_id TEXT PRIMARY KEY,
|
||||
proper_name TEXT NOT NULL,
|
||||
hq_placement TEXT,
|
||||
headquarters_body TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create table");
|
||||
@@ -1069,6 +1095,18 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
/// Register `name` on `body_id` as a D-242 Standalone corp HQ in the
|
||||
/// fixture's `corporations` table (the shape the T-1076 join matches).
|
||||
fn add_standalone_corp(db: &PathBuf, corp_id: &str, name: &str, body_id: &str) {
|
||||
let conn = Connection::open(db).expect("reopen");
|
||||
conn.execute(
|
||||
"INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body)
|
||||
VALUES (?1, ?2, 'Standalone', ?3)",
|
||||
rusqlite::params![corp_id, name, body_id],
|
||||
)
|
||||
.expect("insert corp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_settlements_defaults_null_class_to_population_budget() {
|
||||
// NULL settlement_class is the pre-placement state. It must default to
|
||||
@@ -1154,20 +1192,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── D-242 corp-HQ settlement model (T-1074/T-1075) ──────────────────────
|
||||
// ─── D-242 corp-HQ settlement model (T-1074/T-1075/T-1076) ───────────────
|
||||
//
|
||||
// A Standalone-HQ settlement (economy_import/corporations.py:
|
||||
// populate_standalone_hq_settlements) is inserted into atlas_city_names as
|
||||
// an ORDINARY row — same schema, same fields, no corp linkage marker (the
|
||||
// corp<->settlement relationship lives on corporations.headquarters_body,
|
||||
// not on atlas_city_names). This module's test fixture schema
|
||||
// (make_settlements_db, above) never had a corp_id column to begin with —
|
||||
// this test makes that design invariant explicit: read_body_settlements
|
||||
// (the D-211 placement pipeline's input) cannot distinguish a
|
||||
// corp-Standalone-HQ row from a wiki-pooled city, by construction. If a
|
||||
// future change ever needs to special-case corp-originated settlements,
|
||||
// it will have to be threaded through explicitly — this test fails the
|
||||
// moment that stops being true silently.
|
||||
// an ORDINARY row — same schema, same fields, no corp linkage marker on
|
||||
// the row itself (the corp<->settlement relationship lives on
|
||||
// corporations.headquarters_body). The T-1074 invariant test below
|
||||
// predicted that any future special-casing "will have to be threaded
|
||||
// through explicitly" — T-1076 §1 is exactly that threading: an explicit
|
||||
// LEFT JOIN against corporations now derives `is_standalone_hq`, consumed
|
||||
// ONLY by the road-graph hub rule. The D-211 placement fields stay
|
||||
// identical to an ordinary city's (the equal-terms attractor invariant in
|
||||
// attractor_matching.rs still holds).
|
||||
|
||||
#[test]
|
||||
fn read_body_settlements_treats_standalone_hq_row_identically_to_pooled_city() {
|
||||
@@ -1175,8 +1212,11 @@ mod tests {
|
||||
// Standalone-HQ row (name = corp proper_name, economic_role from
|
||||
// corp_hq_placement.toml's standalone_economic_role, population from
|
||||
// the T-1075 Zipf bake, settlement_class from the same bake/override
|
||||
// path as any other city) — indistinguishable in shape from the
|
||||
// path as any other city) — identical in D-211 placement shape to the
|
||||
// ordinary pooled cities "Tributarium"/"Ruhr" it sits alongside.
|
||||
// (Without a corporations row registering it, the T-1076 join also
|
||||
// leaves is_standalone_hq = false — the flag comes only from the
|
||||
// corporations side, never from the city row.)
|
||||
let db = make_settlements_db(&[
|
||||
(
|
||||
"Tributarium",
|
||||
@@ -1206,11 +1246,56 @@ mod tests {
|
||||
.find(|c| c.name == "Gate Corporation")
|
||||
.expect("Standalone-HQ row must be present");
|
||||
// Every CityRecord field the D-211 pipeline reads is populated exactly
|
||||
// like an ordinary city's — nothing marks this row as corp-originated.
|
||||
// like an ordinary city's.
|
||||
assert_eq!(hq.economic_role, "manufacturing");
|
||||
assert_eq!(hq.population, 909_090_165);
|
||||
assert_eq!(hq.settlement_class, SettlementClass::NameLocked);
|
||||
assert!(!hq.is_capital, "Standalone HQ is not a capital by default");
|
||||
assert!(
|
||||
!hq.is_standalone_hq,
|
||||
"no corporations row registers this name — the flag must stay false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_settlements_flags_standalone_hq_via_corporations_join() {
|
||||
// T-1076 §1: the corporations LEFT JOIN marks exactly the rows whose
|
||||
// (headquarters_body, proper_name) matches a Standalone corp — the
|
||||
// shape populate_standalone_hq_settlements emits.
|
||||
let db = make_settlements_db(&[
|
||||
(
|
||||
"Tributarium",
|
||||
"manufacturing",
|
||||
2_000_000,
|
||||
Some("PopulationBudget"),
|
||||
),
|
||||
(
|
||||
"Gate Corporation",
|
||||
"manufacturing",
|
||||
909_090_165,
|
||||
Some("PopulationBudget"),
|
||||
),
|
||||
]);
|
||||
add_standalone_corp(&db, "gate-corporation", "Gate Corporation", "PlanetX");
|
||||
// A Standalone corp on a DIFFERENT body with the same proper_name must
|
||||
// NOT mark PlanetX's row (the join keys on headquarters_body too).
|
||||
add_standalone_corp(&db, "other-corp", "Tributarium", "PlanetY");
|
||||
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
let cities = reader.read_body_settlements("PlanetX").expect("read");
|
||||
let hq = cities
|
||||
.iter()
|
||||
.find(|c| c.name == "Gate Corporation")
|
||||
.unwrap();
|
||||
let pool = cities.iter().find(|c| c.name == "Tributarium").unwrap();
|
||||
assert!(
|
||||
hq.is_standalone_hq,
|
||||
"registered Standalone corp row is flagged"
|
||||
);
|
||||
assert!(
|
||||
!pool.is_standalone_hq,
|
||||
"same-name corp on another body must not leak the flag across bodies"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── read_body_city_names (T-949) ────────────────────────────────────────
|
||||
|
||||
@@ -549,6 +549,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: None,
|
||||
@@ -556,6 +557,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Waypoint,
|
||||
degree: 0,
|
||||
parent_edge: Some(0),
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
@@ -617,6 +619,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population,
|
||||
is_capital,
|
||||
is_standalone_hq: false,
|
||||
};
|
||||
|
||||
let mut state = blank_state("GJ1c");
|
||||
@@ -696,6 +699,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 2_000_000,
|
||||
is_capital: true,
|
||||
is_standalone_hq: false,
|
||||
}];
|
||||
state.road_graph = RoadGraph {
|
||||
nodes: vec![RoadNode {
|
||||
@@ -704,6 +708,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 0,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
}],
|
||||
edges: vec![RoadEdge {
|
||||
from: 0,
|
||||
|
||||
@@ -984,6 +984,7 @@ mod tests {
|
||||
founding_orientation: orientation,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +1006,7 @@ mod tests {
|
||||
founding_orientation: orientation,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,6 +1112,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(90),
|
||||
@@ -1117,6 +1120,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(91),
|
||||
@@ -1124,6 +1128,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
@@ -1460,6 +1465,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -1592,6 +1598,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(2),
|
||||
@@ -1599,6 +1606,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
@@ -1634,6 +1642,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(11),
|
||||
@@ -1641,6 +1650,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(12),
|
||||
@@ -1648,6 +1658,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
@@ -1694,6 +1705,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(2),
|
||||
@@ -1701,6 +1713,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(3),
|
||||
@@ -1708,6 +1721,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
@@ -1756,6 +1770,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(6),
|
||||
@@ -1763,6 +1778,7 @@ mod tests {
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
},
|
||||
],
|
||||
edges: vec![RoadEdge {
|
||||
@@ -1788,6 +1804,7 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
};
|
||||
|
||||
let GenWorkItem::GenerateSkeleton {
|
||||
|
||||
+750
-31
@@ -45,12 +45,14 @@ use std::cmp::Reverse;
|
||||
use std::collections::{BTreeSet, BinaryHeap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::subbiome;
|
||||
use crate::simulation::generator::{MaintenanceAuthority, PoliticalArchetype, TerritorialStatus};
|
||||
use crate::simulation::generator::{
|
||||
FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, TerritorialStatus,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tunables (workshop-anchored)
|
||||
@@ -100,6 +102,28 @@ const LONG_HAUL_FRAC: f64 = 0.30;
|
||||
/// junctions (the candidates a `RailHeadFacing` second pass would target).
|
||||
pub const JUNCTION_DEGREE: u16 = 3;
|
||||
|
||||
/// One trunk hub is allowed per this many grid-px of body diagonal (T-1076 §1,
|
||||
/// D-242 "scaled-cap hubs"). The working planet grid is a fixed 512×256, whose
|
||||
/// diagonal (including the ×8 routing downsample) is ≈572 px → a cap of ≈8
|
||||
/// trunk hubs on a planet; smaller grids (tests, future sub-planet bodies)
|
||||
/// scale down and are floored by [`HUB_CAP_MIN`]. "Body size" here is the grid
|
||||
/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other
|
||||
/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`,
|
||||
/// `LONG_HAUL_FRAC`); physical radius is not available to this layer.
|
||||
const HUB_SPACING_DIAG_PX: f64 = 64.0;
|
||||
|
||||
/// Floor for the hub cap: small grids keep at least this many trunk hubs so
|
||||
/// the trunk stays a real network (an MST of ≥3 nodes) rather than degenerating
|
||||
/// to hub-and-spoke on every test-sized body.
|
||||
const HUB_CAP_MIN: usize = 6;
|
||||
|
||||
/// A minor settlement within this many grid-px of an existing road edge snaps
|
||||
/// onto it (nearest-point-on-polyline; the edge is split at the junction)
|
||||
/// instead of earning its own spur to a hub (T-1076 §3). Chosen as ~half of
|
||||
/// D-211's 15-px minimum city spacing: a road already passing within half a
|
||||
/// city-spacing of a settlement realistically serves it.
|
||||
const SNAP_MAX_PX: f64 = 8.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,21 +135,33 @@ pub enum RoadNodeKind {
|
||||
Settlement,
|
||||
/// A sub-settlement waypoint at the midpoint of a long edge.
|
||||
Waypoint,
|
||||
/// A topological split point where a minor settlement's spur joins an
|
||||
/// existing edge (T-1076 §3). Carries no `city_id`. The Atlas client's
|
||||
/// marker overlay filters to `Settlement` kind and skips these, like
|
||||
/// waypoints.
|
||||
Junction,
|
||||
}
|
||||
|
||||
/// One node in the road graph — a settlement or a waypoint.
|
||||
/// One node in the road graph — a settlement, a waypoint, or a spur junction.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RoadNode {
|
||||
/// The placed city's id; `None` for waypoints.
|
||||
/// The placed city's id; `None` for waypoints/junctions.
|
||||
pub city_id: Option<u64>,
|
||||
/// Position in working-heightmap-grid coordinates `(row, col)`.
|
||||
pub position: (u16, u16),
|
||||
pub kind: RoadNodeKind,
|
||||
/// Number of incident edges. Settlement junctions (`degree ≥ JUNCTION_DEGREE`)
|
||||
/// are the candidates for a `RailHeadFacing` second pass (T-1038 §6).
|
||||
/// are the candidates for the `RailHeadFacing` pass (T-1038 §6, T-1076 §4).
|
||||
pub degree: u16,
|
||||
/// For a waypoint: the edge it sits on. `None` for settlements.
|
||||
/// For a waypoint: the edge it sits on. `None` for settlements/junctions.
|
||||
pub parent_edge: Option<usize>,
|
||||
/// `true` if this settlement is a trunk hub (T-1076 §1): among the top
|
||||
/// hub-cap non-standalone-HQ settlements by `(population DESC, city_id
|
||||
/// ASC)`. The trunk (MST + secondary links) connects hubs only; every
|
||||
/// other settlement — standalone corp HQs regardless of population, and
|
||||
/// below-cap cities — is a minor node attached by snap-or-spur (§3).
|
||||
/// Always `false` for waypoints/junctions.
|
||||
pub is_hub: bool,
|
||||
}
|
||||
|
||||
/// One inter-settlement road/rail edge.
|
||||
@@ -180,7 +216,8 @@ impl RoadGraph {
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the inter-settlement road/rail graph for one body (D-211, T-1038).
|
||||
/// Build the inter-settlement road/rail graph for one body (D-211, T-1038;
|
||||
/// hub/minor refinement T-1076).
|
||||
///
|
||||
/// `placements` are the Layer-3 city placements; `ta` is the Layer-1 terrain
|
||||
/// analysis (A\* cost inputs); `river_cells` are the Layer-1 river cells (grid
|
||||
@@ -188,6 +225,25 @@ impl RoadGraph {
|
||||
/// `TerritorialStatus`, D-212) and the endpoint archetypes drive
|
||||
/// `MaintenanceAuthority`. `named_routes` is the `systems.db` named-route pool
|
||||
/// (empty today — D-223).
|
||||
///
|
||||
/// **T-1076 flow:**
|
||||
/// 0. Co-located duplicates (exact name match — post-D-242 this should never
|
||||
/// fire) collapse to one node keeping the lowest `city_id`, with a loud
|
||||
/// warning (§2).
|
||||
/// 1. Settlements partition into **hubs** — the top hub-cap by `(population
|
||||
/// DESC, city_id ASC)` among non-standalone-HQ settlements — and
|
||||
/// **minors** (standalone corp HQs regardless of population, and
|
||||
/// below-cap cities). The cap scales with the grid diagonal
|
||||
/// ([`HUB_SPACING_DIAG_PX`], floored by [`HUB_CAP_MIN`]). A body whose
|
||||
/// settlements are ALL standalone HQs falls back to ranking the HQs
|
||||
/// themselves (the graph must still connect; an HQ-only body's biggest
|
||||
/// HQ is its de-facto hub).
|
||||
/// 2. The trunk (MST + secondary links, A\*-routed) connects hubs only.
|
||||
/// 3. Each minor then attaches (§3): snapped onto the nearest point of an
|
||||
/// existing edge if within [`SNAP_MAX_PX`] (the edge is split at a new
|
||||
/// [`RoadNodeKind::Junction`] node), else A\*-spurred to the nearest hub.
|
||||
/// Minors attach in node-index order and may snap onto edges created by
|
||||
/// earlier attachments (roads accrete).
|
||||
pub fn build_road_graph(
|
||||
placements: &[CityPlacement],
|
||||
ta: &TerrainAnalysis,
|
||||
@@ -201,43 +257,55 @@ pub fn build_road_graph(
|
||||
return RoadGraph::default();
|
||||
}
|
||||
|
||||
// Settlement nodes, one per placement, in placement order (deterministic).
|
||||
// --- (0) co-location collapse (T-1076 §2) -------------------------------
|
||||
let placements = collapse_colocated(placements);
|
||||
|
||||
// --- hub / minor partition (T-1076 §1) ----------------------------------
|
||||
// Nodes stay in placement order (deterministic); hubs are flagged.
|
||||
let grid = RouteGrid::build(ta, river_cells, grid_w, grid_h);
|
||||
let body_scale = grid.body_scale();
|
||||
let hub_indices = select_hubs(&placements, body_scale);
|
||||
|
||||
let mut nodes: Vec<RoadNode> = placements
|
||||
.iter()
|
||||
.map(|p| RoadNode {
|
||||
.enumerate()
|
||||
.map(|(i, p)| RoadNode {
|
||||
city_id: Some(p.city_id),
|
||||
position: p.position,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 0,
|
||||
parent_edge: None,
|
||||
is_hub: hub_indices.contains(&i),
|
||||
})
|
||||
.collect();
|
||||
let n = nodes.len();
|
||||
|
||||
// Single city — nothing to connect.
|
||||
if n == 1 {
|
||||
if nodes.len() == 1 {
|
||||
return RoadGraph {
|
||||
nodes,
|
||||
edges: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- routing grid + cost field -----------------------------------------
|
||||
let grid = RouteGrid::build(ta, river_cells, grid_w, grid_h);
|
||||
let body_scale = grid.body_scale();
|
||||
|
||||
// --- (1) MST over Euclidean distances (strict Kruskal) -----------------
|
||||
let positions: Vec<(f64, f64)> = nodes
|
||||
.iter()
|
||||
.map(|node| (node.position.0 as f64, node.position.1 as f64))
|
||||
.collect();
|
||||
let mst_pairs = minimum_spanning_tree(&positions);
|
||||
|
||||
// --- (2) secondary links: long MST detours within the body-scale band --
|
||||
let tree_dist = all_pairs_tree_distance(n, &mst_pairs, &positions);
|
||||
// --- (1) trunk MST over HUB Euclidean distances (strict Kruskal) --------
|
||||
let hub_positions: Vec<(f64, f64)> = hub_indices.iter().map(|&i| positions[i]).collect();
|
||||
let mst_local = minimum_spanning_tree(&hub_positions);
|
||||
let mst_pairs: Vec<(usize, usize)> = mst_local
|
||||
.iter()
|
||||
.map(|&(a, b)| (hub_indices[a], hub_indices[b]))
|
||||
.collect();
|
||||
|
||||
// --- (2) secondary links among hubs: long MST detours in the band -------
|
||||
let tree_dist = all_pairs_tree_distance(nodes.len(), &mst_pairs, &positions);
|
||||
let mut wanted: BTreeSet<(usize, usize)> = mst_pairs.iter().copied().collect();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
for (a, &i) in hub_indices.iter().enumerate() {
|
||||
for &j in hub_indices.iter().skip(a + 1) {
|
||||
let (i, j) = if i < j { (i, j) } else { (j, i) };
|
||||
let euclid = euclid(positions[i], positions[j]);
|
||||
if euclid < SECONDARY_MIN_FRAC * body_scale || euclid > SECONDARY_MAX_FRAC * body_scale
|
||||
{
|
||||
@@ -250,7 +318,7 @@ pub fn build_road_graph(
|
||||
}
|
||||
}
|
||||
|
||||
// --- (3) A* route every wanted edge; drop unroutable pairs (oceans) ----
|
||||
// --- (3) A* route every trunk edge; drop unroutable pairs (oceans) ------
|
||||
let mut edges: Vec<RoadEdge> = Vec::new();
|
||||
let mut dropped = 0usize;
|
||||
for (i, j) in wanted {
|
||||
@@ -268,7 +336,7 @@ pub fn build_road_graph(
|
||||
body_scale,
|
||||
body_status,
|
||||
);
|
||||
edges.push(RoadEdge {
|
||||
edges.push(norm_edge(RoadEdge {
|
||||
from: i,
|
||||
to: j,
|
||||
path,
|
||||
@@ -276,31 +344,397 @@ pub fn build_road_graph(
|
||||
maintenance,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
});
|
||||
}));
|
||||
}
|
||||
if dropped > 0 {
|
||||
debug!(
|
||||
dropped,
|
||||
kept = edges.len(),
|
||||
"road_graph: some settlement pairs are unroutable by land (separated by water)"
|
||||
"road_graph: some hub pairs are unroutable by land (separated by water)"
|
||||
);
|
||||
}
|
||||
|
||||
// --- degree (settlement junction detection) ----------------------------
|
||||
for e in &edges {
|
||||
nodes[e.from].degree += 1;
|
||||
nodes[e.to].degree += 1;
|
||||
// --- (4) minor-settlement attach: snap-to-edge or spur-to-hub (§3) ------
|
||||
attach_minors(
|
||||
&mut nodes,
|
||||
&mut edges,
|
||||
&placements,
|
||||
&hub_indices,
|
||||
&grid,
|
||||
body_scale,
|
||||
body_status,
|
||||
);
|
||||
|
||||
// --- degrees recomputed from the final edge list ------------------------
|
||||
for node in nodes.iter_mut() {
|
||||
node.degree = 0;
|
||||
}
|
||||
let incident: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect();
|
||||
for (f, t) in incident {
|
||||
nodes[f].degree += 1;
|
||||
nodes[t].degree += 1;
|
||||
}
|
||||
|
||||
// --- (4) named-route identity join: trunk (longest) edges first --------
|
||||
// --- (5) named-route identity join: trunk (longest) edges first ---------
|
||||
assign_named_routes(&mut edges, named_routes);
|
||||
|
||||
// --- (5) waypoints at midpoints of long edges --------------------------
|
||||
// --- (6) waypoints at midpoints of long edges ---------------------------
|
||||
add_waypoints(&mut nodes, &edges);
|
||||
|
||||
RoadGraph { nodes, edges }
|
||||
}
|
||||
|
||||
/// T-1076 §2 — collapse exact-name duplicate placements to one graph node,
|
||||
/// keeping the lowest `city_id`. Post-D-242 the pool has no duplicate
|
||||
/// `(body_id, name)` groups (verified on every regen), so this should never
|
||||
/// fire — when it does, it means upstream data regressed, hence the loud
|
||||
/// warning rather than a silent dedupe.
|
||||
fn collapse_colocated(placements: &[CityPlacement]) -> Vec<CityPlacement> {
|
||||
let mut kept: Vec<CityPlacement> = Vec::with_capacity(placements.len());
|
||||
for p in placements {
|
||||
if let Some(prev) = kept.iter().find(|k| k.name == p.name) {
|
||||
warn!(
|
||||
name = %p.name,
|
||||
kept_city_id = prev.city_id,
|
||||
dropped_city_id = p.city_id,
|
||||
"road_graph: co-located duplicate settlement collapsed — \
|
||||
duplicate (body, name) groups should not exist post-D-242"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
kept.push(p.clone());
|
||||
}
|
||||
// Keep-lowest-id: placements arrive in `atlas_city_names.id` order from
|
||||
// the readers, so first-seen == lowest city_id. Guard the assumption:
|
||||
// if a lower id appears later (caller-reordered input), swap it in.
|
||||
for p in placements {
|
||||
if let Some(slot) = kept
|
||||
.iter_mut()
|
||||
.find(|k| k.name == p.name && p.city_id < k.city_id)
|
||||
{
|
||||
*slot = p.clone();
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// T-1076 §1 — the trunk-hub index set: top hub-cap settlements by
|
||||
/// `(population DESC, city_id ASC)` among non-standalone-HQ placements.
|
||||
/// Returns indices into `placements`, sorted ascending. Falls back to ranking
|
||||
/// ALL placements when every settlement on the body is a standalone HQ.
|
||||
fn select_hubs(placements: &[CityPlacement], body_scale: f64) -> Vec<usize> {
|
||||
let cap = ((body_scale / HUB_SPACING_DIAG_PX) as usize).max(HUB_CAP_MIN);
|
||||
let mut eligible: Vec<usize> = (0..placements.len())
|
||||
.filter(|&i| !placements[i].is_standalone_hq)
|
||||
.collect();
|
||||
if eligible.is_empty() {
|
||||
// HQ-only body: the graph must still connect — rank the HQs.
|
||||
eligible = (0..placements.len()).collect();
|
||||
}
|
||||
eligible.sort_by(|&a, &b| {
|
||||
placements[b]
|
||||
.population
|
||||
.cmp(&placements[a].population)
|
||||
.then(placements[a].city_id.cmp(&placements[b].city_id))
|
||||
});
|
||||
let mut chosen: Vec<usize> = eligible.into_iter().take(cap).collect();
|
||||
chosen.sort_unstable();
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Normalize an edge to the `from < to` invariant, reversing the path when the
|
||||
/// endpoints swap (the path always runs `nodes[from] → nodes[to]`).
|
||||
fn norm_edge(mut e: RoadEdge) -> RoadEdge {
|
||||
if e.from > e.to {
|
||||
std::mem::swap(&mut e.from, &mut e.to);
|
||||
e.path.reverse();
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// T-1076 §3 — attach every minor settlement (non-hub node) to the network:
|
||||
/// snap onto the nearest point of an existing edge when within
|
||||
/// [`SNAP_MAX_PX`] (splitting that edge at a new [`RoadNodeKind::Junction`]
|
||||
/// node), else A\*-spur to the nearest hub. Minors attach in ascending node
|
||||
/// order; each attachment's new edges are visible to later minors (roads
|
||||
/// accrete deterministically).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn attach_minors(
|
||||
nodes: &mut Vec<RoadNode>,
|
||||
edges: &mut Vec<RoadEdge>,
|
||||
placements: &[CityPlacement],
|
||||
hub_indices: &[usize],
|
||||
grid: &RouteGrid,
|
||||
body_scale: f64,
|
||||
body_status: &TerritorialStatus,
|
||||
) {
|
||||
let minor_indices: Vec<usize> = (0..placements.len())
|
||||
.filter(|i| !hub_indices.contains(i))
|
||||
.collect();
|
||||
|
||||
for &mi in &minor_indices {
|
||||
let mpos = nodes[mi].position;
|
||||
let march = placements[mi].political_archetype;
|
||||
|
||||
// --- nearest point on any existing edge polyline --------------------
|
||||
let mut best: Option<(f64, usize, usize, (u16, u16))> = None; // (dist², edge, seg, proj)
|
||||
for (ei, e) in edges.iter().enumerate() {
|
||||
for si in 0..e.path.len().saturating_sub(1) {
|
||||
let (d2, proj) = project_onto_segment(mpos, e.path[si], e.path[si + 1]);
|
||||
// Strictly-less keeps the first-found (lowest edge/segment
|
||||
// index) on exact ties — deterministic given fixed iteration.
|
||||
if best.is_none() || d2 < best.unwrap().0 {
|
||||
best = Some((d2, ei, si, proj));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((d2, ei, si, proj)) = best {
|
||||
if d2 <= SNAP_MAX_PX * SNAP_MAX_PX {
|
||||
// Snap: junction at the projection — unless it lands exactly on
|
||||
// an existing endpoint node, in which case attach there (no
|
||||
// degenerate zero-length split halves).
|
||||
let attach_node = if proj == edges[ei].path[0] {
|
||||
edges[ei].from
|
||||
} else if proj == *edges[ei].path.last().unwrap() {
|
||||
edges[ei].to
|
||||
} else {
|
||||
split_edge_at(nodes, edges, ei, si, proj)
|
||||
};
|
||||
let apos = nodes[attach_node].position;
|
||||
let spur_len = euclid(
|
||||
(apos.0 as f64, apos.1 as f64),
|
||||
(mpos.0 as f64, mpos.1 as f64),
|
||||
);
|
||||
// A snapped spur is ≤ SNAP_MAX_PX — a straight local road, not
|
||||
// worth an A* run. Maintenance is credited to the minor it
|
||||
// serves (a junction has no archetype of its own).
|
||||
let maintenance =
|
||||
maintenance_authority(march, march, spur_len, body_scale, body_status);
|
||||
edges.push(norm_edge(RoadEdge {
|
||||
from: attach_node,
|
||||
to: mi,
|
||||
path: vec![apos, mpos],
|
||||
length_cells: 0,
|
||||
maintenance,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- no snap: A*-spur to the nearest hub ----------------------------
|
||||
let mut best_hub: Option<(i64, usize)> = None;
|
||||
for &hi in hub_indices {
|
||||
if hi == mi {
|
||||
continue;
|
||||
}
|
||||
let hpos = nodes[hi].position;
|
||||
let dr = hpos.0 as i64 - mpos.0 as i64;
|
||||
let dc = hpos.1 as i64 - mpos.1 as i64;
|
||||
let d2 = dr * dr + dc * dc;
|
||||
if best_hub.is_none() || d2 < best_hub.unwrap().0 {
|
||||
best_hub = Some((d2, hi));
|
||||
}
|
||||
}
|
||||
let Some((_, hi)) = best_hub else {
|
||||
continue; // no hubs at all (single-node graphs return earlier)
|
||||
};
|
||||
let hpos = nodes[hi].position;
|
||||
let mposf = (mpos.0 as f64, mpos.1 as f64);
|
||||
let hposf = (hpos.0 as f64, hpos.1 as f64);
|
||||
let Some((route_cells, length_cells)) = grid.astar(mposf, hposf) else {
|
||||
debug!(
|
||||
minor = mi,
|
||||
hub = hi,
|
||||
"road_graph: minor settlement unroutable to its nearest hub (water) — left isolated"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let path = grid.to_grid_path(mpos, hpos, &route_cells);
|
||||
let maintenance = maintenance_authority(
|
||||
march,
|
||||
placements[hi].political_archetype,
|
||||
euclid(mposf, hposf),
|
||||
body_scale,
|
||||
body_status,
|
||||
);
|
||||
edges.push(norm_edge(RoadEdge {
|
||||
from: mi,
|
||||
to: hi,
|
||||
path,
|
||||
length_cells,
|
||||
maintenance,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Project grid point `p` onto the segment `a→b` (f64, clamped to the segment).
|
||||
/// Returns `(squared distance, projected point rounded to grid coords)`.
|
||||
fn project_onto_segment(p: (u16, u16), a: (u16, u16), b: (u16, u16)) -> (f64, (u16, u16)) {
|
||||
let (pr, pc) = (p.0 as f64, p.1 as f64);
|
||||
let (ar, ac) = (a.0 as f64, a.1 as f64);
|
||||
let (br, bc) = (b.0 as f64, b.1 as f64);
|
||||
let (dr, dc) = (br - ar, bc - ac);
|
||||
let len2 = dr * dr + dc * dc;
|
||||
let t = if len2 == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(((pr - ar) * dr + (pc - ac) * dc) / len2).clamp(0.0, 1.0)
|
||||
};
|
||||
let (jr, jc) = (ar + t * dr, ac + t * dc);
|
||||
let d2 = (pr - jr) * (pr - jr) + (pc - jc) * (pc - jc);
|
||||
(d2, (jr.round() as u16, jc.round() as u16))
|
||||
}
|
||||
|
||||
/// Split `edges[ei]` at `jpos` on segment `si`: a new [`RoadNodeKind::Junction`]
|
||||
/// node replaces the single edge with two halves meeting at the junction.
|
||||
/// Both halves inherit the parent's maintenance/named-route identity;
|
||||
/// `length_cells` splits proportionally by polyline vertex count (a routed-
|
||||
/// length proxy — the halves' true routed lengths are not re-measured).
|
||||
/// Returns the junction's node index. Caller guarantees `jpos` is not an
|
||||
/// endpoint of the edge's path (guarded at the call site).
|
||||
fn split_edge_at(
|
||||
nodes: &mut Vec<RoadNode>,
|
||||
edges: &mut Vec<RoadEdge>,
|
||||
ei: usize,
|
||||
si: usize,
|
||||
jpos: (u16, u16),
|
||||
) -> usize {
|
||||
let jn = nodes.len();
|
||||
nodes.push(RoadNode {
|
||||
city_id: None,
|
||||
position: jpos,
|
||||
kind: RoadNodeKind::Junction,
|
||||
degree: 0,
|
||||
parent_edge: None,
|
||||
is_hub: false,
|
||||
});
|
||||
|
||||
let old = edges[ei].clone();
|
||||
let mut path1: Vec<(u16, u16)> = old.path[..=si].to_vec();
|
||||
if path1.last() != Some(&jpos) {
|
||||
path1.push(jpos);
|
||||
}
|
||||
let mut path2: Vec<(u16, u16)> = vec![jpos];
|
||||
if old.path[si + 1..].first() == Some(&jpos) {
|
||||
path2.extend_from_slice(&old.path[si + 2..]);
|
||||
} else {
|
||||
path2.extend_from_slice(&old.path[si + 1..]);
|
||||
}
|
||||
|
||||
let total_segs = (old.path.len() - 1).max(1) as u32;
|
||||
let l1 = old.length_cells * (path1.len().saturating_sub(1) as u32) / total_segs;
|
||||
let l2 = old.length_cells - l1;
|
||||
|
||||
edges[ei] = norm_edge(RoadEdge {
|
||||
from: old.from,
|
||||
to: jn,
|
||||
path: path1,
|
||||
length_cells: l1,
|
||||
maintenance: old.maintenance,
|
||||
named_route_id: old.named_route_id.clone(),
|
||||
is_rail: old.is_rail,
|
||||
});
|
||||
edges.push(norm_edge(RoadEdge {
|
||||
from: jn,
|
||||
to: old.to,
|
||||
path: path2,
|
||||
length_cells: l2,
|
||||
maintenance: old.maintenance,
|
||||
named_route_id: old.named_route_id,
|
||||
is_rail: old.is_rail,
|
||||
}));
|
||||
jn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RailHeadFacing assignment (T-1076 §4, D-213 amended)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Octant-snapped compass bearing (0 = N, clockwise, one of
|
||||
/// {0, 45, 90, 135, 180, 225, 270, 315}) from `from` toward `to` in grid
|
||||
/// coordinates (rows grow south, columns grow east). Integer-only (D-010):
|
||||
/// the diagonal band is `|minor| * 2 > |major|` (sector boundaries at
|
||||
/// ≈26.6°/63.4° instead of the exact 22.5°/67.5° — a deliberate integer
|
||||
/// approximation; the consumer snaps to quarter-edges anyway, so the
|
||||
/// half-octant boundary shift never changes a rendered outcome class).
|
||||
fn octant_bearing(from: (u16, u16), to: (u16, u16)) -> u16 {
|
||||
let dr = to.0 as i64 - from.0 as i64; // + = south
|
||||
let dc = to.1 as i64 - from.1 as i64; // + = east
|
||||
if dr == 0 && dc == 0 {
|
||||
return 0;
|
||||
}
|
||||
let (adr, adc) = (dr.abs(), dc.abs());
|
||||
let diagonal = adr.min(adc) * 2 > adr.max(adc);
|
||||
match (diagonal, dr.signum(), dc.signum()) {
|
||||
(false, _, _) if adr >= adc => {
|
||||
if dr < 0 {
|
||||
0 // N
|
||||
} else {
|
||||
180 // S
|
||||
}
|
||||
}
|
||||
(false, _, _) => {
|
||||
if dc > 0 {
|
||||
90 // E
|
||||
} else {
|
||||
270 // W
|
||||
}
|
||||
}
|
||||
(true, r, c) => match (r < 0, c > 0) {
|
||||
(true, true) => 45, // NE
|
||||
(false, true) => 135, // SE
|
||||
(false, false) => 225, // SW
|
||||
(true, false) => 315, // NW
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// T-1076 §4 — override `founding_orientation` to
|
||||
/// [`FoundingOrientation::RailHeadFacing`] for every settlement that is a
|
||||
/// high-connectivity junction (`degree ≥` [`JUNCTION_DEGREE`], the D-213
|
||||
/// workshop criterion — paula-round3.md). The bearing faces the junction's
|
||||
/// **dominant incident edge** — longest `length_cells`, ties broken by lowest
|
||||
/// edge index — toward that edge's other endpoint, octant-snapped
|
||||
/// ([`octant_bearing`]). Runs AFTER [`build_road_graph`] in the cascade
|
||||
/// (orientation is a Layer-3 output, but rail-head facing is knowable only
|
||||
/// once Layer 2 exists); the Layer-4 skeleton picks the override up at
|
||||
/// dispatch (`plugin.rs` copies `placement.founding_orientation` into the
|
||||
/// generation context).
|
||||
pub fn assign_railhead_orientations(placements: &mut [CityPlacement], graph: &RoadGraph) {
|
||||
for idx in graph.high_connectivity_junctions() {
|
||||
let node = &graph.nodes[idx];
|
||||
let Some(city_id) = node.city_id else {
|
||||
continue; // settlement junctions always carry a city_id
|
||||
};
|
||||
// Dominant incident edge: longest; first (lowest index) on ties.
|
||||
let mut dominant: Option<(u32, usize)> = None;
|
||||
for (ei, e) in graph.edges.iter().enumerate() {
|
||||
if e.from != idx && e.to != idx {
|
||||
continue;
|
||||
}
|
||||
if dominant.is_none() || e.length_cells > dominant.unwrap().0 {
|
||||
dominant = Some((e.length_cells, ei));
|
||||
}
|
||||
}
|
||||
let Some((_, ei)) = dominant else {
|
||||
continue; // degree ≥ 3 guarantees incident edges; defensive
|
||||
};
|
||||
let e = &graph.edges[ei];
|
||||
let other = if e.from == idx { e.to } else { e.from };
|
||||
let bearing = octant_bearing(node.position, graph.nodes[other].position);
|
||||
if let Some(p) = placements.iter_mut().find(|p| p.city_id == city_id) {
|
||||
p.founding_orientation = FoundingOrientation::RailHeadFacing {
|
||||
bearing_degrees: bearing,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MaintenanceAuthority derivation (workshop OQ-R3-4)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -373,6 +807,7 @@ fn add_waypoints(nodes: &mut Vec<RoadNode>, edges: &[RoadEdge]) {
|
||||
kind: RoadNodeKind::Waypoint,
|
||||
degree: 0,
|
||||
parent_edge: Some(ei),
|
||||
is_hub: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -749,9 +1184,18 @@ mod tests {
|
||||
founding_orientation: FoundingOrientation::Cardinal,
|
||||
population: 100_000,
|
||||
is_capital: false,
|
||||
is_standalone_hq: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `placement` variant marked as a D-242 standalone corp HQ (T-1076 §1).
|
||||
fn hq_placement(city_id: u64, pos: (u16, u16), population: i64) -> CityPlacement {
|
||||
let mut p = placement(city_id, pos, PoliticalArchetype::Corporate);
|
||||
p.population = population;
|
||||
p.is_standalone_hq = true;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_empty_graph() {
|
||||
let hm = flat_hm(64, 32);
|
||||
@@ -1065,4 +1509,279 @@ mod tests {
|
||||
"a central hub should be a high-connectivity junction"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── T-1076 §1 — hub selection excludes standalone HQs ──────────────────
|
||||
|
||||
#[test]
|
||||
fn standalone_hq_is_minor_regardless_of_population() {
|
||||
// Gate-Corporation shape: an HQ with a HUGE population must still be a
|
||||
// minor node — hubs are the significant CITIES (D-242). The two small
|
||||
// ordinary cities are the hubs; the HQ attaches with a single edge.
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
let placements = vec![
|
||||
hq_placement(1, (16, 48), 909_090_165), // massive HQ, far from the cities
|
||||
placement(2, (8, 8), PoliticalArchetype::Pioneer), // pop 100k
|
||||
placement(3, (24, 8), PoliticalArchetype::Pioneer), // pop 100k
|
||||
];
|
||||
let g = build_road_graph(
|
||||
&placements,
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
);
|
||||
let hq = g.nodes.iter().find(|n| n.city_id == Some(1)).unwrap();
|
||||
assert!(!hq.is_hub, "standalone HQ must never be a trunk hub");
|
||||
assert_eq!(hq.degree, 1, "the HQ hangs off the network by one spur");
|
||||
for n in g.nodes.iter().filter(|n| matches!(n.city_id, Some(2 | 3))) {
|
||||
assert!(n.is_hub, "ordinary cities are the hubs");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hq_only_body_falls_back_to_hq_hubs() {
|
||||
// A body whose settlements are ALL standalone HQs still gets a
|
||||
// connected trunk (the HQs are its de-facto hubs).
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
let placements = vec![
|
||||
hq_placement(1, (8, 8), 500_000),
|
||||
hq_placement(2, (24, 40), 200_000),
|
||||
];
|
||||
let g = build_road_graph(
|
||||
&placements,
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
g.nodes
|
||||
.iter()
|
||||
.filter(|n| n.kind == RoadNodeKind::Settlement)
|
||||
.all(|n| n.is_hub),
|
||||
"fallback: HQs become hubs (waypoints are never hubs)"
|
||||
);
|
||||
assert_eq!(g.edges.len(), 1, "two hubs → one trunk edge");
|
||||
}
|
||||
|
||||
// ─── T-1076 §2 — co-location collapse ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn colocated_duplicates_collapse_to_lowest_id() {
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
// Two placements with the SAME name (the post-D-242 impossible case) +
|
||||
// one distinct city. The duplicate collapses to the lowest city_id.
|
||||
let mut dup_hi = placement(7, (10, 10), PoliticalArchetype::Pioneer);
|
||||
dup_hi.name = "Twinned".into();
|
||||
let mut dup_lo = placement(2, (12, 12), PoliticalArchetype::Pioneer);
|
||||
dup_lo.name = "Twinned".into();
|
||||
let other = placement(3, (24, 40), PoliticalArchetype::Pioneer);
|
||||
let g = build_road_graph(
|
||||
&[dup_hi, dup_lo, other],
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
);
|
||||
let settlements: Vec<&RoadNode> = g
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.kind == RoadNodeKind::Settlement)
|
||||
.collect();
|
||||
assert_eq!(settlements.len(), 2, "duplicate collapsed to one node");
|
||||
assert!(
|
||||
settlements.iter().any(|n| n.city_id == Some(2)),
|
||||
"the LOWEST city_id survives the collapse"
|
||||
);
|
||||
assert!(
|
||||
settlements.iter().all(|n| n.city_id != Some(7)),
|
||||
"the higher-id duplicate is dropped"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── T-1076 §3 — hybrid minor-settlement attach ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn minor_snaps_onto_nearby_trunk_edge_with_split() {
|
||||
// Trunk between two far-apart hubs runs roughly along a row; a minor
|
||||
// (7th settlement, beyond the test-grid hub cap of 6) sits within
|
||||
// SNAP_MAX_PX of it → the edge splits at a Junction and the minor
|
||||
// spurs to it.
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
let mut placements: Vec<CityPlacement> = vec![
|
||||
placement(1, (16, 4), PoliticalArchetype::Pioneer),
|
||||
placement(2, (16, 60), PoliticalArchetype::Pioneer),
|
||||
placement(3, (4, 4), PoliticalArchetype::Pioneer),
|
||||
placement(4, (4, 60), PoliticalArchetype::Pioneer),
|
||||
placement(5, (28, 4), PoliticalArchetype::Pioneer),
|
||||
placement(6, (28, 60), PoliticalArchetype::Pioneer),
|
||||
];
|
||||
// Give the six hubs clear population dominance; the 7th is below-cap.
|
||||
for p in placements.iter_mut() {
|
||||
p.population = 1_000_000;
|
||||
}
|
||||
let mut minor = placement(7, (13, 32), PoliticalArchetype::Pioneer);
|
||||
minor.population = 10_000; // below the six → minor by cap
|
||||
placements.push(minor);
|
||||
|
||||
let g = build_road_graph(
|
||||
&placements,
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
);
|
||||
let minor_node = g.nodes.iter().find(|n| n.city_id == Some(7)).unwrap();
|
||||
assert!(!minor_node.is_hub, "7th settlement is beyond the hub cap");
|
||||
let junctions: Vec<&RoadNode> = g
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.kind == RoadNodeKind::Junction)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
junctions.len(),
|
||||
1,
|
||||
"the minor within snap range splits exactly one edge"
|
||||
);
|
||||
// Invariants hold for every edge, including the split halves + spur.
|
||||
for e in &g.edges {
|
||||
assert!(e.from < e.to, "from < to must hold after splits");
|
||||
assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap());
|
||||
assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap());
|
||||
}
|
||||
// The junction carries trunk halves + the spur → degree 3.
|
||||
let ji = g
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|n| n.kind == RoadNodeKind::Junction)
|
||||
.unwrap();
|
||||
assert_eq!(g.nodes[ji].degree, 3, "two split halves + one spur");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minor_far_from_edges_spurs_to_nearest_hub() {
|
||||
// 7 settlements; the 7th (below-cap minor) sits far from every trunk
|
||||
// edge → it gets an A*-routed spur to its nearest hub, no junction.
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
let mut placements: Vec<CityPlacement> = vec![
|
||||
placement(1, (4, 4), PoliticalArchetype::Pioneer),
|
||||
placement(2, (4, 30), PoliticalArchetype::Pioneer),
|
||||
placement(3, (4, 56), PoliticalArchetype::Pioneer),
|
||||
placement(4, (12, 4), PoliticalArchetype::Pioneer),
|
||||
placement(5, (12, 30), PoliticalArchetype::Pioneer),
|
||||
placement(6, (12, 56), PoliticalArchetype::Pioneer),
|
||||
];
|
||||
for p in placements.iter_mut() {
|
||||
p.population = 1_000_000;
|
||||
}
|
||||
let mut minor = placement(7, (30, 30), PoliticalArchetype::Pioneer);
|
||||
minor.population = 10_000;
|
||||
placements.push(minor);
|
||||
|
||||
let g = build_road_graph(
|
||||
&placements,
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
g.nodes.iter().all(|n| n.kind != RoadNodeKind::Junction),
|
||||
"far minor must not split any edge"
|
||||
);
|
||||
let mi = g.nodes.iter().position(|n| n.city_id == Some(7)).unwrap();
|
||||
let spur = g
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| e.from == mi || e.to == mi)
|
||||
.expect("minor must be connected by a spur");
|
||||
let other = if spur.from == mi { spur.to } else { spur.from };
|
||||
assert!(g.nodes[other].is_hub, "the spur lands on a hub");
|
||||
// Nearest hub to (30,30) is node 5 at (12,30).
|
||||
assert_eq!(g.nodes[other].city_id, Some(5));
|
||||
}
|
||||
|
||||
// ─── T-1076 §4 — RailHeadFacing assignment ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn octant_bearing_snaps_to_compass_octants() {
|
||||
// Rows grow south, columns grow east; 0 = N, clockwise.
|
||||
assert_eq!(octant_bearing((10, 10), (0, 10)), 0); // due north
|
||||
assert_eq!(octant_bearing((10, 10), (0, 20)), 45); // north-east
|
||||
assert_eq!(octant_bearing((10, 10), (10, 20)), 90); // due east
|
||||
assert_eq!(octant_bearing((10, 10), (20, 20)), 135); // south-east
|
||||
assert_eq!(octant_bearing((10, 10), (20, 10)), 180); // due south
|
||||
assert_eq!(octant_bearing((10, 10), (20, 0)), 225); // south-west
|
||||
assert_eq!(octant_bearing((10, 10), (10, 0)), 270); // due west
|
||||
assert_eq!(octant_bearing((10, 10), (0, 0)), 315); // north-west
|
||||
assert_eq!(octant_bearing((10, 10), (10, 10)), 0); // degenerate
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn railhead_orientation_assigned_at_high_connectivity_junctions() {
|
||||
// The junction_detection topology: a central hub wired to 4 others.
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
let mut placements = vec![
|
||||
placement(1, (16, 32), PoliticalArchetype::Pioneer), // central hub
|
||||
placement(2, (4, 8), PoliticalArchetype::Pioneer),
|
||||
placement(3, (4, 56), PoliticalArchetype::Pioneer),
|
||||
placement(4, (28, 8), PoliticalArchetype::Pioneer),
|
||||
placement(5, (28, 56), PoliticalArchetype::Pioneer),
|
||||
];
|
||||
let g = build_road_graph(
|
||||
&placements,
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
);
|
||||
let junctions = g.high_connectivity_junctions();
|
||||
assert!(!junctions.is_empty(), "central hub is a junction");
|
||||
|
||||
assign_railhead_orientations(&mut placements, &g);
|
||||
for &ji in &junctions {
|
||||
let cid = g.nodes[ji].city_id.unwrap();
|
||||
let p = placements.iter().find(|p| p.city_id == cid).unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
p.founding_orientation,
|
||||
FoundingOrientation::RailHeadFacing { .. }
|
||||
),
|
||||
"junction settlement {cid} gets RailHeadFacing"
|
||||
);
|
||||
if let FoundingOrientation::RailHeadFacing { bearing_degrees } = p.founding_orientation
|
||||
{
|
||||
assert!(bearing_degrees < 360 && bearing_degrees % 45 == 0);
|
||||
}
|
||||
}
|
||||
// Non-junction settlements keep their attractor-derived orientation.
|
||||
for p in placements.iter().filter(|p| {
|
||||
!junctions
|
||||
.iter()
|
||||
.any(|&ji| g.nodes[ji].city_id == Some(p.city_id))
|
||||
}) {
|
||||
assert!(matches!(
|
||||
p.founding_orientation,
|
||||
FoundingOrientation::Cardinal
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +813,25 @@ fn coastal_edge(orientation: &FoundingOrientation) -> Option<Edge> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The quarter edge facing the settlement's rail head, from a
|
||||
/// `RailHeadFacing` founding orientation (T-1076 §4, D-213 amended). Same
|
||||
/// octant→cardinal snap as [`coastal_edge`]: the bearing points toward the
|
||||
/// dominant incident road/rail edge, and the blocks on that quarter edge
|
||||
/// present flush freight frontage to it — the rail-head analogue of the
|
||||
/// D-234b waterfront quay rule, reusing the same flush-margin machinery.
|
||||
fn railhead_edge(orientation: &FoundingOrientation) -> Option<Edge> {
|
||||
if let FoundingOrientation::RailHeadFacing { bearing_degrees } = orientation {
|
||||
Some(match bearing_degrees % 360 {
|
||||
d if !(45..315).contains(&d) => Edge::North,
|
||||
d if d < 135 => Edge::East,
|
||||
d if d < 225 => Edge::South,
|
||||
_ => Edge::West,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether block `(row, col)` sits on the quarter's `edge` (4×4 grid).
|
||||
fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
|
||||
match edge {
|
||||
@@ -1029,15 +1048,19 @@ pub fn assign_all_block_tags(
|
||||
chain: SeedChain,
|
||||
exterior_catalog: &ExteriorCatalog,
|
||||
) -> BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> {
|
||||
// Water-facing quarter edge from the settlement's coastal founding
|
||||
// orientation (D-234b); blocks on it present flush to the quay.
|
||||
let water_edge = coastal_edge(&context.founding_orientation);
|
||||
// Flush-frontage quarter edge: water-facing from a coastal founding
|
||||
// orientation (D-234b — blocks present flush to the quay), or rail-facing
|
||||
// from a RailHeadFacing orientation (T-1076 §4 — blocks present flush
|
||||
// freight frontage to the rail head; same machinery). The two are
|
||||
// mutually exclusive by construction (one orientation per settlement).
|
||||
let flush_edge = coastal_edge(&context.founding_orientation)
|
||||
.or_else(|| railhead_edge(&context.founding_orientation));
|
||||
let mut map = BTreeMap::new();
|
||||
for row in 0..4u8 {
|
||||
for col in 0..4u8 {
|
||||
let block = &skeleton.blocks[row as usize][col as usize];
|
||||
let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64);
|
||||
let waterfront = water_edge.filter(|&e| block_on_quarter_edge(row, col, e));
|
||||
let waterfront = flush_edge.filter(|&e| block_on_quarter_edge(row, col, e));
|
||||
let tags = assign_block_tags(
|
||||
block,
|
||||
skeleton,
|
||||
@@ -2303,6 +2326,26 @@ mod tests {
|
||||
assert_eq!(coastal_edge(&FoundingOrientation::Cardinal), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn railhead_edge_maps_bearing_to_cardinal() {
|
||||
// T-1076 §4: same octant→cardinal snap as the coastal quay rule, keyed
|
||||
// on RailHeadFacing. Non-rail orientations yield no rail edge, and a
|
||||
// coastal orientation is not a rail edge (the two flush-frontage
|
||||
// sources are distinct variants).
|
||||
let r = |d| railhead_edge(&FoundingOrientation::RailHeadFacing { bearing_degrees: d });
|
||||
assert_eq!(r(0), Some(Edge::North));
|
||||
assert_eq!(r(45), Some(Edge::East));
|
||||
assert_eq!(r(90), Some(Edge::East));
|
||||
assert_eq!(r(180), Some(Edge::South));
|
||||
assert_eq!(r(270), Some(Edge::West));
|
||||
assert_eq!(r(315), Some(Edge::North));
|
||||
assert_eq!(railhead_edge(&FoundingOrientation::Cardinal), None);
|
||||
assert_eq!(
|
||||
railhead_edge(&FoundingOrientation::Coastal { facing_degrees: 0 }),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waterfront_footprints_present_to_the_quay() {
|
||||
// The water-facing (north) edge drops its setback → buildings sit flush
|
||||
|
||||
@@ -410,6 +410,16 @@ pub enum FoundingOrientation {
|
||||
Cardinal,
|
||||
/// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0–359.
|
||||
Free { bearing_degrees: u16 },
|
||||
/// Street grid faces the settlement's rail head (D-213 amended, T-1076 §4).
|
||||
/// Assigned POST-placement by the Layer-2 road-graph pass
|
||||
/// (`road_graph::assign_railhead_orientations`) — never by the attractor
|
||||
/// match — to settlements that are high-connectivity junctions
|
||||
/// (`RoadGraph::high_connectivity_junctions`, degree ≥ 3, per the D-213
|
||||
/// workshop source paula-round3.md). `bearing_degrees`: octant-snapped
|
||||
/// compass bearing (0–359, 0 = N, clockwise; integer octants only, D-010)
|
||||
/// from the settlement toward the dominant (longest) incident road/rail
|
||||
/// edge — the direction the freight frontage faces.
|
||||
RailHeadFacing { bearing_degrees: u16 },
|
||||
}
|
||||
|
||||
/// Who maintains an inter-settlement road/rail edge — readable in the road's
|
||||
|
||||
Reference in New Issue
Block a user