Bind strategic agency before inference
This commit is contained in:
parent
b37bb0534b
commit
3bfd2cdb29
5 changed files with 213 additions and 66 deletions
|
|
@ -2217,6 +2217,15 @@ fn apply_strategic_tick_plan(
|
|||
crate::outcome::validate_plan_activity_outcomes(campaign, &plan)
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?;
|
||||
let canonical_composition = !plan.selected_actions.is_empty();
|
||||
let activity_means = plan
|
||||
.selected_actions
|
||||
.iter()
|
||||
.map(|action| {
|
||||
crate::resolution::cell_action_digest(action)
|
||||
.map(|digest| (digest, action.intended_effect.clone()))
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()?;
|
||||
let prospective_actor_locations = plan
|
||||
.actor_moves
|
||||
.iter()
|
||||
|
|
@ -2518,7 +2527,14 @@ fn apply_strategic_tick_plan(
|
|||
),
|
||||
at,
|
||||
kind: "gestalt_activity".into(),
|
||||
summary: strategic_activity_summary(&gestalt.name, &action.activity, &target_names),
|
||||
summary: strategic_activity_summary(
|
||||
&gestalt.name,
|
||||
&action.activity,
|
||||
&target_names,
|
||||
activity_means
|
||||
.get(&action.action_digest)
|
||||
.map(String::as_str),
|
||||
),
|
||||
actor_ids,
|
||||
institution_ids,
|
||||
gestalt_ids,
|
||||
|
|
@ -2674,7 +2690,14 @@ fn apply_strategic_tick_plan(
|
|||
),
|
||||
at,
|
||||
kind: "actor_activity".into(),
|
||||
summary: strategic_activity_summary(&actor.name, &action.activity, &target_names),
|
||||
summary: strategic_activity_summary(
|
||||
&actor.name,
|
||||
&action.activity,
|
||||
&target_names,
|
||||
activity_means
|
||||
.get(&action.action_digest)
|
||||
.map(String::as_str),
|
||||
),
|
||||
actor_ids,
|
||||
institution_ids,
|
||||
gestalt_ids,
|
||||
|
|
@ -2784,7 +2807,14 @@ fn apply_strategic_tick_plan(
|
|||
),
|
||||
at,
|
||||
kind: "gestalt_member_activity".into(),
|
||||
summary: strategic_activity_summary(&member.name, &action.activity, &target_names),
|
||||
summary: strategic_activity_summary(
|
||||
&member.name,
|
||||
&action.activity,
|
||||
&target_names,
|
||||
activity_means
|
||||
.get(&action.action_digest)
|
||||
.map(String::as_str),
|
||||
),
|
||||
actor_ids,
|
||||
institution_ids,
|
||||
gestalt_ids,
|
||||
|
|
@ -2858,6 +2888,12 @@ fn apply_strategic_tick_plan(
|
|||
.ok_or_else(|| {
|
||||
KernelError::Invalid("strategic outcome lost its activity context".into())
|
||||
})?;
|
||||
if matches!(
|
||||
&outcome.effect,
|
||||
crate::domain::StrategicOutcomeEffect::NoMaterialChange { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let mut subject_ids = BTreeSet::from([source_subject_id.clone()]);
|
||||
collect_outcome_subject_ids(&outcome.effect, &mut subject_ids);
|
||||
let mut actor_ids = Vec::new();
|
||||
|
|
@ -2973,7 +3009,20 @@ fn strategic_activity_summary(
|
|||
source_name: &str,
|
||||
activity: &StrategicActivityKind,
|
||||
target_names: &[String],
|
||||
admitted_means: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(means) = admitted_means
|
||||
.map(str::trim)
|
||||
.filter(|means| !means.is_empty())
|
||||
{
|
||||
let means = means.trim_end_matches(['.', '!', '?']);
|
||||
let mut characters = means.chars();
|
||||
let lowered = characters
|
||||
.next()
|
||||
.map(|first| first.to_lowercase().chain(characters).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
return format!("{source_name} attempts to {lowered}.");
|
||||
}
|
||||
let targets = target_names.join(", ");
|
||||
match (activity, target_names.is_empty()) {
|
||||
(StrategicActivityKind::Prepare, true) => {
|
||||
|
|
|
|||
|
|
@ -881,19 +881,23 @@ fn resolved_outcome_summary(
|
|||
to_subject_ids,
|
||||
fact_id,
|
||||
..
|
||||
} => format!(
|
||||
"{source_name} communicates {} to {}.",
|
||||
campaign
|
||||
} => {
|
||||
let statement = campaign
|
||||
.facts
|
||||
.get(fact_id)
|
||||
.ok_or_else(|| anyhow!("knowledge outcome fact vanished"))?
|
||||
.statement,
|
||||
to_subject_ids
|
||||
.iter()
|
||||
.map(|subject_id| subject_name(campaign, subject_id))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.join(", ")
|
||||
),
|
||||
.statement
|
||||
.trim()
|
||||
.trim_end_matches(['.', '!', '?']);
|
||||
format!(
|
||||
"{source_name} shares this with {}: {statement}.",
|
||||
to_subject_ids
|
||||
.iter()
|
||||
.map(|subject_id| subject_name(campaign, subject_id))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
};
|
||||
let summary = summary.chars().take(240).collect::<String>();
|
||||
if !bounded_text(&summary, 240) {
|
||||
|
|
@ -2990,7 +2994,7 @@ mod tests {
|
|||
action_digest: cell_action_digest(&action).unwrap(),
|
||||
source_subject_id: "dockers".into(),
|
||||
band: StrategicOutcomeBand::Success,
|
||||
summary: "Dockers communicates the route fact to Reed.".into(),
|
||||
summary: "Dockers shares this with Reed: the western causeway remains open.".into(),
|
||||
supporting_state_references: vec!["fact:fact:safe-route".into()],
|
||||
effect: StrategicOutcomeEffect::KnowledgeCommunicated {
|
||||
from_subject_id: "dockers".into(),
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ pub struct PermittedCellSlice {
|
|||
pub perceived_events: Vec<CellPerceivedEventSlice>,
|
||||
pub world_clock_pressure: Vec<String>,
|
||||
pub detail_focus_subject_id: Option<String>,
|
||||
/// Exact subjects that own this cell's bounded decision slots for the
|
||||
/// current strategic wave. Resolution chooses these before inference;
|
||||
/// Projector output may describe them but may not decide who participates.
|
||||
pub decision_owner_ids: BTreeSet<String>,
|
||||
pub max_actions: usize,
|
||||
pub source_receipt_ids: Vec<String>,
|
||||
}
|
||||
|
|
@ -908,25 +912,21 @@ fn constrain_cell_projection_schema(
|
|||
.and_then(serde_json::Value::as_object_mut)
|
||||
.ok_or_else(|| anyhow!("cell projection schema has no segment array"))?;
|
||||
let required_count = required_projection_subject_ids(slice).len();
|
||||
segments.insert("minItems".into(), required_count.max(1).into());
|
||||
segments.insert(
|
||||
"maxItems".into(),
|
||||
slice.max_actions.max(required_count).max(1).into(),
|
||||
);
|
||||
if required_count == 0 || required_count > slice.max_actions {
|
||||
return Err(anyhow!(
|
||||
"resolution selected an invalid number of cell decision owners"
|
||||
));
|
||||
}
|
||||
segments.insert("minItems".into(), required_count.into());
|
||||
segments.insert("maxItems".into(), required_count.into());
|
||||
let segment = schema
|
||||
.pointer_mut("/$defs/CellPerspectiveSegment/properties")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.ok_or_else(|| anyhow!("cell projection schema has no segment properties"))?;
|
||||
let mut subject_ids = slice
|
||||
.constituents
|
||||
.decision_owner_ids
|
||||
.iter()
|
||||
.map(|subject| subject.subject_id.as_str())
|
||||
.chain(
|
||||
slice
|
||||
.member_exceptions
|
||||
.iter()
|
||||
.map(|member| member.subject_id.as_str()),
|
||||
)
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(focus) = slice.detail_focus_subject_id.as_deref()
|
||||
&& let Some(index) = subject_ids
|
||||
|
|
@ -1123,27 +1123,7 @@ fn agency_footing(
|
|||
}
|
||||
|
||||
fn required_projection_subject_ids(slice: &PermittedCellSlice) -> BTreeSet<String> {
|
||||
slice
|
||||
.detail_focus_subject_id
|
||||
.iter()
|
||||
.filter(|focus| {
|
||||
slice
|
||||
.constituents
|
||||
.iter()
|
||||
.any(|subject| &subject.subject_id == *focus)
|
||||
|| slice
|
||||
.member_exceptions
|
||||
.iter()
|
||||
.any(|member| &member.subject_id == *focus)
|
||||
})
|
||||
.cloned()
|
||||
.chain(
|
||||
slice
|
||||
.member_exceptions
|
||||
.iter()
|
||||
.map(|member| member.subject_id.clone()),
|
||||
)
|
||||
.collect()
|
||||
slice.decision_owner_ids.clone()
|
||||
}
|
||||
|
||||
fn allowed_constituent_effect_types(subject: &CellConstituentSlice) -> Vec<&'static str> {
|
||||
|
|
@ -3342,6 +3322,7 @@ mod tests {
|
|||
}],
|
||||
world_clock_pressure: vec!["vote 5/6".into()],
|
||||
detail_focus_subject_id: Some("faction-06".into()),
|
||||
decision_owner_ids: BTreeSet::from(["faction-06".into()]),
|
||||
max_actions: 1,
|
||||
source_receipt_ids: vec![],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,7 +487,12 @@ fn resolution_demand_context(campaign: &Campaign) -> (Vec<String>, serde_json::V
|
|||
}
|
||||
|
||||
fn cell_slice(campaign: &Campaign, cell: &SimulationCell) -> Result<PermittedCellSlice> {
|
||||
let mut member_exceptions = member_exceptions(campaign, cell)?;
|
||||
let member_candidates = member_exceptions(campaign, cell)?;
|
||||
let decision_owner_ids = select_cell_decision_owners(campaign, cell, &member_candidates)?;
|
||||
let mut member_exceptions = member_candidates
|
||||
.into_iter()
|
||||
.filter(|member| decision_owner_ids.contains(&member.subject_id))
|
||||
.collect::<Vec<_>>();
|
||||
let selected_member_ids = member_exceptions
|
||||
.iter()
|
||||
.map(|member| member.subject_id.clone())
|
||||
|
|
@ -540,11 +545,76 @@ fn cell_slice(campaign: &Campaign, cell: &SimulationCell) -> Result<PermittedCel
|
|||
.map(project_world_clock_pressure)
|
||||
.collect(),
|
||||
detail_focus_subject_id: cell.detail_focus_subject_id.clone(),
|
||||
decision_owner_ids,
|
||||
max_actions: cell_action_limit(cell),
|
||||
source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn select_cell_decision_owners(
|
||||
campaign: &Campaign,
|
||||
cell: &SimulationCell,
|
||||
member_candidates: &[CellMemberSlice],
|
||||
) -> Result<BTreeSet<String>> {
|
||||
let quota = cell_action_limit(cell);
|
||||
if quota == 0 || cell.subject_ids.is_empty() {
|
||||
return Err(anyhow!("simulation cell has no bounded decision capacity"));
|
||||
}
|
||||
if let Some(focus) = cell.detail_focus_subject_id.as_deref()
|
||||
&& !cell.subject_ids.contains(focus)
|
||||
{
|
||||
return Err(anyhow!("simulation cell detail focus is outside the cell"));
|
||||
}
|
||||
|
||||
let canonical = cell.subject_ids.iter().cloned().collect::<Vec<_>>();
|
||||
let mut selected = BTreeSet::new();
|
||||
if let Some(focus) = cell.detail_focus_subject_id.as_ref() {
|
||||
selected.insert(focus.clone());
|
||||
}
|
||||
let available = canonical
|
||||
.iter()
|
||||
.filter(|subject_id| !selected.contains(*subject_id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let remaining = quota.saturating_sub(selected.len()).min(available.len());
|
||||
if remaining == available.len() {
|
||||
selected.extend(available);
|
||||
} else if remaining > 0 {
|
||||
let start =
|
||||
(campaign.strategic_tick_count as usize).saturating_mul(remaining) % available.len();
|
||||
for offset in 0..remaining {
|
||||
selected.insert(available[(start + offset) % available.len()].clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut owners = BTreeSet::new();
|
||||
for subject_id in selected {
|
||||
if cell.detail_focus_subject_id.as_deref() == Some(subject_id.as_str())
|
||||
|| !campaign.gestalts.contains_key(&subject_id)
|
||||
{
|
||||
owners.insert(subject_id);
|
||||
continue;
|
||||
}
|
||||
let mut alternatives = std::iter::once(subject_id.clone())
|
||||
.chain(
|
||||
member_candidates
|
||||
.iter()
|
||||
.filter(|member| member.source_gestalt_id == subject_id)
|
||||
.map(|member| member.subject_id.clone()),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
alternatives.dedup();
|
||||
let index = campaign.strategic_tick_count as usize % alternatives.len();
|
||||
owners.insert(alternatives.swap_remove(index));
|
||||
}
|
||||
if owners.is_empty() || owners.len() > quota {
|
||||
return Err(anyhow!(
|
||||
"resolution produced an invalid exact decision-owner set"
|
||||
));
|
||||
}
|
||||
Ok(owners)
|
||||
}
|
||||
|
||||
fn project_world_clock_pressure(clock: &crate::domain::WorldClock) -> String {
|
||||
if clock.progress >= clock.threshold {
|
||||
format!(
|
||||
|
|
@ -648,9 +718,6 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result<Vec<C
|
|||
);
|
||||
let activity_targets =
|
||||
crate::resolution::member_activity_targets(campaign, &member.id).ok()?;
|
||||
if destinations.is_empty() && activity_targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let player_relationship = member.relationships.contains_key(player_id) as u8;
|
||||
let personal_pressure = (!member.conditions.is_empty()
|
||||
|| !member.obligations.is_empty()
|
||||
|
|
@ -676,7 +743,6 @@ fn member_exceptions(campaign: &Campaign, cell: &SimulationCell) -> Result<Vec<C
|
|||
});
|
||||
candidates
|
||||
.into_iter()
|
||||
.take(cell_action_limit(cell).min(4))
|
||||
.map(
|
||||
|(_, _, _, member_id, origin, destinations, activity_target_ids)| {
|
||||
let member = &campaign.gestalt_members[&member_id];
|
||||
|
|
@ -1065,21 +1131,27 @@ mod tests {
|
|||
})
|
||||
.to_string()),
|
||||
"cell_projector" => {
|
||||
let subject_id = request
|
||||
let subject_ids = request
|
||||
.output_schema
|
||||
.as_ref()
|
||||
.and_then(|schema| {
|
||||
schema.pointer(
|
||||
"/$defs/CellPerspectiveSegment/properties/subject_id/enum/0",
|
||||
)
|
||||
schema
|
||||
.pointer("/$defs/CellPerspectiveSegment/properties/subject_id/enum")
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| anyhow!("fixture projector lacks a bound subject"))?;
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|values| !values.is_empty())
|
||||
.ok_or_else(|| anyhow!("fixture projector lacks bound subjects"))?;
|
||||
Ok(serde_json::json!({
|
||||
"segments":[{
|
||||
"segments":subject_ids.into_iter().map(|subject_id| serde_json::json!({
|
||||
"subject_id":subject_id,
|
||||
"narrative":"The horizon tightens around our own unresolved choice."
|
||||
}]
|
||||
"narrative":"The horizon tightens around this subject's own unresolved choice."
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
|
@ -1327,7 +1399,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cell_slice_projects_only_actionable_salient_member_exceptions() {
|
||||
fn cell_slice_rotates_exact_member_decision_owners_without_hiding_the_cell() {
|
||||
use crate::domain::*;
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 1);
|
||||
let gestalt = |id: &str, name: &str| GestaltPersonaState {
|
||||
|
|
@ -1384,6 +1456,7 @@ mod tests {
|
|||
.gestalt_members
|
||||
.insert(member_id.into(), member(member_id, false));
|
||||
}
|
||||
campaign.strategic_tick_count = 1;
|
||||
crate::resolution::ensure_agency_profiles(&mut campaign);
|
||||
campaign.agency_relations.insert(
|
||||
"migration".into(),
|
||||
|
|
@ -1404,8 +1477,11 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
let slice = cell_slice(&campaign, &cover.cells[0]).unwrap();
|
||||
assert_eq!(slice.member_exceptions.len(), 2);
|
||||
assert_eq!(slice.member_exceptions.len(), 1);
|
||||
assert_eq!(slice.member_exceptions[0].member_id, "mira");
|
||||
assert!(slice.decision_owner_ids.contains("member:mira"));
|
||||
assert!(slice.decision_owner_ids.contains("neighbors"));
|
||||
assert!(!slice.decision_owner_ids.contains("refugees"));
|
||||
assert_eq!(
|
||||
slice.member_exceptions[0]
|
||||
.migration_destinations
|
||||
|
|
@ -1476,6 +1552,12 @@ mod tests {
|
|||
.contains("private dock code")
|
||||
);
|
||||
|
||||
let mut focused_cell = cover.cells[0].clone();
|
||||
focused_cell.detail_focus_subject_id = Some("refugees".into());
|
||||
let focused = cell_slice(&campaign, &focused_cell).unwrap();
|
||||
assert!(focused.decision_owner_ids.contains("refugees"));
|
||||
assert!(!focused.decision_owner_ids.contains("member:mira"));
|
||||
|
||||
campaign.agency_relations.clear();
|
||||
let local_only_cover = crate::resolution::plan_cover(
|
||||
&campaign,
|
||||
|
|
|
|||
|
|
@ -136,7 +136,11 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V
|
|||
.filter(|event| {
|
||||
event.kind != "reaction_wave"
|
||||
&& event.kind != "group_travel"
|
||||
&& !event.actor_ids.iter().any(|actor_id| actor_id == viewer_actor_id)
|
||||
&& (autonomous_strategic_event(event)
|
||||
|| !event
|
||||
.actor_ids
|
||||
.iter()
|
||||
.any(|actor_id| actor_id == viewer_actor_id))
|
||||
})
|
||||
.filter(|event| {
|
||||
player_profile.is_some_and(|profile| {
|
||||
|
|
@ -310,6 +314,21 @@ pub fn player_surface_for_actor(campaign: &Campaign, viewer_actor_id: &str) -> V
|
|||
})
|
||||
}
|
||||
|
||||
fn autonomous_strategic_event(event: &crate::domain::Event) -> bool {
|
||||
matches!(
|
||||
event.kind.as_str(),
|
||||
"institution_action"
|
||||
| "gestalt_action"
|
||||
| "gestalt_migration"
|
||||
| "gestalt_activity"
|
||||
| "actor_movement"
|
||||
| "actor_activity"
|
||||
| "gestalt_member_activity"
|
||||
| "gestalt_member_migration"
|
||||
| "strategic_activity_outcome"
|
||||
)
|
||||
}
|
||||
|
||||
fn local_draft(name: &str, value_kind: &str) -> Value {
|
||||
json!({"targetProp":"value","pointerId":format!("draft:{name}"),"sourceId":"renderer","schemaId":"gamecult.eve.local_draft.v1","routeKind":"local","bindingName":name,"documentId":"ghostlight.play.drafts","fieldPath":name,"valueKind":value_kind,"accessMode":"local-draft","authority":"renderer-ephemeral"})
|
||||
}
|
||||
|
|
@ -694,7 +713,7 @@ mod tests {
|
|||
crate::domain::Event {
|
||||
id: "event:own-activity".into(),
|
||||
at: Utc::now(),
|
||||
kind: "actor_activity".into(),
|
||||
kind: "player_action".into(),
|
||||
summary: "PLAYER_PRIVATE_ACTIVITY_SUMMARY".into(),
|
||||
actor_ids: vec!["player".into()],
|
||||
institution_ids: vec![],
|
||||
|
|
@ -735,6 +754,17 @@ mod tests {
|
|||
location_ids: vec!["center".into()],
|
||||
public_channels: vec![],
|
||||
},
|
||||
crate::domain::Event {
|
||||
id: "event:targeted".into(),
|
||||
at: Utc::now(),
|
||||
kind: "actor_activity".into(),
|
||||
summary: "The local watch warns the player directly.".into(),
|
||||
actor_ids: vec!["faction-0000".into(), "player".into()],
|
||||
institution_ids: vec![],
|
||||
gestalt_ids: vec![],
|
||||
location_ids: vec!["center".into()],
|
||||
public_channels: vec![],
|
||||
},
|
||||
]);
|
||||
|
||||
let encoded = serde_json::to_string(&player_surface(&campaign)).unwrap();
|
||||
|
|
@ -743,6 +773,7 @@ mod tests {
|
|||
assert!(!encoded.contains("PLAYER_SPEECH_DUPLICATE"));
|
||||
assert!(!encoded.contains("PLAYER_TRAVEL_DUPLICATE"));
|
||||
assert!(encoded.contains("The local watch reinforces the gate."));
|
||||
assert!(encoded.contains("The local watch warns the player directly."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue