Resolve NPC initiative through the kernel

This commit is contained in:
Meta Vens 2026-08-16 20:44:07 +02:00
commit a1b00658b0
8 changed files with 328 additions and 6 deletions

View file

@ -309,6 +309,10 @@ pub enum WorldCommand {
event_summary: String,
reactions: Vec<ActorReaction>,
},
BeginNpcAction {
expected_revision: u64,
proposal: WorldActionProposal,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]

View file

@ -0,0 +1,35 @@
use crate::domain::WorldActionProposal;
pub fn winner(proposals: &[WorldActionProposal]) -> Option<WorldActionProposal> {
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");
}
}

View file

@ -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>("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);
}
}

View file

@ -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;

View file

@ -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<serde_json::Value> {
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<AppState>,
@ -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) => {

View file

@ -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.

View file

@ -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.

View file

@ -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.