Preserve canonical actor agency in strategic cells
This commit is contained in:
parent
502384f366
commit
1ddbd294af
9 changed files with 427 additions and 6 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
"commit":committed,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
location_ids: Vec<String>,
|
||||
},
|
||||
MemberActivity {
|
||||
member_id: String,
|
||||
activity: StrategicActivityKind,
|
||||
|
|
@ -703,6 +711,8 @@ pub struct StrategicTickPlan {
|
|||
pub gestalt_migrations: Vec<StrategicGestaltMigration>,
|
||||
pub actor_moves: Vec<StrategicActorMove>,
|
||||
#[serde(default)]
|
||||
pub actor_activities: Vec<StrategicActorActivity>,
|
||||
#[serde(default)]
|
||||
pub member_migrations: Vec<StrategicMemberMigration>,
|
||||
#[serde(default)]
|
||||
pub member_activities: Vec<StrategicMemberActivity>,
|
||||
|
|
@ -751,6 +761,16 @@ pub struct StrategicActorMove {
|
|||
pub public_channels: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub location_ids: Vec<String>,
|
||||
pub public_channels: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
pub struct StrategicMemberMigration {
|
||||
pub member_id: String,
|
||||
|
|
|
|||
|
|
@ -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::<BTreeSet<_>>();
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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::<Vec<_>>();
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -349,6 +349,11 @@ pub fn plan_activity_digests(plan: &StrategicTickPlan) -> Vec<String> {
|
|||
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::<BTreeMap<_, _>>();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -172,6 +172,13 @@ enum CellEffectCandidate {
|
|||
ActorMove {
|
||||
destination_id: String,
|
||||
},
|
||||
ActorActivity {
|
||||
activity: crate::domain::StrategicActivityKind,
|
||||
#[serde(default)]
|
||||
target_subject_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
location_ids: Vec<String>,
|
||||
},
|
||||
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<Strin
|
|||
|
||||
fn allowed_effect_type(kind: &crate::domain::AgencySubjectKind) -> &'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::<BTreeSet<_>>();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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::<BTreeSet<_>>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue