Commit typed d20 outcome deltas
This commit is contained in:
parent
a1b00658b0
commit
ec9d3320a3
6 changed files with 263 additions and 3 deletions
|
|
@ -1,5 +1,5 @@
|
|||
use crate::{
|
||||
domain::{ActionAssessment, ActionIntent, Campaign, ContextModifier},
|
||||
domain::{ActionAssessment, ActionIntent, Campaign, ContextModifier, WorldEffectDelta},
|
||||
model::{ModelPort, ModelStageReceipt, ModelStageRequest, run_validated_stage},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
|
@ -20,6 +20,10 @@ struct AssessmentProposal {
|
|||
success_stake: String,
|
||||
mixed_stake: String,
|
||||
failure_stake: String,
|
||||
strong_effect: WorldEffectDelta,
|
||||
success_effect: WorldEffectDelta,
|
||||
mixed_effect: WorldEffectDelta,
|
||||
failure_effect: WorldEffectDelta,
|
||||
bargains: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +59,7 @@ impl ActionAssessor {
|
|||
let allowed_references = allowed_references(campaign, actor);
|
||||
let schema = serde_json::to_value(schema_for!(AssessmentProposal))?;
|
||||
let prompt = format!(
|
||||
"Assess an attempted effect, not whether words can be spoken. Impossible actions are inadmissible and receive bargains, not a roll. Choose DC only from 5,10,15,20,25,30. Every modifier reference must be copied exactly from ALLOWED REFERENCES. Modifier total is capped at +/-10. Never grant capability, custody, access, knowledge, or spatial reach absent from state. State concrete success, mixed, and failure consequences and a bounded effect ceiling.\nINTENT:\n{}\nACTOR:\n{}\nLOCATION:\n{}\nVISIBLE INSTITUTIONS:\n{}\nALLOWED REFERENCES:\n{}\nOUTPUT JSON SCHEMA:\n{}",
|
||||
"Assess an attempted effect, not whether words can be spoken. Impossible actions are inadmissible and receive bargains, not a roll. Choose DC only from 5,10,15,20,25,30. Every modifier reference must be copied exactly from ALLOWED REFERENCES. Modifier total is capped at +/-10. Never grant capability, custody, access, knowledge, or spatial reach absent from state. State concrete success, mixed, and failure consequences and a bounded effect ceiling. Outcome deltas may only change existing actor conditions or relationships, move the acting actor along an existing route, advance existing clocks, or change existing institution posture. Keep a delta empty when prose consequence has no canonical state change.\nINTENT:\n{}\nACTOR:\n{}\nLOCATION:\n{}\nVISIBLE INSTITUTIONS:\n{}\nALLOWED REFERENCES:\n{}\nOUTPUT JSON SCHEMA:\n{}",
|
||||
serde_json::to_string(&intent)?,
|
||||
serde_json::to_string(actor)?,
|
||||
serde_json::to_string(location)?,
|
||||
|
|
@ -83,6 +87,14 @@ impl ActionAssessor {
|
|||
.ok_or_else(|| anyhow!("assessor returned no typed proposal"))?,
|
||||
)?;
|
||||
validate_proposal(&proposal, &allowed_references)?;
|
||||
for effect in [
|
||||
&proposal.strong_effect,
|
||||
&proposal.success_effect,
|
||||
&proposal.mixed_effect,
|
||||
&proposal.failure_effect,
|
||||
] {
|
||||
validate_effect(campaign, actor, effect)?;
|
||||
}
|
||||
let modifier_total =
|
||||
crate::d20::capped_modifier(proposal.modifiers.iter().map(|m| m.value));
|
||||
let mut assessment = ActionAssessment {
|
||||
|
|
@ -99,6 +111,10 @@ impl ActionAssessor {
|
|||
success_stake: proposal.success_stake,
|
||||
mixed_stake: proposal.mixed_stake,
|
||||
failure_stake: proposal.failure_stake,
|
||||
strong_effect: proposal.strong_effect,
|
||||
success_effect: proposal.success_effect,
|
||||
mixed_effect: proposal.mixed_effect,
|
||||
failure_effect: proposal.failure_effect,
|
||||
bargains: proposal.bargains,
|
||||
expires_at: Utc::now() + Duration::minutes(10),
|
||||
digest: String::new(),
|
||||
|
|
@ -157,6 +173,63 @@ fn validate_proposal(p: &AssessmentProposal, allowed: &BTreeSet<String>) -> Resu
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_effect(
|
||||
campaign: &Campaign,
|
||||
acting_actor: &crate::domain::ActorState,
|
||||
effect: &WorldEffectDelta,
|
||||
) -> Result<()> {
|
||||
let affected = effect
|
||||
.actor_conditions
|
||||
.keys()
|
||||
.chain(effect.actor_relationship_updates.keys());
|
||||
for id in affected {
|
||||
let target = campaign
|
||||
.actors
|
||||
.get(id)
|
||||
.ok_or_else(|| anyhow!("outcome delta invented an actor"))?;
|
||||
if target.location_id != acting_actor.location_id {
|
||||
return Err(anyhow!("outcome delta exceeds spatial reach"));
|
||||
}
|
||||
}
|
||||
for delta in effect.actor_conditions.values() {
|
||||
if delta.add.iter().any(|value| value.trim().is_empty())
|
||||
|| delta.remove.iter().any(|value| value.trim().is_empty())
|
||||
|| !delta.add.is_disjoint(&delta.remove)
|
||||
{
|
||||
return Err(anyhow!("outcome condition delta is contradictory"));
|
||||
}
|
||||
}
|
||||
for relationships in effect.actor_relationship_updates.values() {
|
||||
if relationships.iter().any(|(id, value)| {
|
||||
(!campaign.actors.contains_key(id) && !campaign.institutions.contains_key(id))
|
||||
|| value.trim().is_empty()
|
||||
}) {
|
||||
return Err(anyhow!("outcome relationship delta invented a target"));
|
||||
}
|
||||
}
|
||||
for (actor_id, destination) in &effect.actor_moves {
|
||||
if actor_id != &acting_actor.id
|
||||
|| !campaign.locations.contains_key(destination)
|
||||
|| !campaign.locations[&acting_actor.location_id]
|
||||
.routes
|
||||
.contains_key(destination)
|
||||
{
|
||||
return Err(anyhow!("outcome movement exceeds spatial reach"));
|
||||
}
|
||||
}
|
||||
if effect
|
||||
.clock_advances
|
||||
.iter()
|
||||
.any(|(id, amount)| *amount == 0 || !campaign.clocks.contains_key(id))
|
||||
|| effect.institution_postures.iter().any(|(id, posture)| {
|
||||
!campaign.institutions.contains_key(id) || posture.trim().is_empty()
|
||||
})
|
||||
{
|
||||
return Err(anyhow!("outcome delta cites unknown world state"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn assessment_digest(assessment: &ActionAssessment) -> Result<String> {
|
||||
let mut value = assessment.clone();
|
||||
value.digest.clear();
|
||||
|
|
@ -185,6 +258,10 @@ mod tests {
|
|||
success_stake: "gate opens".into(),
|
||||
mixed_stake: "gate opens noisily".into(),
|
||||
failure_stake: "lock jams".into(),
|
||||
strong_effect: WorldEffectDelta::default(),
|
||||
success_effect: WorldEffectDelta::default(),
|
||||
mixed_effect: WorldEffectDelta::default(),
|
||||
failure_effect: WorldEffectDelta::default(),
|
||||
bargains: vec![],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,6 +349,21 @@ pub struct ContextModifier {
|
|||
pub references: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
|
||||
pub struct ConditionDelta {
|
||||
pub add: BTreeSet<String>,
|
||||
pub remove: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
|
||||
pub struct WorldEffectDelta {
|
||||
pub actor_conditions: BTreeMap<String, ConditionDelta>,
|
||||
pub actor_relationship_updates: BTreeMap<String, BTreeMap<String, String>>,
|
||||
pub actor_moves: BTreeMap<String, String>,
|
||||
pub clock_advances: BTreeMap<String, u8>,
|
||||
pub institution_postures: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
pub struct ActionAssessment {
|
||||
pub schema: String,
|
||||
|
|
@ -364,6 +379,14 @@ pub struct ActionAssessment {
|
|||
pub success_stake: String,
|
||||
pub mixed_stake: String,
|
||||
pub failure_stake: String,
|
||||
#[serde(default)]
|
||||
pub strong_effect: WorldEffectDelta,
|
||||
#[serde(default)]
|
||||
pub success_effect: WorldEffectDelta,
|
||||
#[serde(default)]
|
||||
pub mixed_effect: WorldEffectDelta,
|
||||
#[serde(default)]
|
||||
pub failure_effect: WorldEffectDelta,
|
||||
pub bargains: Vec<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub digest: String,
|
||||
|
|
|
|||
|
|
@ -119,6 +119,19 @@ fn execute(
|
|||
"assessment proposal is stale or invalid".into(),
|
||||
));
|
||||
}
|
||||
let actor = campaign
|
||||
.actors
|
||||
.get(&assessment.intent.actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("assessment actor vanished".into()))?;
|
||||
for effect in [
|
||||
&assessment.strong_effect,
|
||||
&assessment.success_effect,
|
||||
&assessment.mixed_effect,
|
||||
&assessment.failure_effect,
|
||||
] {
|
||||
crate::assessor::validate_effect(&campaign, actor, effect)
|
||||
.map_err(|error| KernelError::Invalid(error.to_string()))?;
|
||||
}
|
||||
assessment
|
||||
}
|
||||
None => assess(&campaign, intent),
|
||||
|
|
@ -152,6 +165,13 @@ fn execute(
|
|||
OutcomeBand::Mixed => &assessment.mixed_stake,
|
||||
OutcomeBand::Failure => &assessment.failure_stake,
|
||||
};
|
||||
let effect = match roll.outcome {
|
||||
OutcomeBand::StrongSuccess => assessment.strong_effect.clone(),
|
||||
OutcomeBand::Success => assessment.success_effect.clone(),
|
||||
OutcomeBand::Mixed => assessment.mixed_effect.clone(),
|
||||
OutcomeBand::Failure => assessment.failure_effect.clone(),
|
||||
};
|
||||
apply_world_effect(&mut campaign, &effect)?;
|
||||
if assessment.intent.actor_id == campaign.player_actor_id {
|
||||
campaign.last_player_activity = Utc::now();
|
||||
campaign.away_ticks_processed = 0;
|
||||
|
|
@ -508,6 +528,52 @@ fn apply_promotion(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_world_effect(
|
||||
campaign: &mut Campaign,
|
||||
effect: &WorldEffectDelta,
|
||||
) -> Result<(), KernelError> {
|
||||
for (actor_id, delta) in &effect.actor_conditions {
|
||||
let actor = campaign
|
||||
.actors
|
||||
.get_mut(actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))?;
|
||||
actor.conditions.extend(delta.add.clone());
|
||||
for value in &delta.remove {
|
||||
actor.conditions.remove(value);
|
||||
}
|
||||
}
|
||||
for (actor_id, relationships) in &effect.actor_relationship_updates {
|
||||
campaign
|
||||
.actors
|
||||
.get_mut(actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))?
|
||||
.relationships
|
||||
.extend(relationships.clone());
|
||||
}
|
||||
for (actor_id, destination) in &effect.actor_moves {
|
||||
campaign
|
||||
.actors
|
||||
.get_mut(actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))?
|
||||
.location_id = destination.clone();
|
||||
}
|
||||
for (clock_id, amount) in &effect.clock_advances {
|
||||
let clock = campaign
|
||||
.clocks
|
||||
.get_mut(clock_id)
|
||||
.ok_or_else(|| KernelError::Invalid("outcome clock vanished".into()))?;
|
||||
clock.progress = clock.progress.saturating_add(*amount).min(clock.threshold);
|
||||
}
|
||||
for (institution_id, posture) in &effect.institution_postures {
|
||||
campaign
|
||||
.institutions
|
||||
.get_mut(institution_id)
|
||||
.ok_or_else(|| KernelError::Invalid("outcome institution vanished".into()))?
|
||||
.posture = posture.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_demotion(campaign: &mut Campaign, demotion: &GestaltDemotion) -> Result<(), KernelError> {
|
||||
let actor = campaign
|
||||
.actors
|
||||
|
|
@ -708,6 +774,10 @@ fn assess(c: &Campaign, intent: ActionIntent) -> ActionAssessment {
|
|||
success_stake: "The intended local effect succeeds and the world reacts.".into(),
|
||||
mixed_stake: "The effect lands with the previewed cost or complication.".into(),
|
||||
failure_stake: "Opposition holds and gains a concrete advantage.".into(),
|
||||
strong_effect: WorldEffectDelta::default(),
|
||||
success_effect: WorldEffectDelta::default(),
|
||||
mixed_effect: WorldEffectDelta::default(),
|
||||
failure_effect: WorldEffectDelta::default(),
|
||||
bargains: if admissible {
|
||||
vec![]
|
||||
} else {
|
||||
|
|
@ -1395,4 +1465,78 @@ mod tests {
|
|||
};
|
||||
assert_eq!(campaign.pending_world_proposals.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roll_commits_only_the_prevalidated_typed_outcome_delta() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CampaignStore::open(dir.path().join("campaign.cc")).unwrap();
|
||||
let kernel = WorldKernel::start(store.clone());
|
||||
let seed = campaign();
|
||||
let campaign_id = seed.id;
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
campaign: seed.clone(),
|
||||
evidence_receipts: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let intent = ActionIntent {
|
||||
actor_id: "player".into(),
|
||||
description: "brace the door".into(),
|
||||
intended_effect: "become braced".into(),
|
||||
};
|
||||
let mut invalid = assess(&seed, intent.clone());
|
||||
invalid
|
||||
.success_effect
|
||||
.actor_moves
|
||||
.insert("player".into(), "nowhere".into());
|
||||
invalid.digest = crate::assessor::assessment_digest(&invalid).unwrap();
|
||||
assert!(
|
||||
kernel
|
||||
.command(WorldCommand::Assess {
|
||||
expected_revision: 0,
|
||||
intent: intent.clone(),
|
||||
proposal: Some(invalid),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let delta = WorldEffectDelta {
|
||||
actor_conditions: BTreeMap::from([(
|
||||
"player".into(),
|
||||
ConditionDelta {
|
||||
add: BTreeSet::from(["braced".into()]),
|
||||
remove: BTreeSet::new(),
|
||||
},
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut valid = assess(&seed, intent.clone());
|
||||
valid.strong_effect = delta.clone();
|
||||
valid.success_effect = delta.clone();
|
||||
valid.mixed_effect = delta.clone();
|
||||
valid.failure_effect = delta;
|
||||
valid.digest = crate::assessor::assessment_digest(&valid).unwrap();
|
||||
kernel
|
||||
.command(WorldCommand::Assess {
|
||||
expected_revision: 0,
|
||||
intent,
|
||||
proposal: Some(valid.clone()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
kernel
|
||||
.command(WorldCommand::Attempt {
|
||||
assessment_digest: valid.digest,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let persisted = store
|
||||
.load::<Campaign>("campaign.v1", &campaign_id.to_string())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(persisted.revision, 1);
|
||||
assert!(persisted.actors["player"].conditions.contains("braced"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,16 @@ This deliberately serializes canonical consequence while keeping perception
|
|||
parallel. It prevents two proposals assessed against the same world snapshot
|
||||
from both committing incompatible outcomes.
|
||||
|
||||
Each assessment also carries four bounded typed outcome deltas. The accepted
|
||||
MVP delta vocabulary is deliberately narrow: conditions and relationships on
|
||||
actors within the acting actor's location, self-movement over an existing
|
||||
route, advancement of existing clocks, and posture changes to existing
|
||||
institutions. It cannot create actors, places, capabilities, knowledge,
|
||||
equipment, clocks, institutions, custody, or branch facts. The kernel validates
|
||||
all four bands before storing an assessment and applies only the OS-random
|
||||
roll's selected band in the same atomic commit as the roll receipt. Narrative
|
||||
stakes describe that transition; they are no longer the transition.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -120,6 +120,12 @@ DeepSeek currently documents both V4 model IDs, streaming, and JSON output throu
|
|||
- Overreach returns explicit sacrifices or bargains that could admit a new assessment.
|
||||
- Speech and its intended effect are separated: the character may successfully say something while failing to persuade, deceive, or intimidate.
|
||||
|
||||
Every roll band carries a prevalidated `WorldEffectDelta`. The MVP vocabulary
|
||||
can change local conditions and relationships, move the acting character over
|
||||
an existing route, advance known clocks, or change a known institution's
|
||||
posture. The chosen band and its delta commit atomically with the roll receipt;
|
||||
stake prose alone never impersonates world mutation.
|
||||
|
||||
### Away-time world agency
|
||||
|
||||
- The daemon owns a five-minute scheduler pulse. Campaigns enter away simulation after fifteen minutes without player activity.
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue