Preserve location-local route identity
This commit is contained in:
parent
101251f2be
commit
fc5cce0a21
6 changed files with 100 additions and 41 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue