Preserve location-local route identity

This commit is contained in:
Meta Vens 2026-08-24 11:27:43 +02:00
commit fc5cce0a21
6 changed files with 100 additions and 41 deletions

View file

@ -1118,7 +1118,7 @@ impl WorldCompiler {
.await?;
let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision);
let base_prompt = format!(
"Compile only the requested bounded destination region. Every new location id must be new. Return explicit origin_routes owned by origin id {} into the new region, and give every such destination a reciprocal route back to the origin with the same positive travel time. Route IDs are globally unique. Do not rewrite existing geography. Every place has a non-empty name, valid container, and concrete persistent features. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}",
"Compile only the requested bounded destination region. Every new location id must be new. Return explicit origin_routes owned by origin id {} into the new region, and give every such destination a reciprocal route back to the origin with the same positive travel time. Route IDs are stable keys local to their exact origin location; the same local key may exist under another origin without naming the same route. Do not rewrite existing geography. Every place has a non-empty name, valid container, and concrete persistent features. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}",
origin_location_id,
serde_json::to_string(&campaign.locations)?,
destination_request,
@ -2447,12 +2447,17 @@ pub fn validate_region_expansion(
.iter()
.map(|location| (location.id.as_str(), location))
.collect::<BTreeMap<_, _>>();
let existing_route_ids = campaign
let existing_origin_route_ids = campaign
.locations
.values()
.flat_map(|location| location.routes.keys().map(String::as_str))
.collect::<BTreeSet<_>>();
let mut new_route_ids = BTreeSet::new();
.get(&expansion.origin_location_id)
.map(|location| {
location
.routes
.keys()
.map(String::as_str)
.collect::<BTreeSet<_>>()
})
.unwrap_or_default();
if expansion.origin_routes.is_empty() {
return Err(anyhow!(
"destination expansion has no explicit route from the origin"
@ -2460,8 +2465,7 @@ pub fn validate_region_expansion(
}
for (route_id, route) in &expansion.origin_routes {
if route_id.trim().is_empty()
|| existing_route_ids.contains(route_id.as_str())
|| !new_route_ids.insert(route_id.as_str())
|| existing_origin_route_ids.contains(route_id.as_str())
|| route.travel_minutes == 0
|| route.distance.trim().is_empty()
|| !new_ids.contains(route.destination_id.as_str())
@ -2505,8 +2509,6 @@ pub fn validate_region_expansion(
}
for (route_id, route) in &location.routes {
if route_id.trim().is_empty()
|| existing_route_ids.contains(route_id.as_str())
|| !new_route_ids.insert(route_id.as_str())
|| route.travel_minutes == 0
|| route.distance.trim().is_empty()
|| !known(&route.destination_id)
@ -5065,6 +5067,26 @@ mod tests {
);
}
#[test]
fn region_expansion_route_keys_are_local_to_their_origin() {
let campaign = crate::resolution::tests::campaign(0, 1);
let mut expansion = valid_region_expansion();
let outward = expansion
.origin_routes
.remove("route:center-annex")
.unwrap();
expansion.origin_routes.insert("road".into(), outward);
let returning = expansion.locations[0]
.routes
.remove("route:annex-center")
.unwrap();
expansion.locations[0]
.routes
.insert("road".into(), returning);
validate_region_expansion(&campaign, &expansion).unwrap();
}
#[test]
fn region_expansion_rejects_containment_cycles() {
let campaign = crate::resolution::tests::campaign(0, 1);

View file

@ -7246,6 +7246,23 @@ mod tests {
travel_minutes: 20,
},
);
seed.locations.insert(
"yard".into(),
Location {
id: "yard".into(),
name: "Yard".into(),
container_id: None,
routes: BTreeMap::from([(
"harbor".into(),
Route {
destination_id: "harbor".into(),
distance: "farther away".into(),
travel_minutes: 45,
},
)]),
persistent_features: vec!["freight apron".into()],
},
);
let membership = two_player_membership(&mut seed);
let campaign_id = seed.id;
let start_time = seed.world_time;

View file

@ -94,7 +94,12 @@ pub fn lower_group_travel(
let route_minutes = campaign
.locations
.get(origin_location_id)
.and_then(|location| location.routes.get(&route_id))
.and_then(|location| {
location
.routes
.values()
.find(|route| route.destination_id == destination_location_id)
})
.map(|route| route.travel_minutes)
.ok_or_else(|| anyhow!("group travel route vanished"))?;
if route_minutes != travel_minutes {
@ -300,7 +305,7 @@ pub fn lower_region_expansion(
for (route_id, route) in &expansion.origin_routes {
mutations.push(WorldMutation::ChangeTopology {
operation: TopologyMutationOperation::Add,
edge_id: route_id.clone(),
edge_id: component_route_id(&expansion.origin_location_id, route_id),
from_place: origin.clone(),
to_place: place_subject(&route.destination_id),
distance: Some(route.distance.clone()),
@ -311,7 +316,7 @@ pub fn lower_region_expansion(
for (route_id, route) in &location.routes {
mutations.push(WorldMutation::ChangeTopology {
operation: TopologyMutationOperation::Add,
edge_id: route_id.clone(),
edge_id: component_route_id(&location.id, route_id),
from_place: place_subject(&location.id),
to_place: place_subject(&route.destination_id),
distance: Some(route.distance.clone()),
@ -362,13 +367,18 @@ fn project_accepted_region_expansion(
.place_profiles
.get(&subject)
.ok_or_else(|| anyhow!("accepted expansion lost a place profile"))?;
let routes = next
.topology
.values()
.filter(|edge| edge.from_place == subject && edge.open)
.map(|edge| {
let routes = requested
.routes
.keys()
.map(|route_id| {
let edge_id = component_route_id(&requested.id, route_id);
let edge = next
.topology
.get(&edge_id)
.filter(|edge| edge.from_place == subject && edge.open)
.ok_or_else(|| anyhow!("accepted expansion lost a local route"))?;
Ok((
edge.id.clone(),
route_id.clone(),
crate::domain::Route {
destination_id: edge.to_place.id.clone(),
distance: edge.distance.clone(),
@ -398,9 +408,10 @@ fn project_accepted_region_expansion(
.get_mut(&expansion.origin_location_id)
.ok_or_else(|| anyhow!("accepted expansion origin vanished"))?;
for route_id in expansion.origin_routes.keys() {
let edge_id = component_route_id(&expansion.origin_location_id, route_id);
let edge = next
.topology
.get(route_id)
.get(&edge_id)
.filter(|edge| edge.from_place.id == expansion.origin_location_id && edge.open)
.ok_or_else(|| anyhow!("accepted expansion lost an origin route"))?;
origin.routes.insert(
@ -1083,12 +1094,16 @@ fn exact_route_id(campaign: &Campaign, origin: &str, destination: &str) -> Resul
.get(origin)
.and_then(|location| {
location.routes.iter().find_map(|(route_id, route)| {
(route.destination_id == destination).then(|| route_id.clone())
(route.destination_id == destination).then(|| component_route_id(origin, route_id))
})
})
.ok_or_else(|| anyhow!("strategic relocation has no exact topology edge"))
}
fn component_route_id(origin: &str, local_route_id: &str) -> String {
format!("route:{}:{origin}:{local_route_id}", origin.len())
}
pub fn digest_serializable<T: serde::Serialize + ?Sized>(value: &T) -> Result<String> {
let bytes = rmp_serde::to_vec_named(value)?;
Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
@ -1174,17 +1189,8 @@ fn foreground_mutations(
.actors
.get(actor_id)
.ok_or_else(|| anyhow!("outcome actor vanished"))?;
let location = campaign
.locations
.get(&actor.location_id)
.ok_or_else(|| anyhow!("actor origin vanished"))?;
let route_id = location
.routes
.iter()
.find_map(|(route_id, route)| {
(route.destination_id == *destination_id).then(|| route_id.clone())
})
.ok_or_else(|| anyhow!("outcome movement has no exact route"))?;
let route_id = exact_route_id(campaign, &actor.location_id, destination_id)
.map_err(|_| anyhow!("outcome movement has no exact route"))?;
mutations.push(WorldMutation::Relocate {
subject: actor_subject(actor_id),
from_place: place_subject(&actor.location_id),
@ -1695,10 +1701,11 @@ fn component_snapshot(campaign: &Campaign) -> Result<ComponentWorldState> {
}
for location in campaign.locations.values() {
for (route_id, route) in &location.routes {
let edge_id = component_route_id(&location.id, route_id);
state.topology.insert(
route_id.clone(),
edge_id.clone(),
TopologyComponentState {
id: route_id.clone(),
id: edge_id,
from_place: place_subject(&location.id),
to_place: place_subject(&route.destination_id),
distance: route.distance.clone(),

View file

@ -234,18 +234,19 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V
} else {
let default_destination_id = location
.routes
.keys()
.values()
.next()
.map(|route| route.destination_id.as_str())
.expect("non-empty routes have a first destination");
children.push(json!({
"id":"dungeon.travel.destination",
"kind":"control.select",
"props":{"label":"Group destination","value":default_destination_id},
"stateBindings":[local_draft("destination_location_id","choice")],
"children":location.routes.iter().filter_map(|(destination_id,route)|campaign.locations.get(destination_id).map(|destination|json!({
"id":format!("dungeon.travel.option.{destination_id}"),
"children":location.routes.values().filter_map(|route|campaign.locations.get(&route.destination_id).map(|destination|json!({
"id":format!("dungeon.travel.option.{}",route.destination_id),
"kind":"control.option",
"props":{"value":destination_id,"label":format!("{} · {} minutes · {}",destination.name,route.travel_minutes,route.distance)},
"props":{"value":route.destination_id,"label":format!("{} · {} minutes · {}",destination.name,route.travel_minutes,route.distance)},
"children":[]
}))).collect::<Vec<_>>()
}));
@ -272,7 +273,7 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V
"world_revision":campaign.revision,
"viewer_actor_id":viewer_actor_id,
"player_location_id":player.location_id,
"reachable_destinations":location.routes.iter().filter_map(|(destination_id,route)|campaign.locations.get(destination_id).map(|destination|json!({"id":destination_id,"name":destination.name,"travel_minutes":route.travel_minutes,"distance":route.distance}))).collect::<Vec<_>>(),
"reachable_destinations":location.routes.values().filter_map(|route|campaign.locations.get(&route.destination_id).map(|destination|json!({"id":route.destination_id,"name":destination.name,"travel_minutes":route.travel_minutes,"distance":route.distance}))).collect::<Vec<_>>(),
"resolution":{
"policy":campaign.resolution_policy,
"effective_budget":effective_budget,
@ -781,7 +782,7 @@ mod tests {
},
);
campaign.locations.get_mut("center").unwrap().routes.insert(
"harbor".into(),
"road-to-harbor".into(),
crate::domain::Route {
destination_id: "harbor".into(),
distance: "nearby".into(),

View file

@ -245,6 +245,13 @@ Components are sparse. A place does not need knowledge; a proposition does not
need occupancy. Component applicability is determined by subject kind and the
specific admitted entity, not by a universal bag of optional fields.
Route keys are scoped to their owning origin location. Canonical route identity
is therefore the pair `(origin_location_id, local_route_id)`, never the bare
map key. The transition overlay encodes that pair into a collision-free edge
identity before validation and maps accepted edges back to the original local
keys. Surface projections use each route's exact `destination_id`; they do not
reinterpret a local route key as a destination or global edge ID.
## The mutation vocabulary
`WorldMutation` is a closed tagged union. Each variant owns one semantic

View file

@ -1019,7 +1019,12 @@ proposition content, evidence references, discovery locations, containment,
route identity, distance, and topology are validated on the component overlay;
the aggregate `Location`, `Route`, and `WorldFact` rows are reconstructed only
from accepted component state. The previous direct location/fact insertion
loops are gone.
loops are gone. A route map key is local to its exact origin. The component
overlay derives its edge identity from `(origin_location_id, local_route_id)`;
the player surface uses the route's exact destination field, and accepted
region expansion restores the original local key. Reusing `road`, `harbor`, or
a destination-shaped key under another location cannot overwrite an unrelated
edge or make a surface-advertised route fail transition admission.
Initial compiler publication is a bounded creation transaction, not a fictional
world transition. `CampaignRegistry` owns discoverability, installs the