Bind direct speech to exact Persona responses
This commit is contained in:
parent
ebd8bc6d9f
commit
fb9c42c266
13 changed files with 732 additions and 25 deletions
|
|
@ -41,6 +41,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
at: campaign.world_time,
|
||||
speaker: "world".into(),
|
||||
text: event_summary.clone(),
|
||||
persona_response_actor_ids: Default::default(),
|
||||
});
|
||||
let store = CampaignStore::open(root.join("campaign.cc"))?;
|
||||
store.create_campaign(&campaign, &[], &[])?;
|
||||
|
|
|
|||
|
|
@ -2918,6 +2918,7 @@ fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Re
|
|||
at: now,
|
||||
speaker: "world".into(),
|
||||
text: seed.opening_narration,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
}],
|
||||
last_player_activity: now,
|
||||
pending_ticks: 0,
|
||||
|
|
|
|||
|
|
@ -646,6 +646,11 @@ pub struct WorldActionProposal {
|
|||
pub struct ActorReaction {
|
||||
pub actor_id: String,
|
||||
pub speech: Option<String>,
|
||||
/// A typed, actor-owned refusal to answer. The kernel lowers this to a
|
||||
/// deterministic visible transcript turn, so an Interpreter cannot smuggle
|
||||
/// an unassessed physical effect through free-form reaction prose.
|
||||
#[serde(default)]
|
||||
pub deliberate_silence: bool,
|
||||
pub private_delta: ActorStateDelta,
|
||||
pub action_proposals: Vec<WorldActionProposal>,
|
||||
}
|
||||
|
|
@ -846,6 +851,11 @@ pub struct NarrativeTurn {
|
|||
pub at: DateTime<Utc>,
|
||||
pub speaker: String,
|
||||
pub text: String,
|
||||
/// Exact model-controlled actors whom this committed speech asks to
|
||||
/// respond. Other present actors still perceive and appraise the turn, but
|
||||
/// they are not response-bound. Human-controlled actors never appear here.
|
||||
#[serde(default)]
|
||||
pub persona_response_actor_ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
|
|
@ -890,6 +900,11 @@ pub enum WorldCommand {
|
|||
actor_id: String,
|
||||
text: String,
|
||||
intended_effect: Option<String>,
|
||||
/// Filled by the server-side scene-address resolver from the exact
|
||||
/// present-actor catalog. Player boundaries must not accept authority
|
||||
/// IDs in this field.
|
||||
#[serde(default)]
|
||||
persona_response_actor_ids: BTreeSet<String>,
|
||||
},
|
||||
Assess {
|
||||
expected_revision: u64,
|
||||
|
|
|
|||
|
|
@ -481,6 +481,7 @@ mod tests {
|
|||
at: chrono::Utc::now(),
|
||||
speaker: campaign.player_actor_id.clone(),
|
||||
text: "I ask the unnamed porter for their name.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
let exact = format!(
|
||||
"{} says: I ask the unnamed porter for their name.",
|
||||
|
|
@ -496,6 +497,7 @@ mod tests {
|
|||
at: chrono::Utc::now(),
|
||||
speaker: "world".into(),
|
||||
text: "They agree to help.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
campaign.revision = 8;
|
||||
assert!(automatic_individuation_stimulus(&campaign).is_none());
|
||||
|
|
|
|||
|
|
@ -348,6 +348,7 @@ fn execute(
|
|||
at: Utc::now(),
|
||||
speaker: "world".into(),
|
||||
text: text.clone(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
commit_mutation_transition(
|
||||
store,
|
||||
|
|
@ -364,20 +365,40 @@ fn execute(
|
|||
actor_id,
|
||||
text,
|
||||
intended_effect,
|
||||
persona_response_actor_ids,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
validate_bounded_text("speech", &text, 4_000)?;
|
||||
if let Some(effect) = &intended_effect {
|
||||
validate_bounded_text("intended effect", effect, 1_000)?;
|
||||
}
|
||||
if !campaign.actors.contains_key(&actor_id) {
|
||||
return Err(KernelError::Invalid("unknown actor".into()));
|
||||
let speaker = campaign
|
||||
.actors
|
||||
.get(&actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("unknown actor".into()))?;
|
||||
for response_actor_id in &persona_response_actor_ids {
|
||||
let response_actor = campaign.actors.get(response_actor_id).ok_or_else(|| {
|
||||
KernelError::Invalid("speech response actor is unknown".into())
|
||||
})?;
|
||||
if response_actor_id == &actor_id
|
||||
|| response_actor.location_id != speaker.location_id
|
||||
|| campaign
|
||||
.agency_profiles
|
||||
.get(response_actor_id)
|
||||
.is_some_and(|profile| !profile.simulation_eligible)
|
||||
{
|
||||
return Err(KernelError::Invalid(
|
||||
"speech response actor is not an eligible present Persona".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let response_actor_ids = persona_response_actor_ids.clone();
|
||||
campaign.transcript.push(NarrativeTurn {
|
||||
revision: campaign.revision + 1,
|
||||
at: Utc::now(),
|
||||
speaker: actor_id.clone(),
|
||||
text,
|
||||
persona_response_actor_ids,
|
||||
});
|
||||
if let Some(effect) = intended_effect {
|
||||
campaign.transcript.push(NarrativeTurn {
|
||||
|
|
@ -385,11 +406,13 @@ fn execute(
|
|||
at: Utc::now(),
|
||||
speaker: "system".into(),
|
||||
text: format!("Intended effect requires assessment: {effect}"),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
}
|
||||
refresh_materialized_member_relevance(
|
||||
&mut campaign,
|
||||
std::iter::once(actor_id.as_str()),
|
||||
std::iter::once(actor_id.as_str())
|
||||
.chain(response_actor_ids.iter().map(String::as_str)),
|
||||
);
|
||||
if is_human_controlled_actor(&campaign, &actor_id) {
|
||||
campaign.last_player_activity = Utc::now();
|
||||
|
|
@ -1265,7 +1288,9 @@ fn execute(
|
|||
reactions,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
let witnessed_memory = canonical_witnessed_memory(&campaign, &event_summary)?;
|
||||
let witnessed_turn = canonical_witnessed_turn(&campaign, &event_summary)?;
|
||||
let response_expected_actor_ids = witnessed_turn.persona_response_actor_ids.clone();
|
||||
let witnessed_memory = format!("Witnessed: {}", canonical_turn_text(witnessed_turn));
|
||||
let player_location = campaign.actors[&campaign.player_actor_id]
|
||||
.location_id
|
||||
.clone();
|
||||
|
|
@ -1288,10 +1313,33 @@ fn execute(
|
|||
"reaction Interpreter cannot write actor memory".into(),
|
||||
));
|
||||
}
|
||||
if let Some(speech) = &reaction.speech {
|
||||
validate_bounded_text("reaction speech", speech, 1_000)?;
|
||||
}
|
||||
for proposal in &reaction.action_proposals {
|
||||
validate_world_proposal(actor, proposal)?;
|
||||
}
|
||||
}
|
||||
for response_actor_id in &response_expected_actor_ids {
|
||||
let reaction = reactions
|
||||
.iter()
|
||||
.find(|reaction| &reaction.actor_id == response_actor_id)
|
||||
.ok_or_else(|| {
|
||||
KernelError::Invalid(
|
||||
"directly addressed Persona is absent from the reaction wave".into(),
|
||||
)
|
||||
})?;
|
||||
if reaction
|
||||
.speech
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
&& !reaction.deliberate_silence
|
||||
{
|
||||
return Err(KernelError::Invalid(
|
||||
"directly addressed Persona produced no observable response".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let source_receipt_id = format!(
|
||||
"reaction-input:{}",
|
||||
crate::legacy_transition::digest_serializable(&reactions)
|
||||
|
|
@ -1319,6 +1367,16 @@ fn execute(
|
|||
at: Utc::now(),
|
||||
speaker: reaction.actor_id.clone(),
|
||||
text: speech,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
}
|
||||
if reaction.deliberate_silence {
|
||||
campaign.transcript.push(NarrativeTurn {
|
||||
revision: campaign.revision + 1,
|
||||
at: Utc::now(),
|
||||
speaker: reaction.actor_id.clone(),
|
||||
text: "deliberately does not answer.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
}
|
||||
campaign
|
||||
|
|
@ -1450,6 +1508,7 @@ fn execute(
|
|||
at: Utc::now(),
|
||||
speaker: "world".into(),
|
||||
text: text.clone(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
Some((roll, transition, mutation_receipt))
|
||||
} else {
|
||||
|
|
@ -1708,21 +1767,16 @@ fn validate_world_proposal(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_witnessed_memory(
|
||||
campaign: &Campaign,
|
||||
fn canonical_witnessed_turn<'a>(
|
||||
campaign: &'a Campaign,
|
||||
event_summary: &str,
|
||||
) -> Result<String, KernelError> {
|
||||
) -> Result<&'a NarrativeTurn, KernelError> {
|
||||
let event_summary = event_summary.trim();
|
||||
let witnessed = campaign.transcript.iter().rev().find_map(|turn| {
|
||||
let canonical = if turn.speaker == "world" {
|
||||
turn.text.trim().to_owned()
|
||||
} else {
|
||||
format!("{} says: {}", turn.speaker, turn.text.trim())
|
||||
};
|
||||
(canonical == event_summary).then_some(canonical)
|
||||
});
|
||||
witnessed
|
||||
.map(|canonical| format!("Witnessed: {canonical}"))
|
||||
campaign
|
||||
.transcript
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|turn| canonical_turn_text(turn) == event_summary)
|
||||
.ok_or_else(|| {
|
||||
KernelError::Invalid(
|
||||
"reaction stimulus does not match a committed transcript turn".into(),
|
||||
|
|
@ -1730,6 +1784,14 @@ fn canonical_witnessed_memory(
|
|||
})
|
||||
}
|
||||
|
||||
fn canonical_turn_text(turn: &NarrativeTurn) -> String {
|
||||
if turn.speaker == "world" {
|
||||
turn.text.trim().to_owned()
|
||||
} else {
|
||||
format!("{} says: {}", turn.speaker, turn.text.trim())
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(
|
||||
base: &BTreeSet<String>,
|
||||
add: &BTreeSet<String>,
|
||||
|
|
@ -3383,6 +3445,7 @@ mod tests {
|
|||
at: Utc::now(),
|
||||
speaker: "world".into(),
|
||||
text: "They agree to help design the relay.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
seed.gestalts.insert(
|
||||
"refugees".into(),
|
||||
|
|
@ -5020,6 +5083,7 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "x".repeat(4_001),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
|
|
@ -5059,6 +5123,7 @@ mod tests {
|
|||
actor_id: "npc".into(),
|
||||
text: "The world does not wait.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -5224,6 +5289,7 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "Wait.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -5764,6 +5830,94 @@ mod tests {
|
|||
assert_eq!(receipts[2].resolution_epoch, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_addressed_persona_must_commit_an_observable_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
|
||||
let kernel = WorldKernel::start(store.clone());
|
||||
let mut seed = campaign();
|
||||
seed.actors.insert(
|
||||
"anna".into(),
|
||||
ActorState {
|
||||
id: "anna".into(),
|
||||
name: "Anna".into(),
|
||||
location_id: "room".into(),
|
||||
capabilities: BTreeSet::new(),
|
||||
knowledge: BTreeSet::new(),
|
||||
equipment: BTreeSet::new(),
|
||||
conditions: BTreeSet::new(),
|
||||
obligations: BTreeSet::new(),
|
||||
relationships: BTreeMap::new(),
|
||||
goals: vec![],
|
||||
memories: vec![],
|
||||
},
|
||||
);
|
||||
seed.transcript.push(NarrativeTurn {
|
||||
revision: 0,
|
||||
at: seed.world_time,
|
||||
speaker: "player".into(),
|
||||
text: "Anna, answer me.".into(),
|
||||
persona_response_actor_ids: BTreeSet::from(["anna".into()]),
|
||||
});
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let silent = ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
};
|
||||
let error = kernel
|
||||
.command(WorldCommand::ResolveReactionWave {
|
||||
expected_revision: 0,
|
||||
event_summary: "player says: Anna, answer me.".into(),
|
||||
reactions: vec![silent],
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("no observable response"));
|
||||
assert_eq!(
|
||||
store
|
||||
.load::<Campaign>("campaign.v1", &seed.id.to_string())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.1
|
||||
.revision,
|
||||
0
|
||||
);
|
||||
|
||||
let committed = kernel
|
||||
.command(WorldCommand::ResolveReactionWave {
|
||||
expected_revision: 0,
|
||||
event_summary: "player says: Anna, answer me.".into(),
|
||||
reactions: vec![ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: None,
|
||||
deliberate_silence: true,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let CommandResult::Committed { campaign, .. } = committed else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(campaign.revision, 1);
|
||||
assert_eq!(
|
||||
campaign.transcript.last().unwrap().text,
|
||||
"deliberately does not answer."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_reaction_rejects_the_entire_wave_without_mutation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -5800,6 +5954,7 @@ mod tests {
|
|||
ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: Some("I saw that.".into()),
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta {
|
||||
memories_add: vec!["saw the event".into()],
|
||||
..Default::default()
|
||||
|
|
@ -5809,6 +5964,7 @@ mod tests {
|
|||
ActorReaction {
|
||||
actor_id: "bert".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![WorldActionProposal {
|
||||
actor_id: "bert".into(),
|
||||
|
|
@ -5851,6 +6007,7 @@ mod tests {
|
|||
at: seed.world_time,
|
||||
speaker: "player".into(),
|
||||
text: "Tell me which seal I repaired.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
seed.actors.insert(
|
||||
"anna".into(),
|
||||
|
|
@ -5884,6 +6041,7 @@ mod tests {
|
|||
reactions: vec![ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta {
|
||||
memories_add: vec!["I repaired the seal.".into()],
|
||||
..Default::default()
|
||||
|
|
@ -5915,6 +6073,7 @@ mod tests {
|
|||
at: seed.world_time,
|
||||
speaker: "player".into(),
|
||||
text: "Tell me which seal I repaired.".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
seed.actors.insert(
|
||||
"anna".into(),
|
||||
|
|
@ -5948,6 +6107,7 @@ mod tests {
|
|||
reactions: vec![ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: Some("I did not witness it.".into()),
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
|
|
@ -5977,6 +6137,7 @@ mod tests {
|
|||
at: seed.world_time,
|
||||
speaker: "world".into(),
|
||||
text: "a disturbance".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
seed.last_player_activity = Utc::now() - Duration::hours(2);
|
||||
for id in ["anna", "bert"] {
|
||||
|
|
@ -6024,12 +6185,14 @@ mod tests {
|
|||
ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![anna.clone()],
|
||||
},
|
||||
ActorReaction {
|
||||
actor_id: "bert".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![bert.clone()],
|
||||
},
|
||||
|
|
@ -6138,6 +6301,7 @@ mod tests {
|
|||
at: seed.world_time,
|
||||
speaker: "world".into(),
|
||||
text: "a disturbance".into(),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
});
|
||||
seed.actors.insert(
|
||||
"anna".into(),
|
||||
|
|
@ -6178,6 +6342,7 @@ mod tests {
|
|||
reactions: vec![ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![proposal.clone()],
|
||||
}],
|
||||
|
|
@ -6190,6 +6355,7 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "I move on before Anna acts.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -6226,6 +6392,7 @@ mod tests {
|
|||
reactions: vec![ActorReaction {
|
||||
actor_id: "anna".into(),
|
||||
speech: None,
|
||||
deliberate_silence: false,
|
||||
private_delta: ActorStateDelta::default(),
|
||||
action_proposals: vec![],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ use ghostlight_dungeon::{
|
|||
surface::{
|
||||
campaign_interface_version, player_surface_for_actor, rebase_campaign_surface_revision,
|
||||
},
|
||||
turn::{SnapshotPermit, appraise_present},
|
||||
turn::{SnapshotPermit, appraise_present, resolve_speech_addresses},
|
||||
vault::VoidBotMcpVault,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -1468,6 +1468,7 @@ async fn dispatch_eve_product_command(
|
|||
actor_id,
|
||||
text: required_string!("text"),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
},
|
||||
"world.assess" => WorldCommand::Assess {
|
||||
expected_revision: required_u64!("expected_revision"),
|
||||
|
|
@ -4205,6 +4206,65 @@ async fn command(
|
|||
let body = player_safe_strategic_failure(&error);
|
||||
return (StatusCode::CONFLICT, Json(body)).into_response();
|
||||
}
|
||||
if let WorldCommand::Speak {
|
||||
expected_revision,
|
||||
actor_id,
|
||||
text,
|
||||
persona_response_actor_ids,
|
||||
..
|
||||
} = &mut command
|
||||
{
|
||||
let campaign = match load_campaign(&runtime.store) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorBody {
|
||||
error: error.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if campaign.revision != *expected_revision {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorBody {
|
||||
error: format!(
|
||||
"stale revision: expected {expected_revision}, actual {}",
|
||||
campaign.revision
|
||||
),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Some(model) = &state.model {
|
||||
match resolve_speech_addresses(model.clone(), MODEL_FAST, &campaign, actor_id, text)
|
||||
.await
|
||||
{
|
||||
Ok((plan, receipts)) => {
|
||||
for receipt in receipts {
|
||||
let _ = runtime.store.insert(
|
||||
"persona_stage_receipt.v1",
|
||||
"ghostlight.persona_stage_receipt.v1",
|
||||
receipt.storage_key(),
|
||||
&receipt,
|
||||
);
|
||||
}
|
||||
*persona_response_actor_ids = plan.persona_response_actor_ids;
|
||||
}
|
||||
Err(error) => {
|
||||
return (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(ErrorBody {
|
||||
error: format!("scene address resolution failed: {error}"),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let WorldCommand::Wait {
|
||||
expected_revision,
|
||||
minutes,
|
||||
|
|
@ -4919,6 +4979,7 @@ fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> {
|
|||
WorldCommand::Speak {
|
||||
text,
|
||||
intended_effect,
|
||||
persona_response_actor_ids,
|
||||
..
|
||||
} => {
|
||||
bounded("speech", text, 4_000)?;
|
||||
|
|
@ -4927,6 +4988,9 @@ fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> {
|
|||
"speech and uncertain intended effects use separate player commands".into(),
|
||||
);
|
||||
}
|
||||
if !persona_response_actor_ids.is_empty() {
|
||||
return Err("player commands cannot supply Persona response authority IDs".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
WorldCommand::Assess { intent, .. } => {
|
||||
|
|
@ -6433,6 +6497,7 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "Hello.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
},
|
||||
"player",
|
||||
));
|
||||
|
|
@ -6442,6 +6507,7 @@ mod tests {
|
|||
actor_id: "npc".into(),
|
||||
text: "I have been puppeted.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
},
|
||||
"player",
|
||||
));
|
||||
|
|
@ -6489,6 +6555,7 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "Hello.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
}
|
||||
));
|
||||
assert!(player_command_requires_return_catch_up(
|
||||
|
|
@ -6553,6 +6620,17 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "Hello".into(),
|
||||
intended_effect: Some("obey me".into()),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_player_http_command(&WorldCommand::Speak {
|
||||
expected_revision: 0,
|
||||
actor_id: "player".into(),
|
||||
text: "Make the NPC answer.".into(),
|
||||
intended_effect: None,
|
||||
persona_response_actor_ids: BTreeSet::from(["npc".into()]),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
|
|
@ -6577,6 +6655,7 @@ mod tests {
|
|||
actor_id: "player".into(),
|
||||
text: "Which record can I inspect without taking custody?".into(),
|
||||
intended_effect: Some("make the archivist disclose every secret".into()),
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
};
|
||||
let stimulus = reaction_stimulus(&command).unwrap();
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -879,7 +879,9 @@ pub struct FixtureModel;
|
|||
#[async_trait]
|
||||
impl ModelPort for FixtureModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
Ok(if request.output_schema.is_some() {
|
||||
Ok(if request.stage == "speech_address_resolver" {
|
||||
r#"{"persona_response_actor_ids":[]}"#.into()
|
||||
} else if request.output_schema.is_some() {
|
||||
r#"{"private_delta":{"memories_add":[],"conditions_add":[],"conditions_remove":[],"goals_add":[],"relationship_updates":{}},"speech":null,"reaction_priority":0,"world_actions":[]}"#.into()
|
||||
} else {
|
||||
request.lived_stream.to_string()
|
||||
|
|
|
|||
|
|
@ -12,11 +12,19 @@ use sha2::{Digest, Sha256};
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActorInteractionRole {
|
||||
DirectResponseExpected,
|
||||
PresentObserver,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PermittedActorSlice {
|
||||
pub actor_id: String,
|
||||
pub location_id: String,
|
||||
pub snapshot_binding: String,
|
||||
pub interaction_role: ActorInteractionRole,
|
||||
pub identity_experience: Vec<String>,
|
||||
pub memories: Vec<String>,
|
||||
pub perceived_events: Vec<String>,
|
||||
|
|
@ -41,6 +49,10 @@ pub struct LivedNarrativeStream {
|
|||
pub struct PersonaProposalBundle {
|
||||
pub private_delta: crate::domain::ActorStateDelta,
|
||||
pub speech: Option<String>,
|
||||
/// Typed refusal lane for a directly addressed actor. WorldKernel lowers
|
||||
/// it deterministically; this field cannot carry free-form effects.
|
||||
#[serde(default)]
|
||||
pub deliberate_silence: bool,
|
||||
pub reaction_priority: i16,
|
||||
pub world_actions: Vec<crate::domain::WorldActionProposal>,
|
||||
}
|
||||
|
|
@ -362,7 +374,7 @@ impl PersonaProjectionEngine {
|
|||
lived_stream: build_persona_prompt(&PersonaPrompt {
|
||||
identity: &slice.actor_id,
|
||||
lived_stream: &lived.text,
|
||||
domain_guidance: "Respond as a situated character. Answer direct questions at human conversational length. Speech and attempted effects are distinct; the world kernel resolves consequences. Asking, inviting, persuading, threatening, or demanding completes only your own speech: never supply the other person's answer, choice, consent, belief, disclosure, or obedience.",
|
||||
domain_guidance: "Respond as a situated character. When the lived stream says a response is expected from you, make your answer, refusal, or deliberate silence observable now. Speech and attempted effects are distinct; the world kernel resolves consequences. Asking, inviting, persuading, threatening, or demanding completes only your own speech: never supply the other person's answer, choice, consent, belief, disclosure, or obedience.",
|
||||
word_budget: 160,
|
||||
}),
|
||||
output_schema: None,
|
||||
|
|
@ -395,7 +407,7 @@ impl PersonaProjectionEngine {
|
|||
domain_guidance: &permission_guidance,
|
||||
}),
|
||||
output_schema: Some(schema),
|
||||
source_receipt_ids: slice.source_receipt_ids,
|
||||
source_receipt_ids: slice.source_receipt_ids.clone(),
|
||||
temperature: Some(0.0),
|
||||
max_output_tokens: Some(768),
|
||||
},
|
||||
|
|
@ -410,6 +422,7 @@ impl PersonaProjectionEngine {
|
|||
.clone()
|
||||
.ok_or_else(|| anyhow!("interpreter produced no typed proposal"))?,
|
||||
)?;
|
||||
validate_actor_proposals(&slice, &proposals)?;
|
||||
Ok(PersonaTerminalBundle {
|
||||
lived_stream: lived,
|
||||
persona_output: persona.narrative,
|
||||
|
|
@ -420,8 +433,16 @@ impl PersonaProjectionEngine {
|
|||
}
|
||||
|
||||
fn actor_interpreter_guidance(slice: &PermittedActorSlice) -> String {
|
||||
let response_guidance = match slice.interaction_role {
|
||||
ActorInteractionRole::DirectResponseExpected => {
|
||||
"This actor is an exact direct addressee. Extract an explicit spoken response, or set deliberate_silence true only when the Persona deliberately refuses or remains silent. Do not erase the Persona's response into null fields."
|
||||
}
|
||||
ActorInteractionRole::PresentObserver => {
|
||||
"This actor is present but was not directly asked to respond. Speech or observable action remains optional and must follow only from this actor's projected choice."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.",
|
||||
"{response_guidance} Record only private changes supported by the lived stream and typed context. World actions are attempts, not completed effects. Speech is extracted separately and is already complete. Do not emit a world action merely to make another actor answer, choose, consent, believe, disclose, feel, or obey; the other actor retains agency and any requested response remains unresolved. actor_id must be {:?}. Exact allowed state references are {:?}. Relationship update keys may only be {:?}.",
|
||||
slice.actor_id,
|
||||
allowed_actor_references(slice),
|
||||
slice.perceived_actors.keys().collect::<Vec<_>>()
|
||||
|
|
@ -455,11 +476,46 @@ fn ground_actor_lived_stream(slice: &PermittedActorSlice, projection: &str) -> S
|
|||
} else {
|
||||
slice.memories[recent_memory_start..].join("; ")
|
||||
};
|
||||
let interaction = match slice.interaction_role {
|
||||
ActorInteractionRole::DirectResponseExpected => {
|
||||
"You are an exact direct addressee of the current speech. A response is expected from you now. You retain the agency to answer, refuse, or remain silent, but refusal or silence must be made observable rather than disappearing from the scene."
|
||||
}
|
||||
ActorInteractionRole::PresentObserver => {
|
||||
"You are present and perceive the current event, but you were not directly asked to respond. You may react from your own goals and pressures; you need not seize conversational focus."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"{projection}\n\nYour reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact."
|
||||
"{projection}\n\n{interaction} Your reliable footing in this moment is narrow. What you know as external fact: {reliable_knowledge}. What you remember experiencing or being told: {remembered_experience}. These are your attributed recollections, not omniscient proof. What is happening now: {visible_now}. People you can presently perceive: {people_now}. Everything else in your impressions is feeling, inference, uncertainty, or possibility—not a remembered or witnessed fact."
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_actor_proposals(
|
||||
slice: &PermittedActorSlice,
|
||||
proposals: &PersonaProposalBundle,
|
||||
) -> Result<()> {
|
||||
if matches!(
|
||||
slice.interaction_role,
|
||||
ActorInteractionRole::DirectResponseExpected
|
||||
) && proposals
|
||||
.speech
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
&& !proposals.deliberate_silence
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"directly addressed Persona produced no observable response"
|
||||
));
|
||||
}
|
||||
if proposals
|
||||
.speech
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty() || value.chars().count() > 1_000)
|
||||
{
|
||||
return Err(anyhow!("Persona speech must contain 1 to 1000 characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cell_projector_context(slice: &PermittedCellSlice) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"cell_id": slice.cell_id,
|
||||
|
|
@ -2537,6 +2593,36 @@ fn constrain_interpreter_schema(
|
|||
.and_then(|value| value.as_object_mut())
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema has no memory additions"))?;
|
||||
memories_add.insert("maxItems".into(), serde_json::json!(0));
|
||||
let root = schema
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema is not an object"))?;
|
||||
let properties = root
|
||||
.get_mut("properties")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema has no root properties"))?;
|
||||
properties.insert(
|
||||
"speech".into(),
|
||||
serde_json::json!({"type":["string","null"],"minLength":1,"maxLength":1000}),
|
||||
);
|
||||
properties.insert(
|
||||
"deliberate_silence".into(),
|
||||
serde_json::json!({"type":"boolean"}),
|
||||
);
|
||||
if matches!(
|
||||
slice.interaction_role,
|
||||
ActorInteractionRole::DirectResponseExpected
|
||||
) {
|
||||
root.entry("allOf")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or_else(|| anyhow!("Persona proposal schema allOf is not an array"))?
|
||||
.push(serde_json::json!({
|
||||
"anyOf":[
|
||||
{"required":["speech"],"properties":{"speech":{"type":"string","minLength":1,"maxLength":1000}}},
|
||||
{"required":["deliberate_silence"],"properties":{"deliberate_silence":{"const":true}}}
|
||||
]
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -3834,6 +3920,7 @@ mod tests {
|
|||
actor_id: "npc".into(),
|
||||
location_id: "room".into(),
|
||||
snapshot_binding: "campaign:1".into(),
|
||||
interaction_role: ActorInteractionRole::PresentObserver,
|
||||
identity_experience: vec!["A tired navigator".into()],
|
||||
memories: vec!["Proposed the eastern trail evacuation at dusk.".into()],
|
||||
perceived_events: vec![],
|
||||
|
|
|
|||
|
|
@ -475,18 +475,21 @@ mod tests {
|
|||
at: Utc::now(),
|
||||
speaker: "player".into(),
|
||||
text: "I ask the question.".into(),
|
||||
persona_response_actor_ids: Default::default(),
|
||||
},
|
||||
NarrativeTurn {
|
||||
revision: 2,
|
||||
at: Utc::now(),
|
||||
speaker: "world".into(),
|
||||
text: "raw outcome".into(),
|
||||
persona_response_actor_ids: Default::default(),
|
||||
},
|
||||
NarrativeTurn {
|
||||
revision: 3,
|
||||
at: Utc::now(),
|
||||
speaker: "npc".into(),
|
||||
text: "I answer directly.".into(),
|
||||
persona_response_actor_ids: Default::default(),
|
||||
},
|
||||
];
|
||||
let story = story_nodes(&campaign);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,128 @@
|
|||
use crate::{
|
||||
domain::{ActorReaction, Campaign},
|
||||
model::ModelStageReceipt,
|
||||
model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage},
|
||||
persistence::CampaignStore,
|
||||
persona::{ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine},
|
||||
persona::{
|
||||
ActorInteractionRole, ExecutionPermit, PermittedActorSlice, PersonaProjectionEngine,
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use schemars::{JsonSchema, schema_for};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct SpeechAddressPlan {
|
||||
/// Exact present, model-controlled actors from whom the utterance requests
|
||||
/// an answer. Mentioned people and passive hearers are not included.
|
||||
pub persona_response_actor_ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Resolve conversational address from player language against the exact
|
||||
/// present-actor catalog. The output is proposal-only until WorldKernel
|
||||
/// validates co-presence and commits it with the speech turn.
|
||||
pub async fn resolve_speech_addresses(
|
||||
model: Arc<dyn ModelPort>,
|
||||
model_name: &str,
|
||||
campaign: &Campaign,
|
||||
speaker_actor_id: &str,
|
||||
speech: &str,
|
||||
) -> Result<(SpeechAddressPlan, Vec<ModelStageReceipt>)> {
|
||||
let speaker = campaign
|
||||
.actors
|
||||
.get(speaker_actor_id)
|
||||
.ok_or_else(|| anyhow!("speech actor is unknown"))?;
|
||||
let candidates = campaign
|
||||
.actors
|
||||
.values()
|
||||
.filter(|actor| {
|
||||
actor.id != speaker_actor_id
|
||||
&& actor.location_id == speaker.location_id
|
||||
&& campaign
|
||||
.agency_profiles
|
||||
.get(&actor.id)
|
||||
.is_none_or(|profile| profile.simulation_eligible)
|
||||
})
|
||||
.map(|actor| (actor.id.clone(), actor.name.clone()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok((
|
||||
SpeechAddressPlan {
|
||||
persona_response_actor_ids: BTreeSet::new(),
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
let allowed_ids = candidates.keys().cloned().collect::<BTreeSet<_>>();
|
||||
let previous_focus = campaign
|
||||
.transcript
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|turn| {
|
||||
turn.speaker == speaker_actor_id && !turn.persona_response_actor_ids.is_empty()
|
||||
})
|
||||
.map(|turn| turn.persona_response_actor_ids.clone())
|
||||
.unwrap_or_default();
|
||||
let recent_public_turns = campaign
|
||||
.transcript
|
||||
.iter()
|
||||
.rev()
|
||||
.take(8)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|turn| {
|
||||
serde_json::json!({
|
||||
"speaker":turn.speaker,
|
||||
"text":turn.text,
|
||||
"persona_response_actor_ids":turn.persona_response_actor_ids,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut schema = serde_json::to_value(schema_for!(SpeechAddressPlan))?;
|
||||
schema["properties"]["persona_response_actor_ids"] = serde_json::json!({
|
||||
"type":"array",
|
||||
"uniqueItems":true,
|
||||
"maxItems":allowed_ids.len(),
|
||||
"items":{"type":"string","enum":allowed_ids},
|
||||
});
|
||||
let prompt = format!(
|
||||
"You are Ghostlight's private scene-address resolver. Decide only which supplied present, model-controlled actors this exact speech directly asks to answer now. A person who is merely mentioned, discussed, visible, or able to overhear is not a response target. A room-wide statement need not demand an answer from everyone. Preserve ordinary conversational focus from previous_focus when pronouns or an unqualified follow-up clearly continue it. Select only exact supplied actor IDs. Empty is valid when the speaker asks nobody present to answer. Return exactly one JSON object matching the schema.\nSCHEMA:\n{}\nPRESENT ACTORS:\n{}\nPREVIOUS FOCUS:\n{}\nRECENT PUBLIC TURNS:\n{}\nSPEAKER ACTOR ID:\n{}\nEXACT SPEECH:\n{}",
|
||||
serde_json::to_string(&schema)?,
|
||||
serde_json::to_string(&candidates)?,
|
||||
serde_json::to_string(&previous_focus)?,
|
||||
serde_json::to_string(&recent_public_turns)?,
|
||||
serde_json::to_string(speaker_actor_id)?,
|
||||
serde_json::to_string(speech)?,
|
||||
);
|
||||
let output = run_validated_stage(
|
||||
model.as_ref(),
|
||||
&ModelStageRequest {
|
||||
stage: "speech_address_resolver".into(),
|
||||
model: model_name.into(),
|
||||
snapshot_binding: format!("campaign:{}:revision:{}", campaign.id, campaign.revision),
|
||||
lived_stream: prompt,
|
||||
output_schema: Some(schema),
|
||||
source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(),
|
||||
temperature: Some(0.0),
|
||||
max_output_tokens: Some(256),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let plan: SpeechAddressPlan = serde_json::from_value(
|
||||
output
|
||||
.structured
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("speech address resolver produced no plan"))?,
|
||||
)?;
|
||||
if !plan.persona_response_actor_ids.is_subset(&allowed_ids) {
|
||||
return Err(anyhow!(
|
||||
"speech address resolver selected an unavailable actor"
|
||||
));
|
||||
}
|
||||
Ok((plan, vec![output.receipt]))
|
||||
}
|
||||
|
||||
pub struct SnapshotPermit {
|
||||
store: CampaignStore,
|
||||
|
|
@ -65,6 +182,9 @@ pub async fn appraise_present(
|
|||
campaign: &Campaign,
|
||||
event_summary: &str,
|
||||
) -> Result<ReactionWaveOutput> {
|
||||
let response_expected_actor_ids = canonical_reaction_turn(campaign, event_summary)?
|
||||
.persona_response_actor_ids
|
||||
.clone();
|
||||
let player = campaign
|
||||
.actors
|
||||
.get(&campaign.player_actor_id)
|
||||
|
|
@ -98,6 +218,11 @@ pub async fn appraise_present(
|
|||
let receipts = campaign.branch_origin.evidence_receipt_ids.clone();
|
||||
let perceived_actors = perceived_actors.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let interaction_role = if response_expected_actor_ids.contains(&actor.id) {
|
||||
ActorInteractionRole::DirectResponseExpected
|
||||
} else {
|
||||
ActorInteractionRole::PresentObserver
|
||||
};
|
||||
jobs.spawn(async move {
|
||||
let _provider_slot = semaphore
|
||||
.acquire_owned()
|
||||
|
|
@ -107,6 +232,7 @@ pub async fn appraise_present(
|
|||
actor_id: actor.id.clone(),
|
||||
location_id: actor.location_id.clone(),
|
||||
snapshot_binding: snapshot,
|
||||
interaction_role,
|
||||
identity_experience: vec![format!("You are {}.", actor.name)],
|
||||
memories: actor.memories,
|
||||
perceived_events: vec![event],
|
||||
|
|
@ -140,6 +266,7 @@ pub async fn appraise_present(
|
|||
reactions.push(ActorReaction {
|
||||
actor_id,
|
||||
speech: terminal.proposals.speech,
|
||||
deliberate_silence: terminal.proposals.deliberate_silence,
|
||||
private_delta: terminal.proposals.private_delta,
|
||||
action_proposals: terminal.proposals.world_actions,
|
||||
});
|
||||
|
|
@ -150,3 +277,188 @@ pub async fn appraise_present(
|
|||
receipts,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_reaction_turn<'a>(
|
||||
campaign: &'a Campaign,
|
||||
event_summary: &str,
|
||||
) -> Result<&'a crate::domain::NarrativeTurn> {
|
||||
let event_summary = event_summary.trim();
|
||||
campaign
|
||||
.transcript
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|turn| {
|
||||
let canonical = if turn.speaker == "world" {
|
||||
turn.text.trim().to_owned()
|
||||
} else {
|
||||
format!("{} says: {}", turn.speaker, turn.text.trim())
|
||||
};
|
||||
canonical == event_summary
|
||||
})
|
||||
.ok_or_else(|| anyhow!("reaction stimulus does not match a committed transcript turn"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
domain::{ActorState, NarrativeTurn},
|
||||
model::ModelStageRequest,
|
||||
persona::{AllowAllPermit, PersonaProjectionEngine},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct AddressAwareModel;
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for AddressAwareModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
Ok(match request.stage.as_str() {
|
||||
"speech_address_resolver" => {
|
||||
assert!(request.lived_stream.contains("Refugee One"));
|
||||
assert!(request.lived_stream.contains("Refugee Two"));
|
||||
assert!(request.lived_stream.contains("Relay Observer"));
|
||||
serde_json::json!({
|
||||
"persona_response_actor_ids":["refugee-one","refugee-two"]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
"projector" => "The question lands in the small circle.".into(),
|
||||
"persona" => {
|
||||
if request.lived_stream.contains("exact direct addressee") {
|
||||
"I answer plainly: that memory is mine alone.".into()
|
||||
} else {
|
||||
"I listen without taking over their answer.".into()
|
||||
}
|
||||
}
|
||||
"interpreter" => {
|
||||
let direct = request.lived_stream.contains("direct_response_expected");
|
||||
serde_json::json!({
|
||||
"private_delta":{
|
||||
"memories_add":[],
|
||||
"conditions_add":[],
|
||||
"conditions_remove":[],
|
||||
"goals_add":[],
|
||||
"relationship_updates":{},
|
||||
},
|
||||
"speech":direct.then_some("that memory is mine alone"),
|
||||
"deliberate_silence":false,
|
||||
"reaction_priority":0,
|
||||
"world_actions":[],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
stage => return Err(anyhow!("unexpected stage {stage}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn provider(&self) -> &'static str {
|
||||
"fixture"
|
||||
}
|
||||
}
|
||||
|
||||
fn actor(id: &str, name: &str, location_id: &str) -> ActorState {
|
||||
ActorState {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
location_id: location_id.into(),
|
||||
capabilities: BTreeSet::from(["ordinary conversation".into()]),
|
||||
knowledge: BTreeSet::new(),
|
||||
equipment: BTreeSet::new(),
|
||||
conditions: BTreeSet::new(),
|
||||
obligations: BTreeSet::new(),
|
||||
relationships: BTreeMap::new(),
|
||||
goals: vec![],
|
||||
memories: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn addressed_campaign() -> Campaign {
|
||||
let mut campaign = crate::resolution::tests::campaign(0, 8);
|
||||
let location = campaign.actors[&campaign.player_actor_id]
|
||||
.location_id
|
||||
.clone();
|
||||
campaign.actors.insert(
|
||||
"refugee-one".into(),
|
||||
actor("refugee-one", "Refugee One", &location),
|
||||
);
|
||||
campaign.actors.insert(
|
||||
"refugee-two".into(),
|
||||
actor("refugee-two", "Refugee Two", &location),
|
||||
);
|
||||
campaign.actors.insert(
|
||||
"relay-observer".into(),
|
||||
actor("relay-observer", "Relay Observer", &location),
|
||||
);
|
||||
crate::resolution::ensure_agency_profiles(&mut campaign);
|
||||
campaign
|
||||
.agency_profiles
|
||||
.get_mut(&campaign.player_actor_id)
|
||||
.unwrap()
|
||||
.simulation_eligible = false;
|
||||
campaign
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn address_resolver_selects_exact_supplied_respondents() {
|
||||
let campaign = addressed_campaign();
|
||||
let (plan, receipts) = resolve_speech_addresses(
|
||||
Arc::new(AddressAwareModel),
|
||||
"flash",
|
||||
&campaign,
|
||||
&campaign.player_actor_id,
|
||||
"Refugee One and Refugee Two: answer separately. Relay Observer, listen.",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.persona_response_actor_ids,
|
||||
BTreeSet::from(["refugee-one".into(), "refugee-two".into()])
|
||||
);
|
||||
assert_eq!(receipts.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_addressees_receive_response_duty_while_observers_keep_agency() {
|
||||
let mut campaign = addressed_campaign();
|
||||
let speech = "Refugee One and Refugee Two: answer separately.";
|
||||
campaign.transcript.push(NarrativeTurn {
|
||||
revision: campaign.revision,
|
||||
at: Utc::now(),
|
||||
speaker: campaign.player_actor_id.clone(),
|
||||
text: speech.into(),
|
||||
persona_response_actor_ids: BTreeSet::from([
|
||||
"refugee-one".into(),
|
||||
"refugee-two".into(),
|
||||
]),
|
||||
});
|
||||
let summary = format!("{} says: {speech}", campaign.player_actor_id);
|
||||
let engine = PersonaProjectionEngine {
|
||||
model: Arc::new(AddressAwareModel),
|
||||
permit: Arc::new(AllowAllPermit),
|
||||
projector_model: "flash".into(),
|
||||
persona_model: "pro".into(),
|
||||
interpreter_model: "flash".into(),
|
||||
};
|
||||
let wave = appraise_present(engine, &campaign, &summary).await.unwrap();
|
||||
assert_eq!(wave.reactions.len(), 3);
|
||||
for actor_id in ["refugee-one", "refugee-two"] {
|
||||
assert_eq!(
|
||||
wave.reactions
|
||||
.iter()
|
||||
.find(|reaction| reaction.actor_id == actor_id)
|
||||
.and_then(|reaction| reaction.speech.as_deref()),
|
||||
Some("that memory is mine alone")
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
wave.reactions
|
||||
.iter()
|
||||
.find(|reaction| reaction.actor_id == "relay-observer")
|
||||
.unwrap()
|
||||
.speech
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -694,6 +694,19 @@ to their own snapshot versions. Initiative selects compatible reactions for a
|
|||
later `WorldCommand`; it does not suppress perception for actors outside the
|
||||
current conversational focus.
|
||||
|
||||
Player speech first passes through a small private address projection against
|
||||
the exact co-present, simulation-eligible actor catalog. The projection may
|
||||
select only supplied actor IDs and identifies only actors from whom the speech
|
||||
actually requests a response; people who are merely mentioned or able to hear
|
||||
remain observers. `WorldKernel` validates co-presence and simulation custody,
|
||||
then commits the selected response set with the exact speech turn. Browser and
|
||||
native callers cannot supply those IDs. Each actor's permitted slice carries
|
||||
either `direct_response_expected` or `present_observer`, while every present
|
||||
actor still appraises the event. A direct addressee must emit speech or choose
|
||||
typed deliberate silence. The latter lowers to deterministic visible refusal;
|
||||
it is not a free-text effect lane. The complete reaction wave still validates
|
||||
and commits atomically.
|
||||
|
||||
The primary player event commits before optional presence casting, reaction
|
||||
appraisal and initiative. A failure in those later stages is stored
|
||||
as a rejected-proposal receipt and returned as an accepted committed result
|
||||
|
|
|
|||
|
|
@ -734,6 +734,18 @@ world turns, or read historical `narration_projection.v1` rows. This removes two
|
|||
calls per live turn and makes display-layer invention of names, dialogue,
|
||||
injuries, participants, or actions structurally impossible.
|
||||
|
||||
Player speech address is also typed before it can steer a reaction. A compact
|
||||
Flash projection receives only the exact co-present simulation-eligible actor
|
||||
IDs and names, bounded recent public turns, prior conversational focus, and the
|
||||
new utterance. It returns the exact Persona actors from whom the utterance asks
|
||||
for a response. `WorldKernel` revalidates those IDs and commits them with the
|
||||
speech `NarrativeTurn`; neither Eve nor the native client may submit them.
|
||||
Every present actor continues to appraise the turn, but the private actor slice
|
||||
now distinguishes `direct_response_expected` from `present_observer`. A direct
|
||||
addressee must speak or select typed deliberate silence. The kernel lowers
|
||||
silence to a deterministic visible refusal, so no free-text gesture lane can
|
||||
claim an unassessed world effect.
|
||||
|
||||
`ghostlight-campaign-inspect` is a read-only typed-store witness. It reports the
|
||||
latest strategic cover, attributed appraisals and inactions, activity outcomes,
|
||||
events, news, subject continuity, and model receipt/token metadata without
|
||||
|
|
@ -952,6 +964,10 @@ Pending NPC actions are revision-scoped reaction-window state. The next
|
|||
reaction wave atomically replaces the set, and the kernel resolves an action
|
||||
only when the current last event is the exact current-revision reaction wave.
|
||||
Same-wave assessment correction remains possible; cross-turn rebasing is not.
|
||||
The same reaction commit validates its response duty against the exact
|
||||
committed source turn. A missing directly addressed Persona or a null response
|
||||
aborts the whole wave without private deltas, transcript additions, initiative,
|
||||
or revision change; observers may still choose to speak from their own goals.
|
||||
|
||||
Population fission now composes admitted child entities, stable child
|
||||
identities, one lineage split, exact resource custody transfers, and exact
|
||||
|
|
|
|||
|
|
@ -118,6 +118,15 @@ browser renders these projections as labeled, escaped human-readable controls,
|
|||
and the player story lowers exact committed transcript turns without a
|
||||
generative rewrite.
|
||||
|
||||
Foreground speech now carries explicit response ownership. A private bounded
|
||||
address stage selects only exact co-present Persona IDs; `WorldKernel` commits
|
||||
that set with the speech turn and projects direct-addressee versus observer
|
||||
roles into parallel Persona appraisal. Direct addressees must speak or choose
|
||||
typed deliberate silence, which the kernel lowers deterministically. This
|
||||
closes the live failure where every present actor perceived a direct question
|
||||
but all Interpreters returned `speech: null`, without forcing observers to seize
|
||||
conversational focus or admitting a free-text reaction-effect lane.
|
||||
|
||||
World transition authority is now a closed semantic mutation algebra rather
|
||||
than four empirical effect universes. Foreground attempts, NPC initiative,
|
||||
reaction appraisal, strategic actor/institution/Gestalt outcomes, waits,
|
||||
|
|
|
|||
Loading…
Reference in a new issue