diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 64e8edb..dae92a5 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -1146,7 +1146,26 @@ async fn post_session_zero_message( } } Err(error) => { - tracing::warn!(%error, "Session Zero DM inference failed without mutation") + tracing::warn!(%error, "Session Zero DM inference failed without draft mutation"); + let failure = ghostlight_dungeon::session_zero::SessionZeroDelta { + dm_speech: "I couldn't finish that response. Your message is safely recorded and no draft state changed; please retry or rephrase when you're ready.".into(), + ..Default::default() + }; + if let Err(stale) = kernel + .command(SessionZeroCommand::ApplyDmTurn { + expected_component_epoch: component_epoch, + expected_channel_revision: channel.revision, + channel_id, + member_id: private_member, + delta: failure, + model_receipts: Vec::new(), + }) + .await + { + tracing::info!(%stale, "stale Session Zero DM failure notice discarded"); + } else { + schedule_mesh_refresh(&mesh_state); + } } } }); diff --git a/crates/ghostlight-dungeon/src/session_zero.rs b/crates/ghostlight-dungeon/src/session_zero.rs index c6e9170..96088f2 100644 --- a/crates/ghostlight-dungeon/src/session_zero.rs +++ b/crates/ghostlight-dungeon/src/session_zero.rs @@ -2763,6 +2763,66 @@ mod tests { ); } + #[tokio::test] + async fn local_dm_failure_notice_changes_only_the_conversation() { + let dir = tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("session-zero.cc")).unwrap(); + let initial = state(); + let state_id = initial.id; + let channel_id = "shared:table".to_string(); + SessionZeroKernel::initialize(&store, &initial).unwrap(); + let kernel = SessionZeroKernel::start(store, state_id); + let posted = kernel + .command(SessionZeroCommand::PostPlayerMessage { + actor_account_hash: "account:host".into(), + expected_revision: initial.revision, + channel_id: channel_id.clone(), + text: "Please try this turn.".into(), + }) + .await + .unwrap(); + let before = posted.state.clone(); + + let noticed = kernel + .command(SessionZeroCommand::ApplyDmTurn { + expected_component_epoch: before.shared_epoch, + expected_channel_revision: before.channels[&channel_id].revision, + channel_id: channel_id.clone(), + member_id: None, + delta: SessionZeroDelta { + dm_speech: "I couldn't finish that response. No draft state changed.".into(), + ..Default::default() + }, + model_receipts: vec![], + }) + .await + .unwrap(); + + assert_eq!(noticed.state.contract, before.contract); + assert_eq!(noticed.state.character_drafts, before.character_drafts); + assert_eq!(noticed.state.decisions, before.decisions); + assert_eq!(noticed.state.boundaries, before.boundaries); + assert_eq!(noticed.state.shared_epoch, before.shared_epoch); + assert_eq!(noticed.state.character_epochs, before.character_epochs); + assert_eq!( + noticed.state.preview_model_receipts, + before.preview_model_receipts + ); + assert_eq!(noticed.state.revision, before.revision + 1); + assert_eq!( + noticed.state.channels[&channel_id].revision, + before.channels[&channel_id].revision + 1 + ); + let last_message_id = noticed.state.channels[&channel_id] + .message_ids + .last() + .unwrap(); + assert_eq!( + noticed.state.messages[last_message_id].speaker, + SessionZeroSpeakerKind::Dm + ); + } + #[tokio::test] async fn material_character_bargain_is_inert_until_its_owner_accepts() { let dir = tempdir().unwrap();