Publish revision-bound narration projections

This commit is contained in:
Meta Vens 2026-08-16 20:50:30 +02:00
commit 736becdbd3
8 changed files with 286 additions and 10 deletions

View file

@ -243,6 +243,18 @@ pub struct NarrativeTurn {
pub text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct NarrationProjection {
pub schema: String,
pub id: String,
pub campaign_id: Uuid,
pub source_revision: u64,
pub text: String,
pub event_ids: Vec<String>,
pub model_receipt_hash: String,
pub published_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct ActionIntent {
pub actor_id: String,

View file

@ -6,6 +6,7 @@ pub mod gestalt;
pub mod initiative;
pub mod kernel;
pub mod model;
pub mod narrator;
pub mod persistence;
pub mod persona;
pub mod scheduler;

View file

@ -11,10 +11,14 @@ use ghostlight_dungeon::{
WorldKernel,
assessor::ActionAssessor,
compiler::{CustomStart, OpeningRequest, OpeningSuggestion, SelectedStart, WorldCompiler},
domain::{ActionIntent, Campaign, RegionExpansionPreview, WorldCommand, WorldCompilePreview},
domain::{
ActionIntent, Campaign, NarrationProjection, RegionExpansionPreview, WorldCommand,
WorldCompilePreview,
},
gestalt::GestaltPresencePlanner,
kernel::CommandResult,
model::{DeepSeekPort, ModelPort, ModelStageRequest, run_validated_stage},
narrator::Narrator,
persistence::CampaignStore,
persona::PersonaProjectionEngine,
surface::player_surface,
@ -238,7 +242,25 @@ async fn surface(headers: HeaderMap, State(state): State<AppState>) -> Response
return StatusCode::UNAUTHORIZED.into_response();
}
match load_campaign(&state.store) {
Ok(campaign) => Json(player_surface(&campaign)).into_response(),
Ok(campaign) => {
let mut narrations = state
.store
.keys("narration_projection.v1")
.unwrap_or_default()
.into_iter()
.filter_map(|key| {
state
.store
.load::<NarrationProjection>("narration_projection.v1", &key)
.ok()
.flatten()
.map(|(_, value)| value)
})
.filter(|value| value.campaign_id == campaign.id)
.collect::<Vec<_>>();
narrations.sort_by_key(|value| value.source_revision);
Json(player_surface(&campaign, &narrations)).into_response()
}
Err(_) => Json(serde_json::json!({"schema":"gamecult.eve.surface.v1","surface_id":"ghostlight.compiler","version":0,"title":"Compile a world","layout":{"kind":"stack","children":[]}})).into_response(),
}
}
@ -525,6 +547,39 @@ async fn resolve_npc_initiative(
Ok(serde_json::json!({"begun":begun,"assessment":assessed,"attempt":attempted}))
}
async fn publish_latest_narration(state: &AppState) -> anyhow::Result<Option<NarrationProjection>> {
let campaign = load_campaign(&state.store)?;
let key = format!("{}:{}", campaign.id, campaign.revision);
if let Some((_, existing)) = state
.store
.load::<NarrationProjection>("narration_projection.v1", &key)?
{
return Ok(Some(existing));
}
let model = state
.model
.as_ref()
.ok_or_else(|| anyhow::anyhow!("narration requires the model provider"))?;
let narrator = Narrator {
model: model.clone(),
model_name: "deepseek-v4-pro".into(),
};
let (projection, receipt) = narrator.project(&state.store, &campaign).await?;
state.store.insert(
"narration_projection.v1",
"ghostlight.narration_projection.v1",
&projection.id,
&projection,
)?;
let _ = state.store.insert(
"persona_stage_receipt.v1",
"ghostlight.persona_stage_receipt.v1",
&receipt.output_hash,
&receipt,
);
Ok(Some(projection))
}
async fn command(
headers: HeaderMap,
State(state): State<AppState>,
@ -696,7 +751,11 @@ async fn command(
.into_response();
}
};
return Json(serde_json::json!({"primary":result,"presence":presence_result,"reaction_wave":reaction,"npc_initiative":initiative})).into_response();
let narration = publish_latest_narration(&state)
.await
.ok()
.flatten();
return Json(serde_json::json!({"primary":result,"presence":presence_result,"reaction_wave":reaction,"npc_initiative":initiative,"narration":narration})).into_response();
}
Err(error) => {
return (
@ -722,15 +781,24 @@ async fn command(
}
}
if presence_result.is_some() {
let narration = publish_latest_narration(&state).await.ok().flatten();
return Json(
serde_json::json!({"primary":result,"presence":presence_result}),
serde_json::json!({"primary":result,"presence":presence_result,"narration":narration}),
)
.into_response();
}
}
}
}
Json(result).into_response()
if matches!(
&result,
CommandResult::Committed { .. } | CommandResult::Created { .. }
) {
let narration = publish_latest_narration(&state).await.ok().flatten();
Json(serde_json::json!({"result":result,"narration":narration})).into_response()
} else {
Json(result).into_response()
}
}
Err(error) => (
StatusCode::CONFLICT,

View file

@ -0,0 +1,170 @@
use crate::{
domain::{Campaign, NarrationProjection},
model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage},
persistence::CampaignStore,
};
use anyhow::{Result, anyhow};
use chrono::Utc;
use std::sync::Arc;
#[derive(Clone)]
pub struct Narrator {
pub model: Arc<dyn ModelPort>,
pub model_name: String,
}
impl Narrator {
pub async fn project(
&self,
store: &CampaignStore,
campaign: &Campaign,
) -> Result<(NarrationProjection, ModelStageReceipt)> {
let player = &campaign.actors[&campaign.player_actor_id];
let location = &campaign.locations[&player.location_id];
let recent_events: Vec<_> = campaign.events.iter().rev().take(8).cloned().collect();
let recent_turns: Vec<_> = campaign.transcript.iter().rev().take(12).cloned().collect();
let visible_actors: Vec<_> = campaign
.actors
.values()
.filter(|actor| actor.location_id == player.location_id)
.map(|actor| {
serde_json::json!({
"id": actor.id,
"name": actor.name,
"conditions": actor.conditions,
})
})
.collect();
let public_slice = serde_json::json!({
"world_time": campaign.world_time,
"location": location,
"visible_actors": visible_actors,
"recent_events": recent_events,
"recent_explicit_speech_and_stakes": recent_turns,
});
let output = run_validated_stage(
self.model.as_ref(),
&ModelStageRequest {
stage: "narrator".into(),
model: self.model_name.clone(),
snapshot_binding: format!(
"campaign:{}:revision:{}",
campaign.id, campaign.revision
),
lived_stream: format!(
"Narrate the latest committed change in vivid, concise second-person interactive-fiction prose. Describe only the supplied committed state, speech, and accessible sensory consequences. Do not add actions, facts, private thoughts, expertise, geography, outcomes, or dialogue. Emit prose only.\n\n{}",
serde_json::to_string(&public_slice)?
),
output_schema: None,
source_receipt_ids: campaign.branch_origin.evidence_receipt_ids.clone(),
},
)
.await?;
let text = output.narrative.trim();
if text.is_empty() || text.starts_with('{') || text.contains("```json") {
return Err(anyhow!("narrator violated prose projection membrane"));
}
let current = store
.load::<Campaign>("campaign.v1", &campaign.id.to_string())?
.map(|(_, campaign)| campaign)
.ok_or_else(|| anyhow!("campaign vanished during narration"))?;
if current.revision != campaign.revision {
return Err(anyhow!("narration snapshot became stale"));
}
let projection = NarrationProjection {
schema: "ghostlight.narration_projection.v1".into(),
id: format!("{}:{}", campaign.id, campaign.revision),
campaign_id: campaign.id,
source_revision: campaign.revision,
text: text.into(),
event_ids: recent_events.into_iter().map(|event| event.id).collect(),
model_receipt_hash: output.receipt.output_hash.clone(),
published_at: Utc::now(),
};
Ok((projection, output.receipt))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
domain::{ActorState, BranchOrigin, Campaign, Location},
model::FixtureModel,
};
use std::collections::{BTreeMap, BTreeSet};
fn campaign() -> Campaign {
let actor = ActorState {
id: "player".into(),
name: "Player".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![],
};
Campaign {
schema: "ghostlight.campaign.v1".into(),
id: uuid::Uuid::new_v4(),
name: "Narrator test".into(),
revision: 0,
branch_origin: BranchOrigin {
canon_cutoff: "test".into(),
evidence_receipt_ids: vec![],
},
world_time: Utc::now(),
tick_hours: 6,
player_actor_id: "player".into(),
locations: BTreeMap::from([(
"room".into(),
Location {
id: "room".into(),
name: "Room".into(),
container_id: None,
routes: BTreeMap::new(),
persistent_features: vec![],
},
)]),
actors: BTreeMap::from([("player".into(), actor)]),
institutions: BTreeMap::new(),
clocks: BTreeMap::new(),
facts: BTreeMap::new(),
transcript: vec![],
last_player_activity: Utc::now(),
pending_ticks: 0,
away_ticks_processed: 0,
events: vec![],
news: vec![],
canon_candidates: BTreeMap::new(),
gestalts: BTreeMap::new(),
gestalt_members: BTreeMap::new(),
pending_world_proposals: vec![],
}
}
#[tokio::test]
async fn narration_is_revision_bound_and_does_not_mutate_campaign() {
let dir = tempfile::tempdir().unwrap();
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
let seed = campaign();
store.create_campaign(&seed, &[]).unwrap();
let narrator = Narrator {
model: Arc::new(FixtureModel),
model_name: "fixture".into(),
};
let (projection, _) = narrator.project(&store, &seed).await.unwrap();
assert_eq!(projection.source_revision, 0);
let persisted = store
.load::<Campaign>("campaign.v1", &seed.id.to_string())
.unwrap()
.unwrap()
.1;
assert_eq!(persisted, seed);
assert!(store.keys("narration_projection.v1").unwrap().is_empty());
}
}

