diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index c7ec1fe..f1a7d0d 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -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::>(); - let existing_route_ids = campaign + let existing_origin_route_ids = campaign .locations - .values() - .flat_map(|location| location.routes.keys().map(String::as_str)) - .collect::>(); - let mut new_route_ids = BTreeSet::new(); + .get(&expansion.origin_location_id) + .map(|location| { + location + .routes + .keys() + .map(String::as_str) + .collect::>() + }) + .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); diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index 21ce569..5039a25 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -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; diff --git a/crates/ghostlight-dungeon/src/legacy_transition.rs b/crates/ghostlight-dungeon/src/legacy_transition.rs index d64c782..f47ebd2 100644 --- a/crates/ghostlight-dungeon/src/legacy_transition.rs +++ b/crates/ghostlight-dungeon/src/legacy_transition.rs @@ -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(value: &T) -> Result { 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 { } 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(), diff --git a/crates/ghostlight-dungeon/src/surface.rs b/crates/ghostlight-dungeon/src/surface.rs index 157b251..a20f23d 100644 --- a/crates/ghostlight-dungeon/src/surface.rs +++ b/crates/ghostlight-dungeon/src/surface.rs @@ -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::>() })); @@ -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::>(), + "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::>(), "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(), diff --git a/docs/architecture/ghostlight-transition-algebra.md b/docs/architecture/ghostlight-transition-algebra.md index d2e36d8..5f52f63 100644 --- a/docs/architecture/ghostlight-transition-algebra.md +++ b/docs/architecture/ghostlight-transition-algebra.md @@ -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 diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index badc0a8..0ceccb6 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -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