From a1b00658b08b5763954e8bfbfee92fc70e4ec42b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 16 Aug 2026 20:44:07 +0200 Subject: [PATCH] Resolve NPC initiative through the kernel --- crates/ghostlight-dungeon/src/domain.rs | 4 + crates/ghostlight-dungeon/src/initiative.rs | 35 +++++ crates/ghostlight-dungeon/src/kernel.rs | 159 +++++++++++++++++++- crates/ghostlight-dungeon/src/lib.rs | 1 + crates/ghostlight-dungeon/src/main.rs | 102 ++++++++++++- docs/architecture/ghostlight-dungeon-mvp.md | 24 +++ notes/ghostlight-dungeon.md | 7 + state/map.yaml | 2 +- 8 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 crates/ghostlight-dungeon/src/initiative.rs diff --git a/crates/ghostlight-dungeon/src/domain.rs b/crates/ghostlight-dungeon/src/domain.rs index 657caf2..a6fa82f 100644 --- a/crates/ghostlight-dungeon/src/domain.rs +++ b/crates/ghostlight-dungeon/src/domain.rs @@ -309,6 +309,10 @@ pub enum WorldCommand { event_summary: String, reactions: Vec, }, + BeginNpcAction { + expected_revision: u64, + proposal: WorldActionProposal, + }, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] diff --git a/crates/ghostlight-dungeon/src/initiative.rs b/crates/ghostlight-dungeon/src/initiative.rs new file mode 100644 index 0000000..e2da2d1 --- /dev/null +++ b/crates/ghostlight-dungeon/src/initiative.rs @@ -0,0 +1,35 @@ +use crate::domain::WorldActionProposal; + +pub fn winner(proposals: &[WorldActionProposal]) -> Option { + proposals.iter().cloned().max_by(|left, right| { + left.priority + .cmp(&right.priority) + .then_with(|| right.actor_id.cmp(&left.actor_id)) + .then_with(|| right.intent.cmp(&left.intent)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn proposal(actor: &str, priority: i16) -> WorldActionProposal { + WorldActionProposal { + actor_id: actor.into(), + intent: "act".into(), + intended_effect: "change something".into(), + priority, + state_references: vec![], + } + } + + #[test] + fn priority_wins_with_stable_actor_tie_break() { + let proposals = vec![ + proposal("bert", 5), + proposal("anna", 5), + proposal("cara", 4), + ]; + assert_eq!(winner(&proposals).unwrap().actor_id, "anna"); + } +} diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index d1a87e0..b770eea 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -152,9 +152,11 @@ fn execute( OutcomeBand::Mixed => &assessment.mixed_stake, OutcomeBand::Failure => &assessment.failure_stake, }; - campaign.last_player_activity = Utc::now(); - campaign.away_ticks_processed = 0; - campaign.pending_ticks = 0; + if assessment.intent.actor_id == campaign.player_actor_id { + campaign.last_player_activity = Utc::now(); + campaign.away_ticks_processed = 0; + campaign.pending_ticks = 0; + } campaign.transcript.push(NarrativeTurn { revision: campaign.revision + 1, at: Utc::now(), @@ -425,6 +427,36 @@ fn execute( }); commit(store, row, campaign, "reaction_wave", None) } + WorldCommand::BeginNpcAction { + expected_revision, + proposal, + } => { + require_revision(&campaign, expected_revision)?; + let selected = crate::initiative::winner(&campaign.pending_world_proposals) + .ok_or_else(|| KernelError::Invalid("there is no pending NPC action".into()))?; + if proposal != selected { + return Err(KernelError::Invalid( + "proposal does not own the current initiative opportunity".into(), + )); + } + let actor = campaign + .actors + .get(&proposal.actor_id) + .ok_or_else(|| KernelError::Invalid("initiative actor is unknown".into()))?; + validate_world_proposal(actor, &proposal)?; + campaign.pending_world_proposals.clear(); + campaign.events.push(Event { + id: format!("npc-action-begun:{}", campaign.revision + 1), + at: campaign.world_time, + kind: "npc_action_begun".into(), + summary: format!("{} attempts {}", actor.name, proposal.intent), + actor_ids: vec![proposal.actor_id], + institution_ids: vec![], + location_ids: vec![actor.location_id.clone()], + public_channels: vec![], + }); + commit(store, row, campaign, "begin_npc_action", None) + } WorldCommand::CreateCampaign { .. } => unreachable!(), } } @@ -1242,4 +1274,125 @@ mod tests { assert!(persisted.transcript.is_empty()); assert!(persisted.pending_world_proposals.is_empty()); } + + #[tokio::test] + async fn initiative_grants_one_npc_opportunity_without_faking_player_activity() { + 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.last_player_activity = Utc::now() - Duration::hours(2); + for id in ["anna", "bert"] { + seed.actors.insert( + id.into(), + ActorState { + id: id.into(), + name: id.into(), + location_id: "room".into(), + capabilities: BTreeSet::from(["intervene".into()]), + knowledge: BTreeSet::new(), + equipment: BTreeSet::new(), + conditions: BTreeSet::new(), + obligations: BTreeSet::new(), + relationships: BTreeMap::new(), + goals: vec![], + memories: vec![], + }, + ); + } + let activity = seed.last_player_activity; + kernel + .command(WorldCommand::CreateCampaign { + campaign: seed, + evidence_receipts: vec![], + }) + .await + .unwrap(); + let proposal = |actor: &str, priority| WorldActionProposal { + actor_id: actor.into(), + intent: "intervene".into(), + intended_effect: "take control of the immediate situation".into(), + priority, + state_references: vec!["capability:intervene".into(), "location:room".into()], + }; + let anna = proposal("anna", 9); + let bert = proposal("bert", 4); + let wave = kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: 0, + event_summary: "a disturbance".into(), + reactions: vec![ + ActorReaction { + actor_id: "anna".into(), + speech: None, + private_delta: ActorStateDelta::default(), + action_proposals: vec![anna.clone()], + }, + ActorReaction { + actor_id: "bert".into(), + speech: None, + private_delta: ActorStateDelta::default(), + action_proposals: vec![bert.clone()], + }, + ], + }) + .await + .unwrap(); + assert!( + kernel + .command(WorldCommand::BeginNpcAction { + expected_revision: 1, + proposal: bert, + }) + .await + .is_err() + ); + let begun = kernel + .command(WorldCommand::BeginNpcAction { + expected_revision: 1, + proposal: anna, + }) + .await + .unwrap(); + let CommandResult::Committed { + campaign: begun, .. + } = begun + else { + panic!() + }; + assert!(begun.pending_world_proposals.is_empty()); + let intent = ActionIntent { + actor_id: "anna".into(), + description: "intervene".into(), + intended_effect: "take control of the immediate situation".into(), + }; + let assessed = kernel + .command(WorldCommand::Assess { + expected_revision: 2, + intent, + proposal: None, + }) + .await + .unwrap(); + let CommandResult::Assessed { assessment } = assessed else { + panic!() + }; + kernel + .command(WorldCommand::Attempt { + assessment_digest: assessment.digest, + }) + .await + .unwrap(); + let persisted = store + .load::("campaign.v1", &begun.id.to_string()) + .unwrap() + .unwrap() + .1; + assert_eq!(persisted.last_player_activity, activity); + assert_eq!(persisted.revision, 3); + let CommandResult::Committed { campaign, .. } = wave else { + panic!() + }; + assert_eq!(campaign.pending_world_proposals.len(), 2); + } } diff --git a/crates/ghostlight-dungeon/src/lib.rs b/crates/ghostlight-dungeon/src/lib.rs index 615508e..3d3fb2f 100644 --- a/crates/ghostlight-dungeon/src/lib.rs +++ b/crates/ghostlight-dungeon/src/lib.rs @@ -3,6 +3,7 @@ pub mod compiler; pub mod d20; pub mod domain; pub mod gestalt; +pub mod initiative; pub mod kernel; pub mod model; pub mod persistence; diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 2f43ccc..fc0b4a6 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -11,8 +11,9 @@ use ghostlight_dungeon::{ WorldKernel, assessor::ActionAssessor, compiler::{CustomStart, OpeningRequest, OpeningSuggestion, SelectedStart, WorldCompiler}, - domain::{Campaign, RegionExpansionPreview, WorldCommand, WorldCompilePreview}, + domain::{ActionIntent, Campaign, RegionExpansionPreview, WorldCommand, WorldCompilePreview}, gestalt::GestaltPresencePlanner, + kernel::CommandResult, model::{DeepSeekPort, ModelPort, ModelStageRequest, run_validated_stage}, persistence::CampaignStore, persona::PersonaProjectionEngine, @@ -463,6 +464,67 @@ async fn approve_destination( } } +async fn resolve_npc_initiative( + state: &AppState, + reaction: &CommandResult, +) -> anyhow::Result { + let CommandResult::Committed { campaign, .. } = reaction else { + return Ok(serde_json::Value::Null); + }; + let Some(proposal) = ghostlight_dungeon::initiative::winner(&campaign.pending_world_proposals) + else { + return Ok(serde_json::Value::Null); + }; + let begun = state + .kernel + .command(WorldCommand::BeginNpcAction { + expected_revision: campaign.revision, + proposal: proposal.clone(), + }) + .await?; + let CommandResult::Committed { + campaign: begun_campaign, + .. + } = &begun + else { + unreachable!() + }; + let assessor = state + .assessor + .as_ref() + .ok_or_else(|| anyhow::anyhow!("NPC initiative requires the action assessor"))?; + let intent = ActionIntent { + actor_id: proposal.actor_id, + description: proposal.intent, + intended_effect: proposal.intended_effect, + }; + let (assessment, receipt) = assessor.assess(begun_campaign, intent.clone()).await?; + let _ = state.store.insert( + "persona_stage_receipt.v1", + "ghostlight.persona_stage_receipt.v1", + &receipt.output_hash, + &receipt, + ); + let assessed = state + .kernel + .command(WorldCommand::Assess { + expected_revision: begun_campaign.revision, + intent, + proposal: Some(assessment.clone()), + }) + .await?; + if !assessment.admissible { + return Ok(serde_json::json!({"begun":begun,"assessment":assessed,"attempt":null})); + } + let attempted = state + .kernel + .command(WorldCommand::Attempt { + assessment_digest: assessment.digest, + }) + .await?; + Ok(serde_json::json!({"begun":begun,"assessment":assessed,"attempt":attempted})) +} + async fn command( headers: HeaderMap, State(state): State, @@ -609,7 +671,43 @@ async fn command( &receipt, ); } - match state.kernel.command(WorldCommand::ResolveReactionWave{expected_revision:reaction_campaign.revision,event_summary:summary,reactions:wave.reactions}).await{Ok(reaction)=>return Json(serde_json::json!({"primary":result,"presence":presence_result,"reaction_wave":reaction})).into_response(),Err(error)=>return(StatusCode::CONFLICT,Json(ErrorBody{error:error.to_string()})).into_response()} + match state + .kernel + .command(WorldCommand::ResolveReactionWave { + expected_revision: reaction_campaign.revision, + event_summary: summary, + reactions: wave.reactions, + }) + .await + { + Ok(reaction) => { + let initiative = + match resolve_npc_initiative(&state, &reaction) + .await + { + Ok(value) => value, + Err(error) => { + return ( + StatusCode::BAD_GATEWAY, + Json(ErrorBody { + error: error.to_string(), + }), + ) + .into_response(); + } + }; + return Json(serde_json::json!({"primary":result,"presence":presence_result,"reaction_wave":reaction,"npc_initiative":initiative})).into_response(); + } + Err(error) => { + return ( + StatusCode::CONFLICT, + Json(ErrorBody { + error: error.to_string(), + }), + ) + .into_response(); + } + } } Ok(_) => {} Err(error) => { diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 1d91525..41c8e8e 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -147,6 +147,30 @@ source intent -> post-commit appraisal/projection wave ``` +### NPC initiative authority + +- **Owner:** `WorldKernel` owns the transition from a pending reaction proposal + to the single NPC action opportunity for that revision. +- **Inputs:** the complete validated proposal set committed by the reaction + wave, expected campaign revision, and the deterministic initiative winner. +- **Outputs:** one `npc_action_begun` commit that records the selected attempt + and consumes the entire stale proposal set; assessment and resolution then + operate on the resulting revision. +- **Derived state:** priority ordering and the selected winner are calculations, + not world truth and not commits. +- **Forbidden writers:** Persona output, the initiative selector, narrator, and + HTTP handler cannot begin or resolve an NPC action directly. +- **Shared paths:** live reactions, interrupts, and future offscreen + individualized actions all submit `BeginNpcAction`, then use the same + `Assess -> Attempt` resolution spine as player attempts. +- **Cut line:** pending proposals are no longer a durable action queue. Once one + proposal gains the opportunity, every sibling proposal from that snapshot is + consumed; actors reappraise the committed result before another action. + +This deliberately serializes canonical consequence while keeping perception +parallel. It prevents two proposals assessed against the same world snapshot +from both committing incompatible outcomes. + The scheduler may calculate pending work, but `AdvanceStrategicTick` owns the transition. Return catch-up invokes that same command before the next player action is admitted. diff --git a/notes/ghostlight-dungeon.md b/notes/ghostlight-dungeon.md index 69f1068..1e62ecf 100644 --- a/notes/ghostlight-dungeon.md +++ b/notes/ghostlight-dungeon.md @@ -80,6 +80,13 @@ Ghostlight owns its own generalized projection machinery: All affected scene participants appraise a committed event in parallel. Actor-private deltas commit against their own snapshot versions; initiative then chooses compatible reactions or actions for world resolution. This ensures present characters continue perceiving and reacting even when another character currently holds conversational focus. +Canonical NPC consequence is serialized one opportunity at a time. The +deterministic initiative projection chooses the highest-priority stable winner; +`BeginNpcAction` verifies that exact proposal against the committed wave, +consumes its siblings, and advances the revision. The chosen NPC then traverses +the same assessment and server-side d20 path as the player without resetting +player-activity or away-time state. + Use DeepSeek’s current API directly: - `deepseek-v4-flash`, non-thinking: Projectors, Interpreters, retrieval planning/reranking, verification, and offscreen actors. diff --git a/state/map.yaml b/state/map.yaml index e228a0e..a4464aa 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -255,5 +255,5 @@ data_flow: current_status: summary: GhostlightDungeon is the active MVP program. The approval-gated world compiler now persists evidence, canon candidates, and bounded on-demand region expansion through the WorldKernel. Fiction-first assessments use model proposals with deterministic DC and state-reference validation. Away-time ticks move institutions and gate news by information access. Present NPCs appraise live events in parallel Persona waves, and the kernel atomically validates their private deltas and world-action proposals. Gestalt populations can materialize durable individuals and fold them back into persistent member deltas. Live DeepSeek plus VoidBot compilation and disposable authenticated HTTP restart smokes have passed. The broader hosted MVP remains incomplete. - next_action: Complete initiative selection and world resolution for validated NPC proposals, narrator projection, and the campaign/operator laboratory controls; then acceptance-test automatic relevance-driven gestalt promotion and folding with live model output. + next_action: Add typed outcome deltas so player and NPC d20 results mutate validated world state rather than transcript alone, then add narrator projection and campaign/operator laboratory controls; acceptance-test automatic gestalt promotion with live model output. gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization commands compose and demote active actors without deleting member identity.