Bound player commands before inference or mutation
This commit is contained in:
parent
5db04a1fe7
commit
0815776fa8
2 changed files with 169 additions and 0 deletions
|
|
@ -118,6 +118,7 @@ fn execute(
|
|||
proposal,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
validate_intent_text(&intent)?;
|
||||
let assessment = match proposal {
|
||||
Some(assessment) => {
|
||||
if assessment.campaign_id != campaign.id
|
||||
|
|
@ -215,6 +216,10 @@ fn execute(
|
|||
intended_effect,
|
||||
} => {
|
||||
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()));
|
||||
}
|
||||
|
|
@ -248,6 +253,11 @@ fn execute(
|
|||
minutes,
|
||||
} => {
|
||||
require_revision(&campaign, expected_revision)?;
|
||||
if minutes == 0 || minutes > 1_440 {
|
||||
return Err(KernelError::Invalid(
|
||||
"wait duration must be between 1 and 1440 minutes".into(),
|
||||
));
|
||||
}
|
||||
campaign.world_time += Duration::minutes(i64::from(minutes));
|
||||
campaign.last_player_activity = Utc::now();
|
||||
campaign.away_ticks_processed = 0;
|
||||
|
|
@ -866,6 +876,25 @@ fn execute(
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_intent_text(intent: &ActionIntent) -> Result<(), KernelError> {
|
||||
validate_bounded_text("action description", &intent.description, 4_000)?;
|
||||
validate_bounded_text("intended effect", &intent.intended_effect, 1_000)
|
||||
}
|
||||
|
||||
fn validate_bounded_text(label: &str, value: &str, max_chars: usize) -> Result<(), KernelError> {
|
||||
if value.trim().is_empty()
|
||||
|| value.chars().count() > max_chars
|
||||
|| value
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"{label} must contain 1 to {max_chars} readable characters"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_individuation(
|
||||
campaign: &mut Campaign,
|
||||
individuation: &GestaltIndividuation,
|
||||
|
|
@ -3707,6 +3736,55 @@ mod tests {
|
|||
assert_eq!(stored.world_time, seed.world_time + Duration::minutes(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_wait_and_oversized_speech_cannot_mutate_campaign() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
|
||||
let kernel = WorldKernel::start(store.clone());
|
||||
let seed = campaign();
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
model_stage_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let baseline = store
|
||||
.load::<Campaign>("campaign.v1", &seed.id.to_string())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.1;
|
||||
|
||||
assert!(
|
||||
kernel
|
||||
.command(WorldCommand::Wait {
|
||||
expected_revision: 0,
|
||||
minutes: 1_441,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
kernel
|
||||
.command(WorldCommand::Speak {
|
||||
expected_revision: 0,
|
||||
actor_id: "player".into(),
|
||||
text: "x".repeat(4_001),
|
||||
intended_effect: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let stored = store
|
||||
.load::<Campaign>("campaign.v1", &seed.id.to_string())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(stored, baseline);
|
||||
assert!(store.keys("world_commit_receipt.v1").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn npc_speech_does_not_impersonate_player_activity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -916,6 +916,9 @@ async fn command(
|
|||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(error) = validate_player_http_command(&command) {
|
||||
return (StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorBody { error })).into_response();
|
||||
}
|
||||
if let Err(error) = process_due_ticks(
|
||||
&state,
|
||||
&runtime,
|
||||
|
|
@ -1442,6 +1445,47 @@ fn player_http_command_allowed(command: &WorldCommand, player_actor_id: &str) ->
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_player_http_command(command: &WorldCommand) -> Result<(), String> {
|
||||
let bounded = |label: &str, value: &str, max: usize| {
|
||||
if value.trim().is_empty() || value.chars().count() > max {
|
||||
Err(format!("{label} must contain 1 to {max} characters"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
match command {
|
||||
WorldCommand::Speak {
|
||||
text,
|
||||
intended_effect,
|
||||
..
|
||||
} => {
|
||||
bounded("speech", text, 4_000)?;
|
||||
if intended_effect.is_some() {
|
||||
return Err(
|
||||
"speech and uncertain intended effects use separate player commands".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
WorldCommand::Assess { intent, .. } => {
|
||||
bounded("action description", &intent.description, 4_000)?;
|
||||
bounded("intended effect", &intent.intended_effect, 1_000)
|
||||
}
|
||||
WorldCommand::Attempt { assessment_digest } => {
|
||||
bounded("assessment digest", assessment_digest, 160)
|
||||
}
|
||||
WorldCommand::Wait { minutes, .. } if *minutes == 0 || *minutes > 1_440 => {
|
||||
Err("wait duration must be between 1 and 1440 minutes".into())
|
||||
}
|
||||
WorldCommand::SetResolutionBudget {
|
||||
active_cell_budget, ..
|
||||
} if !(1..=32).contains(active_cell_budget) => {
|
||||
Err("active Persona-cell budget must be between 1 and 32".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reaction_stimulus(command: &WorldCommand) -> Option<String> {
|
||||
match command {
|
||||
WorldCommand::Speak { actor_id, text, .. } => {
|
||||
|
|
@ -2474,6 +2518,53 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_http_command_admission_bounds_cost_and_fictional_time() {
|
||||
assert!(
|
||||
validate_player_http_command(&WorldCommand::Wait {
|
||||
expected_revision: 0,
|
||||
minutes: 0,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_player_http_command(&WorldCommand::Wait {
|
||||
expected_revision: 0,
|
||||
minutes: 1_441,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_player_http_command(&WorldCommand::SetResolutionBudget {
|
||||
expected_revision: 0,
|
||||
expected_resolution_epoch: 0,
|
||||
active_cell_budget: 33,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_player_http_command(&WorldCommand::Speak {
|
||||
expected_revision: 0,
|
||||
actor_id: "player".into(),
|
||||
text: "Hello".into(),
|
||||
intended_effect: Some("obey me".into()),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_player_http_command(&WorldCommand::Assess {
|
||||
expected_revision: 0,
|
||||
intent: ActionIntent {
|
||||
actor_id: "player".into(),
|
||||
description: "x".repeat(4_001),
|
||||
intended_effect: "cross the room".into(),
|
||||
},
|
||||
proposal: None,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_http_boundary_is_loopback_only() {
|
||||
assert!(operator_peer_allowed("127.0.0.1:8831".parse().unwrap()));
|
||||
|
|
|
|||
Loading…
Reference in a new issue