diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs index d3fd2f5..52c263e 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs @@ -332,6 +332,7 @@ async fn main() -> anyhow::Result<()> { + plan.gestalt_activities.len() + plan.gestalt_migrations.len() + plan.actor_moves.len() + + plan.actor_activities.len() + plan.member_activities.len(); for stage in &output.stages { store.insert( diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs index 986dc1b..148cb9f 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs @@ -161,7 +161,7 @@ async fn main() -> anyhow::Result<()> { "cover":cover, "appraisals":output.wave.appraisals, "plan":plan, - "material_action_count":plan.institution_actions.len() + plan.gestalt_actions.len() + plan.gestalt_activities.len() + plan.gestalt_migrations.len() + plan.actor_moves.len() + plan.member_activities.len() + plan.member_migrations.len(), + "material_action_count":plan.institution_actions.len() + plan.gestalt_actions.len() + plan.gestalt_activities.len() + plan.gestalt_migrations.len() + plan.actor_moves.len() + plan.actor_activities.len() + plan.member_activities.len() + plan.member_migrations.len(), "explicit_inaction_count":explicit_inaction_count, "model_stage_receipts":output.stages.iter().map(|stage|&stage.receipt).collect::>(), "commit":committed, diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index a5d6068..5983589 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -448,6 +448,14 @@ pub enum StrategicCellEffect { actor_id: String, destination_id: String, }, + ActorActivity { + actor_id: String, + activity: StrategicActivityKind, + #[serde(default)] + target_subject_ids: Vec, + #[serde(default)] + location_ids: Vec, + }, MemberActivity { member_id: String, activity: StrategicActivityKind, @@ -703,6 +711,8 @@ pub struct StrategicTickPlan { pub gestalt_migrations: Vec, pub actor_moves: Vec, #[serde(default)] + pub actor_activities: Vec, + #[serde(default)] pub member_migrations: Vec, #[serde(default)] pub member_activities: Vec, @@ -751,6 +761,16 @@ pub struct StrategicActorMove { pub public_channels: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct StrategicActorActivity { + pub action_digest: String, + pub actor_id: String, + pub activity: StrategicActivityKind, + pub target_subject_ids: Vec, + pub location_ids: Vec, + pub public_channels: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct StrategicMemberMigration { pub member_id: String, diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index b808cc9..98c155d 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -1695,6 +1695,16 @@ fn apply_strategic_tick_plan( ), ); } + for activity in &plan.actor_activities { + outcome_event_context.insert( + activity.action_digest.clone(), + ( + activity.actor_id.clone(), + activity.location_ids.clone(), + activity.public_channels.clone(), + ), + ); + } for activity in &plan.member_activities { outcome_event_context.insert( activity.action_digest.clone(), @@ -2013,6 +2023,87 @@ fn apply_strategic_tick_plan( }); } + for action in plan.actor_activities { + if !seen_actors.insert(action.actor_id.clone()) { + return Err(KernelError::Invalid( + "actor acts twice in one strategic tick".into(), + )); + } + if is_human_controlled_actor(campaign, &action.actor_id) { + return Err(KernelError::Invalid( + "strategic simulation cannot puppet a human-controlled actor".into(), + )); + } + validate_public_channels(&action.public_channels)?; + let actor = campaign + .actors + .get(&action.actor_id) + .ok_or_else(|| KernelError::Invalid("strategic plan invented an actor".into()))?; + let allowed_targets = + crate::resolution::strategic_activity_targets(campaign, &action.actor_id); + let unique_targets = action.target_subject_ids.iter().collect::>(); + let needs_target = !action.activity.allows_targetless_local_attempt(); + if action.target_subject_ids.len() > 4 + || unique_targets.len() != action.target_subject_ids.len() + || action + .target_subject_ids + .iter() + .any(|target| !allowed_targets.contains(target)) + || (needs_target && action.target_subject_ids.is_empty()) + || action.location_ids.len() != 1 + || action.location_ids[0] != actor.location_id + { + return Err(KernelError::Invalid( + "strategic actor activity exceeds exact graph or location scope".into(), + )); + } + let target_names = action + .target_subject_ids + .iter() + .map(|target| agency_subject_name(campaign, target)) + .collect::, _>>()?; + let institution_ids = action + .target_subject_ids + .iter() + .filter(|target| campaign.institutions.contains_key(*target)) + .cloned() + .collect(); + let mut actor_ids = vec![action.actor_id.clone()]; + actor_ids.extend( + action + .target_subject_ids + .iter() + .filter(|target| { + campaign.actors.contains_key(*target) + || target.strip_prefix("member:").is_some_and(|member_id| { + campaign.gestalt_members.contains_key(member_id) + }) + }) + .cloned(), + ); + actor_ids.sort(); + actor_ids.dedup(); + let mut gestalt_ids = action + .target_subject_ids + .iter() + .filter(|target| campaign.gestalts.contains_key(*target)) + .cloned() + .collect::>(); + gestalt_ids.sort(); + gestalt_ids.dedup(); + events.push(Event { + id: format!("strategic:{revision}:actor-activity:{}", action.actor_id), + at, + kind: "actor_activity".into(), + summary: strategic_activity_summary(&actor.name, &action.activity, &target_names), + actor_ids, + institution_ids, + gestalt_ids, + location_ids: action.location_ids, + public_channels: action.public_channels, + }); + } + let mut seen_members = BTreeSet::new(); for action in plan.member_activities { if !seen_members.insert(action.member_id.clone()) { @@ -3077,6 +3168,11 @@ mod tests { plan.gestalt_activities .iter() .map(|activity| (activity.action_digest.clone(), activity.gestalt_id.clone())) + .chain( + plan.actor_activities.iter().map(|activity| { + (activity.action_digest.clone(), activity.actor_id.clone()) + }), + ) .chain(plan.member_activities.iter().map(|activity| { ( activity.action_digest.clone(), @@ -4654,6 +4750,46 @@ mod tests { ticks[0].model_receipt_hash, Some(format!("sha256:{}", "b".repeat(64))) ); + + let actor_activity = resolve_test_activities(crate::domain::StrategicTickPlan { + actor_activities: vec![crate::domain::StrategicActorActivity { + action_digest: test_action_digest("runner-investigates-yard"), + actor_id: "runner".into(), + activity: StrategicActivityKind::Investigate, + target_subject_ids: vec![], + location_ids: vec!["yard".into()], + public_channels: vec![], + }], + ..Default::default() + }); + let result = kernel + .command(WorldCommand::AdvanceStrategicTick { + expected_revision: 1, + source: TickSource::Scheduler, + plan: Some(actor_activity), + model_receipt_hash: Some(format!("sha256:{}", "c".repeat(64))), + resolution_wave: None, + }) + .await + .unwrap(); + let CommandResult::Committed { campaign, .. } = result else { + panic!("expected commit") + }; + assert_eq!(campaign.actors["runner"].location_id, "yard"); + assert_eq!(campaign.actors["player"].location_id, "room"); + assert!( + campaign + .events + .iter() + .any(|event| event.kind == "actor_activity" + && event.actor_ids.contains(&"runner".to_owned())) + ); + assert!( + campaign + .events + .iter() + .any(|event| event.kind == "strategic_activity_outcome") + ); } #[tokio::test] diff --git a/crates/ghostlight-dungeon/src/outcome.rs b/crates/ghostlight-dungeon/src/outcome.rs index 07a4d45..f84d95b 100644 --- a/crates/ghostlight-dungeon/src/outcome.rs +++ b/crates/ghostlight-dungeon/src/outcome.rs @@ -349,6 +349,11 @@ pub fn plan_activity_digests(plan: &StrategicTickPlan) -> Vec { plan.gestalt_activities .iter() .map(|activity| activity.action_digest.clone()) + .chain( + plan.actor_activities + .iter() + .map(|activity| activity.action_digest.clone()), + ) .chain( plan.member_activities .iter() @@ -395,6 +400,25 @@ pub fn validate_plan_activity_outcomes( }, ) }) + .chain(plan.actor_activities.iter().map(|activity| { + ( + activity.action_digest.clone(), + CellActionProposal { + subject_id: activity.actor_id.clone(), + intent: "committed strategic activity".into(), + intended_effect: "resolve the committed strategic activity".into(), + priority: 0, + state_references: vec![], + public_channels: activity.public_channels.clone(), + effect: StrategicCellEffect::ActorActivity { + actor_id: activity.actor_id.clone(), + activity: activity.activity.clone(), + target_subject_ids: activity.target_subject_ids.clone(), + location_ids: activity.location_ids.clone(), + }, + }, + ) + })) .chain(plan.member_activities.iter().map(|activity| { ( activity.action_digest.clone(), @@ -415,7 +439,11 @@ pub fn validate_plan_activity_outcomes( ) })) .collect::>(); - if synthetic.len() != plan.gestalt_activities.len() + plan.member_activities.len() { + if synthetic.len() + != plan.gestalt_activities.len() + + plan.actor_activities.len() + + plan.member_activities.len() + { return Err(anyhow!( "strategic activities contain duplicate action digests" )); @@ -1378,6 +1406,12 @@ fn activity_parts( location_ids, .. } + | StrategicCellEffect::ActorActivity { + activity, + target_subject_ids, + location_ids, + .. + } | StrategicCellEffect::MemberActivity { activity, target_subject_ids, diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index 89f6bbe..563f20d 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -172,6 +172,13 @@ enum CellEffectCandidate { ActorMove { destination_id: String, }, + ActorActivity { + activity: crate::domain::StrategicActivityKind, + #[serde(default)] + target_subject_ids: Vec, + #[serde(default)] + location_ids: Vec, + }, MemberActivity { activity: crate::domain::StrategicActivityKind, #[serde(default)] @@ -249,6 +256,7 @@ const CELL_APPRAISAL_OUTPUT_CONTRACT: &str = r#"{ {"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"gestalt_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":4,"items":{"type":"string"}}}}, {"type":"object","required":["type","destination_gestalt_id"],"properties":{"type":{"const":"gestalt_migration"},"destination_gestalt_id":{"type":"string"}}}, {"type":"object","required":["type","destination_id"],"properties":{"type":{"const":"actor_move"},"destination_id":{"type":"string"}}}, + {"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"actor_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":1,"items":{"type":"string"}}}}, {"type":"object","required":["type","activity","target_subject_ids","location_ids"],"properties":{"type":{"const":"member_activity"},"activity":{"enum":["prepare","coordinate","investigate","recruit","obstruct","trade","communicate"]},"target_subject_ids":{"type":"array","maxItems":4,"items":{"type":"string"}},"location_ids":{"type":"array","maxItems":1,"items":{"type":"string"}}}}, {"type":"object","required":["type","destination_gestalt_id"],"properties":{"type":{"const":"member_migration"},"destination_gestalt_id":{"type":"string"}}} ]} @@ -726,7 +734,7 @@ fn required_projection_subject_ids(slice: &PermittedCellSlice) -> BTreeSet &'static str { match kind { - crate::domain::AgencySubjectKind::Actor => "actor_move", + crate::domain::AgencySubjectKind::Actor => "actor_move_or_actor_activity", crate::domain::AgencySubjectKind::Institution => "institution", crate::domain::AgencySubjectKind::Gestalt => { "gestalt_pressure_or_gestalt_activity_or_gestalt_migration" @@ -1218,6 +1226,16 @@ fn bind_cell_appraisal( destination_id, } } + CellEffectCandidate::ActorActivity { + activity, + target_subject_ids, + location_ids, + } => crate::domain::StrategicCellEffect::ActorActivity { + actor_id: candidate.subject_id.clone(), + activity, + target_subject_ids, + location_ids, + }, CellEffectCandidate::MemberActivity { activity, target_subject_ids, @@ -1616,6 +1634,36 @@ fn validate_constituent_effect( )); } } + crate::domain::StrategicCellEffect::ActorActivity { + actor_id, + activity, + target_subject_ids, + location_ids, + } => { + let unique_targets = target_subject_ids.iter().collect::>(); + let needs_target = !activity.allows_targetless_local_attempt(); + if subject.subject_kind != crate::domain::AgencySubjectKind::Actor + || actor_id != &subject.subject_id + || target_subject_ids.len() > 4 + || unique_targets.len() != target_subject_ids.len() + || target_subject_ids + .iter() + .any(|target| !subject.activity_target_ids.contains(target)) + || (needs_target && target_subject_ids.is_empty()) + || location_ids.len() != 1 + || !subject.location_ids.contains(&location_ids[0]) + { + return Err(anyhow!( + "actor {} proposed {:?} toward {:?} at {:?}; exact allowed targets are {:?} and exact locations are {:?}", + subject.subject_id, + activity, + target_subject_ids, + location_ids, + subject.activity_target_ids, + subject.location_ids + )); + } + } crate::domain::StrategicCellEffect::MemberMigration { .. } => { return Err(anyhow!( "constituent {} is not a named member and may not emit member_migration", @@ -2406,6 +2454,72 @@ mod tests { assert!(validate_cell_appraisal(&slice, &stolen).is_err()); } + #[test] + fn canonical_actor_activity_stays_attributed_to_the_actor() { + let mut slice = fixture_cell_slice(); + let subject = &mut slice.constituents[0]; + subject.subject_id = "actor:liaison".into(); + subject.subject_kind = AgencySubjectKind::Actor; + subject.name = "Liaison".into(); + subject.collective_authority_id = None; + subject.location_ids = BTreeSet::from(["forum".into()]); + subject.permitted_state_references = BTreeSet::from(["subject:actor:liaison".into()]); + subject.activity_target_ids = BTreeSet::from(["clinic".into()]); + subject.current_posture = None; + + let appraisal = bind_cell_appraisal( + &slice, + CellAppraisalProposal { + actions: vec![CellActionCandidate { + subject_id: "actor:liaison".into(), + intent: "ask the clinic for its shortage count".into(), + intended_effect: "send the request without inventing a reply".into(), + priority: 70, + state_references: vec!["subject:actor:liaison".into()], + public_channels: vec![], + effect: CellEffectCandidate::ActorActivity { + activity: crate::domain::StrategicActivityKind::Communicate, + target_subject_ids: vec!["clinic".into()], + location_ids: vec!["forum".into()], + }, + }], + inactions: vec![], + }, + ) + .unwrap(); + validate_cell_appraisal(&slice, &appraisal).unwrap(); + assert!(matches!( + &appraisal.actions[0].effect, + StrategicCellEffect::ActorActivity { actor_id, .. } if actor_id == "actor:liaison" + )); + + let wrong_lane = bind_cell_appraisal( + &slice, + CellAppraisalProposal { + actions: vec![CellActionCandidate { + subject_id: "actor:liaison".into(), + intent: "ask the clinic for its shortage count".into(), + intended_effect: "send the request without inventing a reply".into(), + priority: 70, + state_references: vec!["subject:actor:liaison".into()], + public_channels: vec![], + effect: CellEffectCandidate::MemberActivity { + activity: crate::domain::StrategicActivityKind::Communicate, + target_subject_ids: vec!["clinic".into()], + location_ids: vec!["forum".into()], + }, + }], + inactions: vec![], + }, + ) + .unwrap_err(); + assert!( + wrong_lane + .to_string() + .contains("is not a selected member exception") + ); + } + #[test] fn repeated_institution_posture_reports_the_exact_missing_change() { let slice = fixture_cell_slice(); diff --git a/crates/ghostlight-dungeon/src/resolution.rs b/crates/ghostlight-dungeon/src/resolution.rs index 78c983b..ebb85f1 100644 --- a/crates/ghostlight-dungeon/src/resolution.rs +++ b/crates/ghostlight-dungeon/src/resolution.rs @@ -1678,6 +1678,7 @@ pub fn select_resolution_wave( let is_activity = matches!( proposal.effect, StrategicCellEffect::GestaltActivity { .. } + | StrategicCellEffect::ActorActivity { .. } | StrategicCellEffect::MemberActivity { .. } ); let selected_activity_proposal = is_activity.then(|| proposal.clone()); @@ -1737,6 +1738,19 @@ pub fn select_resolution_wave( destination_id, public_channels: proposal.public_channels, }), + StrategicCellEffect::ActorActivity { + actor_id, + activity, + target_subject_ids, + location_ids, + } => plan.actor_activities.push(StrategicActorActivity { + action_digest, + actor_id, + activity, + target_subject_ids, + location_ids, + public_channels: proposal.public_channels, + }), StrategicCellEffect::MemberActivity { member_id, activity, @@ -1783,6 +1797,7 @@ pub fn select_resolution_wave( + plan.gestalt_activities.len() + plan.gestalt_migrations.len() + plan.actor_moves.len() + + plan.actor_activities.len() + plan.member_activities.len() + plan.member_migrations.len() >= consequence_limit @@ -1950,6 +1965,34 @@ fn validate_cell_proposal( return Err(anyhow!("actor proposal exceeds spatial reach")); } } + StrategicCellEffect::ActorActivity { + actor_id, + activity, + target_subject_ids, + location_ids, + } => { + let actor = campaign + .actors + .get(actor_id) + .filter(|_| actor_id == &proposal.subject_id) + .ok_or_else(|| anyhow!("actor activity exceeds constituent state"))?; + let allowed_targets = strategic_activity_targets(campaign, actor_id); + let unique_targets = target_subject_ids.iter().collect::>(); + let needs_target = !activity.allows_targetless_local_attempt(); + if target_subject_ids.len() > 4 + || unique_targets.len() != target_subject_ids.len() + || target_subject_ids + .iter() + .any(|target| !allowed_targets.contains(target)) + || (needs_target && target_subject_ids.is_empty()) + || location_ids.len() != 1 + || location_ids[0] != actor.location_id + { + return Err(anyhow!( + "actor activity exceeds exact subject, graph, or location scope" + )); + } + } StrategicCellEffect::MemberMigration { .. } => { return Err(anyhow!( "a population, institution, actor, or arena cannot migrate a named member" @@ -2602,6 +2645,7 @@ fn proposal_target_key(proposal: &CellActionProposal) -> String { format!("gestalt:{}", proposal.subject_id) } StrategicCellEffect::ActorMove { actor_id, .. } => format!("actor:{actor_id}"), + StrategicCellEffect::ActorActivity { actor_id, .. } => format!("actor:{actor_id}"), StrategicCellEffect::MemberActivity { member_id, .. } => { format!("member:{member_id}") } @@ -3111,6 +3155,59 @@ pub(crate) mod tests { )); } + #[test] + fn canonical_actor_activity_uses_exact_actor_scope() { + let mut value = campaign(0, 1); + let mut liaison = value.actors["player"].clone(); + liaison.id = "liaison".into(); + liaison.name = "Liaison".into(); + liaison.capabilities.insert("read ration ledgers".into()); + value.actors.insert(liaison.id.clone(), liaison); + ensure_agency_profiles(&mut value); + let cell = SimulationCell { + schema: "ghostlight.simulation_cell.v1".into(), + id: "cell:liaison".into(), + mode: SimulationCellMode::Cohesive, + subject_ids: BTreeSet::from(["liaison".into()]), + merge_loss: MergeLoss::default(), + rationale: "exact actor fixture".into(), + lease_until_world_revision: 0, + lease_until_strategic_tick: 0, + detail_focus_subject_id: Some("liaison".into()), + }; + let proposal = CellActionProposal { + subject_id: "liaison".into(), + intent: "inspect the room's ration ledger".into(), + intended_effect: "attempt a local investigation".into(), + priority: 50, + state_references: vec!["subject:liaison".into()], + public_channels: vec![], + effect: StrategicCellEffect::ActorActivity { + actor_id: "liaison".into(), + activity: StrategicActivityKind::Investigate, + target_subject_ids: vec![], + location_ids: vec!["center".into()], + }, + }; + validate_cell_proposal(&value, &cell, &proposal).unwrap(); + + let mut wrong_location = proposal.clone(); + let StrategicCellEffect::ActorActivity { location_ids, .. } = &mut wrong_location.effect + else { + unreachable!() + }; + *location_ids = vec!["somewhere-else".into()]; + assert!(validate_cell_proposal(&value, &cell, &wrong_location).is_err()); + + let mut player_puppet = proposal; + player_puppet.subject_id = "player".into(); + let StrategicCellEffect::ActorActivity { actor_id, .. } = &mut player_puppet.effect else { + unreachable!() + }; + *actor_id = "player".into(); + assert!(validate_cell_proposal(&value, &cell, &player_puppet).is_err()); + } + #[test] fn partitions_one_thousand_subjects_with_bounded_local_work() { let value = campaign(1_000, 8); diff --git a/docs/architecture/ghostlight-multiresolution-agency.md b/docs/architecture/ghostlight-multiresolution-agency.md index 7082c6f..822e0d2 100644 --- a/docs/architecture/ghostlight-multiresolution-agency.md +++ b/docs/architecture/ghostlight-multiresolution-agency.md @@ -269,9 +269,10 @@ One wave-level strategic outcome resolver owns that missing decision. It runs after priority and incompatibility selection but before the command enters the kernel: -1. Ghostlight content-addresses every selected `gestalt_activity` and - `member_activity` proposal. The digest binds exact source, intent, intended - effect, targets, locations, state references, channels, and typed activity. +1. Ghostlight content-addresses every selected `gestalt_activity`, + `actor_activity`, and `member_activity` proposal. The digest binds exact + source, intent, intended effect, targets, locations, state references, + channels, and typed activity. 2. A local context compiler supplies only those selected attempts and their legal consequence handles: source capabilities and holdings, exact target state, active incident relations, current pressures, eligible named-member @@ -509,6 +510,16 @@ the destination population cannot claim the person's voice. `member_activity` and `member_migration` resolve under one exact member key, so only the higher-priority compatible choice can commit in a wave. +Canonical non-player actors use `actor_activity` for preparation, +coordination, investigation, recruitment, obstruction, trade, and +communication. The action is bound to the actor's exact current location, +state references, information channels, and graph-adjacent or co-located +targets. `actor_move` and `actor_activity` share one actor action key, so a +cell cannot make the same person travel and act again in the same wave. +Human-controlled actors are inadmissible in both paths. An arena may carry the +actor's attributed proposal but cannot translate it into population speech, +member activity, or collective authority. + The Interpreter maps natural attempts narrowly: speech, offers, requests, and notices are communication; coordination requires an actual attempt to arrange joint work; preparation is the source's own concrete work; investigation seeks diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index e2019db..ce293f2 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -391,6 +391,14 @@ around unnamed local features remains part of the source-attributed attempt and does not create topology or assert arrival. A different supplied canonical location or population destination still requires the exact movement path. +Canonical non-player actors have an `actor_activity` path for ordinary +preparation, coordination, investigation, recruitment, obstruction, trade, +and communication. The Interpreter binds the actor's exact ID locally; +WorldKernel validates current location, graph targets, state references, +channels, and human-control exclusion. Actor movement and actor activity share +one action key, while successful consequences remain a separately resolved, +digest-bound outcome. + Salient dormant members have their own `member_activity` path for ordinary local attempts. It uses the same bounded verbs, the member's exact location, their current leaf plus explicitly related or exactly co-located targets, and