View file

@ -1,8 +1,9 @@
use crate::domain::Campaign;
use crate::domain::{Campaign, NarrationProjection};
use serde_json::{Value, json};
pub fn player_surface(campaign: &Campaign) -> Value {
let turns = campaign.transcript.iter().map(|t| json!({"id":format!("turn-{}-{}",t.revision,t.speaker),"kind":"text","props":{"value":format!("{}: {}",t.speaker,t.text)},"children":[]})).collect::<Vec<_>>();
pub fn player_surface(campaign: &Campaign, narrations: &[NarrationProjection]) -> Value {
let mut story = narrations.iter().map(|n| json!({"id":format!("narration-{}",n.source_revision),"kind":"text","props":{"value":n.text},"children":[]})).collect::<Vec<_>>();
story.extend(campaign.transcript.iter().map(|t| json!({"id":format!("turn-{}-{}",t.revision,t.speaker),"kind":"text","props":{"value":format!("{}: {}",t.speaker,t.text)},"children":[]})));
let player = &campaign.actors[&campaign.player_actor_id];
let location = &campaign.locations[&player.location_id];
let ledger = format!(
@ -39,7 +40,7 @@ pub fn player_surface(campaign: &Campaign) -> Value {
{"id":"dungeon.location","kind":"card","props":{"title":format!("{} · {}",location.name,player.name)},"children":[{"id":"dungeon.pressures","kind":"text","props":{"value":pressures},"children":[]}]},
{"id":"dungeon.ledger","kind":"card","props":{"title":"Character ledger"},"children":[{"id":"dungeon.ledger.text","kind":"text","props":{"value":ledger},"children":[]}]},
{"id":"dungeon.news","kind":"card","props":{"title":"Accessible news and rumors"},"children":news},
{"id":"dungeon.transcript","kind":"card","props":{"title":"Story"},"children":turns},
{"id":"dungeon.transcript","kind":"card","props":{"title":"Story"},"children":story},
{"id":"dungeon.composer","kind":"text-input","props":{"label":"What do you attempt?","commandId":"attempt.assess"},"children":[]}
]},"styles":{"tokens":{"colorBackground":"#0c1110","colorPanel":"#17201d","colorText":"#e8e1cf","colorMuted":"#9aa69f","colorAccent":"#d49b58"}}},
"commands":[{"id":"attempt.assess","schema":"gamecult.eve.command.v1","receiptSchema":"ghostlight.player_action_assessment.v1"}]

View file

@ -185,6 +185,29 @@ 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.
### Narration projection authority
- **Owner:** committed campaign state and events own what happened; the
narrator owns only a readable projection of one exact revision.
- **Inputs:** a player-visible slice of location, visible actors, explicit
speech/stakes, recent committed events, and evidence receipts.
- **Outputs:** immutable `narration_projection.v1` CultCache rows bound to a
campaign ID and source revision, plus private model-stage receipts.
- **Derived state:** story prose and the Eve transcript card are projections;
neither is a world fact, actor memory, action, or correction path.
- **Forbidden writers:** narrator output cannot append events, speech, facts,
deltas, memories, clocks, topology, or campaign revision.
- **Shared paths:** player attempts, NPC attempts, waits, strategic ticks, and
other committed transitions invoke the same post-commit narrator projection.
- **Cut line:** the existing campaign transcript retains explicit speech and
deterministic stake text only. Generated connective prose lives outside the
campaign row and cannot be read back as canonical input except as a display
projection.
The narrator rechecks the campaign revision after inference. Stale or malformed
prose is discarded. Successful projections survive refresh without acquiring
write authority over the world they describe.
### Cut line
Ghostlight has no existing hosted runtime authority to preserve. The v0

View file

@ -167,6 +167,7 @@ stake prose alone never impersonates world mutation.
- `campaign.v1`, `world_fact.v1`, `location.v1`, `actor_state.v1`, `institution_state.v1`, `relationship_state.v1`, `world_clock.v1`, `event.v1`
- `player_action_assessment.v1`, `roll_receipt.v1`, `world_commit_receipt.v1`
- `persona_stage_receipt.v1`, `actor_state_delta.v1`, `world_action_proposal.v1`
- `narration_projection.v1`
- `strategic_tick.v1`, `news_issue.v1`, `canon_candidate.v1`
- `gestalt_persona_state.v1`, `gestalt_member_delta.v1`,
`gestalt_materialization_receipt.v1`

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: Add post-commit narrator projection without narrative write authority, then complete campaign fork/reset/export and operator laboratory controls; acceptance-test automatic gestalt promotion and typed outcomes with live model output.
next_action: Complete named campaign creation, fork/reset and CultCache export plus operator laboratory controls; then acceptance-test narration, automatic gestalt promotion, and typed outcomes 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.