diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 3881852..bab338c 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -67,6 +67,7 @@ pub struct CustomStart { struct RequiredRelationshipActor { id: String, name: String, + approved_relationship_descriptions: Vec, } #[derive(Clone, Debug, PartialEq)] @@ -364,6 +365,7 @@ struct PrivateRelationshipActorCandidate { equipment: BTreeSet, conditions: BTreeSet, obligations: BTreeSet, + relationships: Vec, goals: Vec, #[serde(default)] memories: Vec, @@ -991,13 +993,21 @@ impl WorldCompiler { validate_required_relationship_actor_inputs(anchors)?; let private_context = serde_json::json!({ "start": start, - "approved_private_subject_names": anchors.iter().map(|anchor| anchor.name.as_str()).collect::>(), + "approved_private_subjects": anchors.iter().map(|anchor| serde_json::json!({ + "name":anchor.name, + "approved_relationship_descriptions":anchor.approved_relationship_descriptions, + })).collect::>(), "locations": seed.locations.iter().map(|location| serde_json::json!({ "id": location.id, "name": location.name, "container_id": location.container_id, "persistent_features": location.persistent_features, })).collect::>(), + "public_actors": seed.actors.iter().map(|actor| serde_json::json!({ + "id": actor.id, + "name": actor.name, + "location_id": actor.location_id, + })).collect::>(), "public_institutions": seed.institutions.iter().map(|institution| serde_json::json!({ "id": institution.id, "name": institution.name, @@ -1011,7 +1021,7 @@ impl WorldCompiler { })).collect::>(), }); let base_prompt = format!( - "PRIVATE RELATIONSHIP ACTOR COMPILATION\nThis is a private branch-local stage. Synthesize exactly one ordinary actor candidate for every approved_private_subject_name in the supplied order. Copy each name exactly. Choose one exact location id from the supplied topology and give the person only state justified by the public world frame and their approved name. Return actor candidates only: no canonical IDs, relationships, narration, facts, evidence gaps, branch assumptions, agency profiles, or changes to the public world. Do not infer the player's relationship, secret, or private history.\nCONTEXT:\n{}", + "PRIVATE RELATIONSHIP ACTOR COMPILATION\nThis is a private branch-local stage. Synthesize exactly one ordinary actor candidate for every approved_private_subject in the supplied order. Copy each name exactly. Choose one exact location id from the supplied topology. The approved relationship descriptions are private, player-approved context: use them only to preserve facts explicitly owned by the counterpart, such as that person's role, affiliation, capabilities, knowledge, goals, obligations, or stated feelings. Do not infer that the counterpart knows the player's secrets or private history, and do not convert player-only interpretation into counterpart knowledge. When ownership is ambiguous, omit the fact and let the private approval preview expose the gap. Candidate relationships may name only exact actor, institution, or population IDs supplied in the public world context and should preserve an explicit affiliation or obligation; never invent a player ID or relationship-anchor ID. Return actor candidates only: no canonical IDs, narration, facts, evidence gaps, branch assumptions, agency profiles, or changes to the public world.\nCONTEXT:\n{}", serde_json::to_string(&private_context)? ); let schema = serde_json::to_value(schema_for!(PrivateRelationshipActorSet))?; @@ -2107,8 +2117,9 @@ fn approved_relationship_plan(brief: &ApprovedCampaignBrief) -> Result::new(); let mut targets = BTreeMap::new(); for character in &brief.characters { - for subject in character.relationships.keys() { + for (subject, description) in &character.relationships { validate_user_text("relationship subject", subject, 160)?; + validate_user_text("relationship description", description, 1_000)?; let normalized = normalized_identity(subject); let target = if let Some(actor_id) = brief.member_actor_ids.get(subject) { actor_id.clone() @@ -2128,12 +2139,22 @@ fn approved_relationship_plan(brief: &ApprovedCampaignBrief) -> Result 8 + { + return Err(anyhow!( + "relationship actors require one to eight approved descriptions" + )); + } + for description in &anchor.approved_relationship_descriptions { + validate_user_text("relationship description", description, 1_000)?; + } if !anchor.id.starts_with("relationship-anchor:") || anchor.id.chars().count() > 80 || !ids.insert(anchor.id.clone()) @@ -2220,6 +2251,13 @@ fn materialize_private_relationship_actors( .chain(seed.gestalts.iter().map(|item| item.id.as_str())) .chain(seed.gestalt_members.iter().map(|item| item.id.as_str())) .collect::>(); + let allowed_relationship_subject_ids = seed + .actors + .iter() + .map(|item| item.id.as_str()) + .chain(seed.institutions.iter().map(|item| item.id.as_str())) + .chain(seed.gestalts.iter().map(|item| item.id.as_str())) + .collect::>(); let mut used_candidates = BTreeSet::new(); let mut actors = Vec::with_capacity(anchors.len()); for (anchor_index, anchor) in anchors.iter().enumerate() { @@ -2266,6 +2304,15 @@ fn materialize_private_relationship_actors( candidate.location_id )); } + let relationships = compiled_relationship_map(candidate.relationships.clone())?; + if relationships + .keys() + .any(|subject_id| !allowed_relationship_subject_ids.contains(subject_id.as_str())) + { + return Err(anyhow!( + "private actor candidate slot {anchor_index} relates to a subject outside the exact public actor, institution, and population allowlist" + )); + } actors.push(ActorState { id: anchor.id.clone(), name: anchor.name.clone(), @@ -2275,7 +2322,7 @@ fn materialize_private_relationship_actors( equipment: candidate.equipment.clone(), conditions: candidate.conditions.clone(), obligations: candidate.obligations.clone(), - relationships: BTreeMap::new(), + relationships, goals: candidate.goals.clone(), memories: candidate.memories.clone(), }); @@ -4239,7 +4286,9 @@ mod tests { self.private_stage_was_minimal.store( request.lived_stream.contains("convoy quartermaster") && !request.lived_stream.contains("relationship-anchor:") - && !request.lived_stream.contains("life-debt"), + && request.lived_stream.contains("life-debt") + && !request.lived_stream.contains("SECRET_COOLANT_RECORD") + && !request.lived_stream.contains("PRIVATE_ROUTE_CODE"), Ordering::SeqCst, ); return Ok(serde_json::json!({ @@ -4251,6 +4300,10 @@ mod tests { "equipment":["manifest terminal"], "conditions":[], "obligations":["account for the convoy"], + "relationships":[{ + "subject_id":"yard-workers", + "description":"convoy clerk coordinating with the yard workers" + }], "goals":["supply the convoy"], "memories":[] }] @@ -4727,10 +4780,9 @@ mod tests { first.targets[&("member-sable".into(), "convoy quartermaster".into())], first.anchors[0].id ); - assert!( - !serde_json::to_string(&first.anchors) - .unwrap() - .contains("life-debt") + assert_eq!( + first.anchors[0].approved_relationship_descriptions, + vec!["Sable owes them a life-debt"] ); } @@ -4740,6 +4792,7 @@ mod tests { let anchor = RequiredRelationshipActor { id: "relationship-anchor:quartermaster".into(), name: "convoy quartermaster".into(), + approved_relationship_descriptions: vec!["trusted convoy contact".into()], }; let mut actor = campaign.actors["player"].clone(); actor.id = anchor.id.clone(); @@ -4779,7 +4832,7 @@ mod tests { }; character.relationships.insert( "convoy quartermaster".into(), - "Sable owes them a life-debt".into(), + "Sable owes them a life-debt; they are the current convoy quartermaster and can explain the manifest but cannot allocate cargo alone".into(), ); let brief = ApprovedCampaignBrief { schema: "ghostlight.approved_campaign_brief.v1".into(), @@ -4838,11 +4891,17 @@ mod tests { .unwrap(); assert_eq!(anchor.name, "convoy quartermaster"); assert_eq!(anchor.location_id, "yard"); + assert!(anchor.capabilities.contains("convoy logistics")); + assert!(anchor.knowledge.contains("current manifest")); + assert_eq!( + anchor.relationships.get("yard-workers"), + Some(&"convoy clerk coordinating with the yard workers".into()) + ); assert_eq!( preview.campaign.actors["actor-sable"] .relationships .get(&anchor.id), - Some(&"Sable owes them a life-debt".into()) + Some(&"Sable owes them a life-debt; they are the current convoy quartermaster and can explain the manifest but cannot allocate cargo alone".into()) ); } @@ -4996,16 +5055,18 @@ mod tests { equipment: BTreeSet::from(["manifest terminal".into()]), conditions: BTreeSet::new(), obligations: BTreeSet::from(["account for the convoy".into()]), + relationships: vec![], goals: vec!["get the convoy supplied".into()], memories: vec![], } } #[test] - fn private_actor_materialization_attaches_server_identity_without_model_relationships() { + fn private_actor_materialization_attaches_server_identity() { let anchor = RequiredRelationshipActor { id: "relationship-anchor:quartermaster".into(), name: "convoy quartermaster".into(), + approved_relationship_descriptions: vec!["trusted convoy contact".into()], }; let actors = materialize_private_relationship_actors( &private_actor_test_seed(), @@ -5032,6 +5093,7 @@ mod tests { let anchor = RequiredRelationshipActor { id: "relationship-anchor:quartermaster".into(), name: "convoy quartermaster".into(), + approved_relationship_descriptions: vec!["trusted convoy contact".into()], }; let seed = private_actor_test_seed(); assert!( @@ -5064,6 +5126,7 @@ mod tests { let medic = RequiredRelationshipActor { id: "relationship-anchor:medic".into(), name: "convoy medic".into(), + approved_relationship_descriptions: vec!["known convoy medic".into()], }; assert!( materialize_private_relationship_actors( @@ -5096,6 +5159,24 @@ mod tests { ); unknown.location_id = "convoy-staging".into(); + unknown.relationships = vec![CompiledRelationship { + subject_id: "player".into(), + description: "guessed player relationship".into(), + }]; + assert!( + materialize_private_relationship_actors( + &seed, + std::slice::from_ref(&anchor), + PrivateRelationshipActorSet { + actors: vec![unknown.clone()] + } + ) + .unwrap_err() + .to_string() + .contains("outside the exact public actor, institution, and population allowlist") + ); + + unknown.relationships.clear(); let mut collision = seed; collision.player.id.clone_from(&anchor.id); assert!( diff --git a/crates/ghostlight-dungeon/src/session_zero.rs b/crates/ghostlight-dungeon/src/session_zero.rs index 3f1a7b1..d24188f 100644 --- a/crates/ghostlight-dungeon/src/session_zero.rs +++ b/crates/ghostlight-dungeon/src/session_zero.rs @@ -2505,6 +2505,18 @@ struct PrivateRelationshipSubjectProjection { location_id: Option, location_name: Option, relationship: String, + counterpart_actor_state: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct PrivateRelationshipActorStateProjection { + capabilities: BTreeSet, + knowledge: BTreeSet, + equipment: BTreeSet, + conditions: BTreeSet, + obligations: BTreeSet, + relationships: BTreeMap, + goals: Vec, } fn private_relationship_subjects( @@ -2520,20 +2532,30 @@ fn private_relationship_subjects( .relationships .iter() .map(|(target_id, description)| { - let (subject_kind, name, location_id) = + let (subject_kind, name, location_id, counterpart_actor_state) = if let Some(target) = campaign.actors.get(target_id) { ( "actor", target.name.as_str(), Some(target.location_id.as_str()), + Some(PrivateRelationshipActorStateProjection { + capabilities: target.capabilities.clone(), + knowledge: target.knowledge.clone(), + equipment: target.equipment.clone(), + conditions: target.conditions.clone(), + obligations: target.obligations.clone(), + relationships: target.relationships.clone(), + goals: target.goals.clone(), + }), ) } else if let Some(target) = campaign.institutions.get(target_id) { - ("institution", target.name.as_str(), None) + ("institution", target.name.as_str(), None, None) } else if let Some(target) = campaign.gestalts.get(target_id) { ( "gestalt", target.name.as_str(), Some(target.home_location_id.as_str()), + None, ) } else if let Some(member_id) = target_id.strip_prefix("member:") { if let Some(target) = campaign.gestalt_members.get(member_id) { @@ -2543,12 +2565,12 @@ fn private_relationship_subjects( .get(&target.gestalt_id) .map(|gestalt| gestalt.home_location_id.as_str()) }); - ("gestalt_member", target.name.as_str(), location_id) + ("gestalt_member", target.name.as_str(), location_id, None) } else { - ("unknown", target_id.as_str(), None) + ("unknown", target_id.as_str(), None, None) } } else { - ("unknown", target_id.as_str(), None) + ("unknown", target_id.as_str(), None, None) }; let location_name = location_id .and_then(|id| campaign.locations.get(id)) @@ -2560,11 +2582,21 @@ fn private_relationship_subjects( location_id: location_id.map(str::to_owned), location_name: location_name.map(str::to_owned), relationship: description.clone(), + counterpart_actor_state, } }) .collect() } +fn display_values<'a>(values: impl IntoIterator) -> String { + let joined = values.into_iter().cloned().collect::>().join(", "); + if joined.is_empty() { + "none".into() + } else { + joined + } +} + fn display_world_preview( preview: &WorldCompilePreview, player_actor_ids: &BTreeSet, @@ -3133,9 +3165,38 @@ pub fn session_zero_surface( .or(subject.location_id.as_deref()) .map(|value| format!(" · located at {value}")) .unwrap_or_default(); + let actor_state = subject + .counterpart_actor_state + .as_ref() + .map(|state| format!( + "\nCounterpart-owned state:\nCapabilities: {}\nKnowledge: {}\nEquipment: {}\nConditions: {}\nObligations: {}\nRelationships: {}\nGoals: {}", + display_values(&state.capabilities), + display_values(&state.knowledge), + display_values(&state.equipment), + display_values(&state.conditions), + display_values(&state.obligations), + if state.relationships.is_empty() { + "none".into() + } else { + state + .relationships + .iter() + .map(|(subject_id, description)| { + format!("{subject_id}: {description}") + }) + .collect::>() + .join(", ") + }, + display_values(&state.goals), + )) + .unwrap_or_default(); format!( - "{} · {}{}\n{}", - subject.name, subject.subject_kind, location, subject.relationship + "{} · {}{}\n{}{}", + subject.name, + subject.subject_kind, + location, + subject.relationship, + actor_state, ) }) .collect::>() @@ -6359,6 +6420,16 @@ mod tests { let mut quartermaster = campaign.actors["player"].clone(); quartermaster.id = "relationship-anchor:quartermaster".into(); quartermaster.name = "convoy quartermaster".into(); + quartermaster + .capabilities + .insert("convoy manifest custody".into()); + quartermaster + .knowledge + .insert("the convoy departure window".into()); + quartermaster.relationships.insert( + "faction-0000".into(), + "serves as the faction's convoy quartermaster".into(), + ); campaign .actors .insert(quartermaster.id.clone(), quartermaster); @@ -6396,6 +6467,17 @@ mod tests { assert_eq!(subjects[0].location_id.as_deref(), Some("center")); assert_eq!(subjects[0].location_name.as_deref(), Some("Center")); assert_eq!(subjects[0].relationship, "owes them a life-debt"); + let counterpart = subjects[0].counterpart_actor_state.as_ref().unwrap(); + assert!(counterpart.capabilities.contains("convoy manifest custody")); + assert!( + counterpart + .knowledge + .contains("the convoy departure window") + ); + assert_eq!( + counterpart.relationships.get("faction-0000"), + Some(&"serves as the faction's convoy quartermaster".into()) + ); assert!(private_relationship_subjects(&preview, "not-the-player").is_empty()); let mut draft = state(); @@ -6419,6 +6501,10 @@ mod tests { assert!(surface.contains("Institutions")); assert!(surface.contains("convoy quartermaster")); assert!(surface.contains("owes them a life-debt")); + assert!(surface.contains("Counterpart-owned state")); + assert!(surface.contains("convoy manifest custody")); + assert!(surface.contains("the convoy departure window")); + assert!(surface.contains("serves as the faction's convoy quartermaster")); assert!(!surface.contains("OPERATOR-ONLY-RESERVE")); assert!(!surface.contains("PRIVATE-INSTITUTION-GOAL")); }