Make facts precede informational outcomes
This commit is contained in:
parent
ea30938b48
commit
ab0feece30
9 changed files with 278 additions and 39 deletions
|
|
@ -69,6 +69,7 @@ impl ActionAssessor {
|
|||
})
|
||||
})
|
||||
.collect();
|
||||
let information_facts = available_information_facts(campaign, actor);
|
||||
let allowed_references = allowed_references(campaign, actor);
|
||||
let agency_guidance = action_agency_guidance(&campaign.player_actor_id, &intent.actor_id);
|
||||
let mut schema = serde_json::to_value(schema_for!(AssessmentProposal))?;
|
||||
|
|
@ -82,7 +83,7 @@ impl ActionAssessor {
|
|||
"maximum":10
|
||||
});
|
||||
let base_prompt = format!(
|
||||
"OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nAssess 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 name actor IDs copied exactly from PRESENT ACTORS, change their conditions or relationships, move only the acting actor along an existing route, advance existing clocks, or change existing institution posture. When an outcome directly reveals information to the acting actor, actor_knowledge_additions must contain the specific observed finding as a player-readable natural-language declarative statement, never a fact ID, key, slug, or label. Strong and ordinary success share one visible stake, so give them identical knowledge additions. The runtime binds each exact finding into the player-visible stake; do not spend prose repeating it solely for formatting. Findings become provisional branch facts on commit, so do not invent remote events, hidden actors, unsupported proper nouns, or conclusions beyond the effect ceiling. Keep a delta empty only when the outcome truly has no canonical state change.\nAGENCY BOUNDARY:\n{}\nPLAYER ACTOR ID:\n{}\nINTENT:\n{}\nACTOR:\n{}\nLOCATION:\n{}\nPRESENT ACTORS:\n{}\nVISIBLE INSTITUTIONS:\n{}\nALLOWED REFERENCES:\n{}",
|
||||
"OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nAssess 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 name actor IDs copied exactly from PRESENT ACTORS, change their conditions or relationships, move only the acting actor along an existing route, advance existing clocks, or change existing institution posture. Informational outcomes may reveal only an exact statement copied from AVAILABLE INFORMATION FACTS; they never create a new fact. A location-discoverable fact may be added only to the acting actor. A fact already known by the acting actor may instead be communicated to another present actor. actor_knowledge_additions contains the player-readable statement, never a fact ID, key, slug, or label. Strong and ordinary success share one visible stake, so give them identical knowledge additions. The runtime binds each exact finding into the player-visible stake; do not spend prose repeating it solely for formatting. If no supplied fact supports the intended discovery or disclosure, leave knowledge deltas empty and make the limitation explicit in the stakes or mark the attempt inadmissible. Never invent remote events, hidden actors, unsupported proper nouns, or conclusions beyond the effect ceiling. Keep a delta empty only when the outcome truly has no canonical state change.\nAGENCY BOUNDARY:\n{}\nPLAYER ACTOR ID:\n{}\nINTENT:\n{}\nACTOR:\n{}\nLOCATION:\n{}\nPRESENT ACTORS:\n{}\nVISIBLE INSTITUTIONS:\n{}\nAVAILABLE INFORMATION FACTS:\n{}\nALLOWED REFERENCES:\n{}",
|
||||
serde_json::to_string(&schema)?,
|
||||
agency_guidance,
|
||||
campaign.player_actor_id,
|
||||
|
|
@ -91,6 +92,7 @@ impl ActionAssessor {
|
|||
serde_json::to_string(location)?,
|
||||
serde_json::to_string(&present_actors)?,
|
||||
serde_json::to_string(&visible_institutions)?,
|
||||
serde_json::to_string(&information_facts)?,
|
||||
serde_json::to_string(&allowed_references)?
|
||||
);
|
||||
let snapshot_binding = format!("campaign:{}:revision:{}", campaign.id, campaign.revision);
|
||||
|
|
@ -139,7 +141,7 @@ impl ActionAssessor {
|
|||
.and_then(|value| serde_json::to_string(value).ok())
|
||||
.unwrap_or_else(|| "unavailable".into());
|
||||
correction = format!(
|
||||
"\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS ASSESSMENT: {error}\nPREVIOUS ASSESSMENT:\n{rejected}\nReturn a corrected complete assessment against the same snapshot. Copy every modifier reference from ALLOWED REFERENCES exactly; omit a modifier rather than paraphrasing or inventing its reference. Copy every actor and destination ID exactly from the supplied state. Bind specific directly observed findings only to the acting actor; strong and ordinary success must use identical knowledge additions. Otherwise leave the typed delta empty."
|
||||
"\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS ASSESSMENT: {error}\nPREVIOUS ASSESSMENT:\n{rejected}\nReturn a corrected complete assessment against the same snapshot. Copy every modifier reference from ALLOWED REFERENCES exactly; omit a modifier rather than paraphrasing or inventing its reference. Copy every actor and destination ID exactly from the supplied state. Every knowledge addition must copy one exact statement from AVAILABLE INFORMATION FACTS and obey its access mode; strong and ordinary success must use identical knowledge additions. Otherwise leave the typed delta empty."
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
|
|
@ -235,14 +237,43 @@ fn allowed_references(campaign: &Campaign, actor: &crate::domain::ActorState) ->
|
|||
for id in campaign.institutions.keys() {
|
||||
refs.insert(format!("institution:{id}"));
|
||||
}
|
||||
for id in campaign.facts.keys() {
|
||||
refs.insert(format!("fact:{id}"));
|
||||
for fact in available_information_facts(campaign, actor) {
|
||||
if let Some(id) = fact.get("id").and_then(serde_json::Value::as_str) {
|
||||
refs.insert(format!("fact:{id}"));
|
||||
}
|
||||
}
|
||||
for id in &campaign.branch_origin.evidence_receipt_ids {
|
||||
refs.insert(id.clone());
|
||||
}
|
||||
refs
|
||||
}
|
||||
|
||||
fn available_information_facts(
|
||||
campaign: &Campaign,
|
||||
actor: &crate::domain::ActorState,
|
||||
) -> Vec<serde_json::Value> {
|
||||
campaign
|
||||
.facts
|
||||
.values()
|
||||
.filter_map(|fact| {
|
||||
let access = if actor.knowledge.contains(&fact.statement) {
|
||||
"known_by_actor"
|
||||
} else if fact
|
||||
.discoverable_at_location_ids
|
||||
.contains(&actor.location_id)
|
||||
{
|
||||
"discoverable_here"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(serde_json::json!({
|
||||
"id": fact.id,
|
||||
"statement": fact.statement,
|
||||
"access": access,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn validate_proposal(p: &AssessmentProposal, allowed: &BTreeSet<String>) -> Result<()> {
|
||||
if ![5, 10, 15, 20, 25, 30].contains(&p.dc) {
|
||||
return Err(anyhow!("assessor chose invalid DC"));
|
||||
|
|
@ -310,19 +341,38 @@ pub(crate) fn validate_effect(
|
|||
}
|
||||
}
|
||||
for (actor_id, additions) in &effect.actor_knowledge_additions {
|
||||
if actor_id != &acting_actor.id
|
||||
|| additions.is_empty()
|
||||
let target = campaign
|
||||
.actors
|
||||
.get(actor_id)
|
||||
.ok_or_else(|| anyhow!("outcome knowledge target vanished"))?;
|
||||
let invalid_finding = additions.iter().find(|finding| {
|
||||
let Some(fact) = campaign
|
||||
.facts
|
||||
.values()
|
||||
.find(|fact| fact.statement == finding.as_str())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let accessible = if actor_id == &acting_actor.id {
|
||||
fact.discoverable_at_location_ids
|
||||
.contains(&acting_actor.location_id)
|
||||
} else {
|
||||
acting_actor.knowledge.contains(*finding)
|
||||
};
|
||||
!accessible || target.knowledge.contains(*finding)
|
||||
});
|
||||
if additions.is_empty()
|
||||
|| additions.len() > 4
|
||||
|| additions.iter().any(|finding| {
|
||||
finding.trim().is_empty()
|
||||
|| finding.chars().count() > 500
|
||||
|| looks_like_identifier(finding)
|
||||
|| acting_actor.knowledge.contains(finding)
|
||||
|| !stake.contains(finding)
|
||||
})
|
||||
|| invalid_finding.is_some()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"outcome knowledge must be a new bounded natural-language finding, directly observed by the acting actor and visible verbatim in its stake"
|
||||
"outcome knowledge must copy an existing accessible WorldFact statement: a location-discoverable fact may go only to the acting actor, while another present actor may receive only a fact already known by the acting actor; every finding must be new to its recipient and visible verbatim in its stake"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ impl WorldCompiler {
|
|||
scoped_evidence
|
||||
);
|
||||
let base_prompt = format!(
|
||||
"{shared_prefix}Compile a bounded playable region with stable topology, local actors, populations, clocks, and only those remote institutions that have a direct causal relationship to this requested start. SCOPED EVIDENCE contains direct_seed witnesses only. Setting-background and excluded witnesses remain visible in the approval coverage but are deliberately absent here: they cannot donate cast, incidents, clocks, location state, goals, or institutional posture to this branch. When direct evidence cannot ground a requested local detail, keep the local cast sparse, mark reversible texture provisional_local, and list the material gap instead of borrowing a nearby story. Do not eagerly invent remote settlements, routes, or people. Emit only supported canon facts. A canon_baseline fact must cite one or more exact receipt_id values printed in SCOPED EVIDENCE whose witnesses directly support the whole statement. Never label an invented proper noun canon. The player location and every actor location must exist. Every route destination must exist, travel time must be positive, clocks need positive thresholds, and the player id must be unique. Actor relationship map keys must copy exact actor or institution IDs declared in this candidate, never display names, roles, groups, or location IDs. Represent populations that can act collectively (villages, crews, crowds, departments, corporations) as gestalt Personas. Seed a small roster of plausible durable member identities for people the player may encounter; member deltas contain only departures from their gestalt baseline and begin dematerialized. Do not duplicate a gestalt member in actors. Keep named plot-critical people as ordinary actors. Every gestalt home location and member gestalt reference must exist. Do not emit agency profiles or relations; those are compiled from the exact validated subject roster in the next stage."
|
||||
"{shared_prefix}Compile a bounded playable region with stable topology, local actors, populations, clocks, and only those remote institutions that have a direct causal relationship to this requested start. SCOPED EVIDENCE contains direct_seed witnesses only. Setting-background and excluded witnesses remain visible in the approval coverage but are deliberately absent here: they cannot donate cast, incidents, clocks, location state, goals, or institutional posture to this branch. When direct evidence cannot ground a requested local detail, keep the local cast sparse, mark reversible texture provisional_local, and list the material gap instead of borrowing a nearby story. Do not eagerly invent remote settlements, routes, or people. Emit only supported canon facts. A canon_baseline fact must cite one or more exact receipt_id values printed in SCOPED EVIDENCE whose witnesses directly support the whole statement. Never label an invented proper noun canon. Facts that an actor can uncover through an admitted local observation must exist before play and list the exact discoverable_at_location_ids where that observation is possible. Seed enough branch_local or provisional_local discoverable facts to make the requested opening pressure and immediate goal actionable; the later action assessor can reveal an existing fact but cannot invent one. Facts that are private history or not directly observable have an empty discovery-location set. The player location and every actor location must exist. Every route destination and fact discovery location must exist, travel time must be positive, clocks need positive thresholds, and the player id must be unique. Actor relationship map keys must copy exact actor or institution IDs declared in this candidate, never display names, roles, groups, or location IDs. Represent populations that can act collectively (villages, crews, crowds, departments, corporations) as gestalt Personas. Seed a small roster of plausible durable member identities for people the player may encounter; member deltas contain only departures from their gestalt baseline and begin dematerialized. Do not duplicate a gestalt member in actors. Keep named plot-critical people as ordinary actors. Every gestalt home location and member gestalt reference must exist. Do not emit agency profiles or relations; those are compiled from the exact validated subject roster in the next stage."
|
||||
);
|
||||
let schema = serde_json::to_value(schema_for!(CompiledSeed))?;
|
||||
let sources = receipt_ids_for_coverage(&receipts, &evidence_coverage);
|
||||
|
|
@ -637,7 +637,7 @@ impl WorldCompiler {
|
|||
.await?;
|
||||
let snapshot = format!("campaign:{}:revision:{}", campaign.id, campaign.revision);
|
||||
let base_prompt = format!(
|
||||
"Compile only the requested bounded destination region. Every new location id must be new. At least one new location must route back to origin id {} with a positive travel time. Do not rewrite existing geography. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}",
|
||||
"Compile only the requested bounded destination region. Every new location id must be new. At least one new location must route back to origin id {} with a positive travel time. Do not rewrite existing geography. Any locally observable clue must already exist as a fact and list exact discoverable_at_location_ids from the combined existing and new topology; later action assessment can reveal facts but cannot invent them. CAMPAIGN LOCATIONS:\n{}\nREQUEST:\n{}\nEVIDENCE:\n{}",
|
||||
origin_location_id,
|
||||
serde_json::to_string(&campaign.locations)?,
|
||||
destination_request,
|
||||
|
|
@ -1398,6 +1398,35 @@ pub fn validate_region_expansion(
|
|||
if !attached {
|
||||
return Err(anyhow!("destination expansion is not attached to origin"));
|
||||
}
|
||||
let existing_fact_ids = campaign.facts.keys().collect::<BTreeSet<_>>();
|
||||
let mut new_fact_ids = BTreeSet::new();
|
||||
let mut fact_statements = campaign
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| fact.statement.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for fact in &expansion.facts {
|
||||
if fact.id.trim().is_empty()
|
||||
|| existing_fact_ids.contains(&fact.id)
|
||||
|| !new_fact_ids.insert(fact.id.clone())
|
||||
|| fact.statement.trim().is_empty()
|
||||
|| !fact_statements.insert(fact.statement.clone())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"destination expansion facts must have new IDs and non-empty unique statements"
|
||||
));
|
||||
}
|
||||
if fact
|
||||
.discoverable_at_location_ids
|
||||
.iter()
|
||||
.any(|id| !known(id))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"destination expansion fact {} has an unknown discovery location",
|
||||
fact.id
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1993,6 +2022,29 @@ pub fn validate_campaign_seed(c: &Campaign) -> Result<()> {
|
|||
}
|
||||
}
|
||||
}
|
||||
let mut fact_statements = BTreeSet::new();
|
||||
for fact in c.facts.values() {
|
||||
if fact.statement.trim().is_empty() || !fact_statements.insert(fact.statement.clone()) {
|
||||
return Err(anyhow!(
|
||||
"world facts must have non-empty unique statements; rejected fact {}",
|
||||
fact.id
|
||||
));
|
||||
}
|
||||
let invalid_locations = fact
|
||||
.discoverable_at_location_ids
|
||||
.iter()
|
||||
.filter(|id| !c.locations.contains_key(*id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !invalid_locations.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"fact {} is discoverable at unknown locations {:?}; valid location IDs={:?}",
|
||||
fact.id,
|
||||
invalid_locations,
|
||||
c.locations.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
}
|
||||
for clock in c.clocks.values() {
|
||||
if clock.threshold == 0 || clock.progress > clock.threshold {
|
||||
return Err(anyhow!("clock {} is invalid", clock.id));
|
||||
|
|
|
|||
|
|
@ -538,6 +538,11 @@ pub struct WorldFact {
|
|||
pub statement: String,
|
||||
pub scope: FactScope,
|
||||
pub evidence_receipt_ids: Vec<String>,
|
||||
/// Locations whose immediate environment can expose this fact through an
|
||||
/// admitted informational attempt. Empty means the fact is not directly
|
||||
/// discoverable from occupancy alone.
|
||||
#[serde(default)]
|
||||
pub discoverable_at_location_ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -888,18 +888,6 @@ fn apply_world_effect(
|
|||
.get_mut(actor_id)
|
||||
.ok_or_else(|| KernelError::Invalid("outcome actor vanished".into()))?;
|
||||
actor.knowledge.extend(additions.clone());
|
||||
for finding in additions {
|
||||
let fact_id = format!(
|
||||
"fact:outcome:{:x}",
|
||||
Sha256::digest(format!("{}:{finding}", campaign.id).as_bytes())
|
||||
);
|
||||
campaign.facts.entry(fact_id.clone()).or_insert(WorldFact {
|
||||
id: fact_id,
|
||||
statement: finding.clone(),
|
||||
scope: FactScope::BranchLocal,
|
||||
evidence_receipt_ids: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (actor_id, relationships) in &effect.actor_relationship_updates {
|
||||
campaign
|
||||
|
|
@ -2539,7 +2527,18 @@ mod tests {
|
|||
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 mut seed = campaign();
|
||||
let player_location = seed.actors["player"].location_id.clone();
|
||||
seed.facts.insert(
|
||||
"fact:door-brace-seated".into(),
|
||||
WorldFact {
|
||||
id: "fact:door-brace-seated".into(),
|
||||
statement: "The door brace is seated against the frame.".into(),
|
||||
scope: FactScope::BranchLocal,
|
||||
evidence_receipt_ids: vec![],
|
||||
discoverable_at_location_ids: BTreeSet::from([player_location]),
|
||||
},
|
||||
);
|
||||
let campaign_id = seed.id;
|
||||
kernel
|
||||
.command(WorldCommand::CreateCampaign {
|
||||
|
|
@ -2578,6 +2577,7 @@ mod tests {
|
|||
"player".into(),
|
||||
BTreeSet::from(["The hidden latch is broken.".into()]),
|
||||
);
|
||||
hidden_finding.success_stake = "The hidden latch is broken.".into();
|
||||
hidden_finding.digest = crate::assessor::assessment_digest(&hidden_finding).unwrap();
|
||||
assert!(
|
||||
kernel
|
||||
|
|
@ -2643,6 +2643,79 @@ mod tests {
|
|||
fact.statement == "The door brace is seated against the frame."
|
||||
&& fact.scope == FactScope::BranchLocal
|
||||
}));
|
||||
assert_eq!(persisted.facts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn information_effects_reveal_existing_accessible_facts_only() {
|
||||
let statement = "The maintenance panel records a seven-minute interruption.";
|
||||
let mut seed = campaign();
|
||||
seed.facts.insert(
|
||||
"fact:panel-interruption".into(),
|
||||
WorldFact {
|
||||
id: "fact:panel-interruption".into(),
|
||||
statement: statement.into(),
|
||||
scope: FactScope::BranchLocal,
|
||||
evidence_receipt_ids: vec![],
|
||||
discoverable_at_location_ids: BTreeSet::from(["room".into()]),
|
||||
},
|
||||
);
|
||||
let effect = WorldEffectDelta {
|
||||
actor_knowledge_additions: BTreeMap::from([(
|
||||
"player".into(),
|
||||
BTreeSet::from([statement.into()]),
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
crate::assessor::validate_effect(&seed, &seed.actors["player"], &effect, statement,)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let mut absent = seed.clone();
|
||||
absent.facts.clear();
|
||||
assert!(
|
||||
crate::assessor::validate_effect(
|
||||
&absent,
|
||||
&absent.actors["player"],
|
||||
&effect,
|
||||
statement,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("existing accessible WorldFact")
|
||||
);
|
||||
|
||||
let mut wrong_place = seed.clone();
|
||||
wrong_place
|
||||
.facts
|
||||
.get_mut("fact:panel-interruption")
|
||||
.unwrap()
|
||||
.discoverable_at_location_ids = BTreeSet::from(["elsewhere".into()]);
|
||||
assert!(
|
||||
crate::assessor::validate_effect(
|
||||
&wrong_place,
|
||||
&wrong_place.actors["player"],
|
||||
&effect,
|
||||
statement,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut speaker = seed.actors["player"].clone();
|
||||
speaker.id = "speaker".into();
|
||||
speaker.name = "Speaker".into();
|
||||
speaker.knowledge.insert(statement.into());
|
||||
seed.actors.insert(speaker.id.clone(), speaker.clone());
|
||||
seed.facts
|
||||
.get_mut("fact:panel-interruption")
|
||||
.unwrap()
|
||||
.discoverable_at_location_ids
|
||||
.clear();
|
||||
assert!(
|
||||
crate::assessor::validate_effect(&seed, &speaker, &effect, statement).is_ok(),
|
||||
"an actor may communicate an existing fact they know to another present actor"
|
||||
);
|
||||
}
|
||||
|
||||
fn resolution_stage(
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ use ghostlight_dungeon::{
|
|||
SuggestedOpenings, SuggestedRoles, WorldCompiler,
|
||||
},
|
||||
domain::{
|
||||
ActionIntent, Campaign, GestaltFissionPreview, NarrationProjection, RegionExpansionPreview,
|
||||
RejectedProposalReceipt, WorldCommand, WorldCompilePreview,
|
||||
ActionIntent, Campaign, FactScope, GestaltFissionPreview, NarrationProjection,
|
||||
RegionExpansionPreview, RejectedProposalReceipt, WorldCommand, WorldCompilePreview,
|
||||
},
|
||||
gestalt::GestaltPresencePlanner,
|
||||
kernel::{CommandResult, KernelError},
|
||||
|
|
@ -1315,6 +1315,19 @@ fn world_compile_preview_projection(preview: &WorldCompilePreview) -> serde_json
|
|||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let branch_facts = campaign
|
||||
.facts
|
||||
.values()
|
||||
.filter(|fact| fact.scope != FactScope::CanonBaseline)
|
||||
.map(|fact| {
|
||||
serde_json::json!({
|
||||
"id":fact.id,
|
||||
"scope":fact.scope,
|
||||
"statement":fact.statement,
|
||||
"discoverable_at_location_ids":fact.discoverable_at_location_ids,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::json!({
|
||||
"schema":preview.schema,
|
||||
"title":preview.title,
|
||||
|
|
@ -1335,6 +1348,7 @@ fn world_compile_preview_projection(preview: &WorldCompilePreview) -> serde_json
|
|||
"evidence_coverage":preview.evidence_coverage,
|
||||
"gaps":preview.gaps,
|
||||
"branch_assumptions":preview.branch_assumptions,
|
||||
"branch_facts":branch_facts,
|
||||
"requires_approval":preview.requires_approval,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -607,16 +607,45 @@ receive no roll. Natural 20 and 1 shift one band and cannot cross impossibility
|
|||
or the effect ceiling. Speech occurs as speech; persuasion, deception, and
|
||||
intimidation are separate intended effects.
|
||||
|
||||
Informational attempts must preview the exact finding that becomes knowledge.
|
||||
On resolution, only the acting character may receive those bounded knowledge
|
||||
additions; the same statement is committed as a provisional branch fact. A
|
||||
vague promise such as “identify any faults” is not a valid informational
|
||||
outcome, and an unstated hidden finding cannot appear after the roll.
|
||||
The assessor normalizes each bounded typed finding into its visible stake before
|
||||
validation and digesting. Exact visibility is therefore deterministic; a model
|
||||
retry is reserved for semantic or authority failure, not punctuation mismatch.
|
||||
The schema names these values as player-readable declarative statements, and a
|
||||
fact ID, key, or slug cannot enter the character ledger as knowledge.
|
||||
Informational attempts may reveal an existing `WorldFact`; they may not author
|
||||
one. `WorldFact` owns branch truth and the location boundaries at which a fact
|
||||
can be discovered. `ActorState.knowledge` owns only which exact statements that
|
||||
actor has learned. The compiler or an approval-gated region expansion must
|
||||
therefore establish a clue before a roll can expose it.
|
||||
|
||||
The assessor receives only facts already known by the acting actor or marked
|
||||
discoverable at that actor's current location. Every proposed knowledge
|
||||
addition must exactly match one of those statements. A fact known by the acting
|
||||
actor may be communicated to another present actor; a location-bound fact may
|
||||
be discovered only by the acting actor. Player attempts and NPC initiative use
|
||||
the same validator. The kernel atomically updates actor knowledge but never
|
||||
creates a fact from model prose.
|
||||
|
||||
This ownership makes the negative invariant structural: an invented protocol
|
||||
number, hidden culprit, remote event, or clue cannot become true because an
|
||||
assessor wrote it into a successful stake. Unsupported information causes one
|
||||
same-snapshot correction and then aborts without mutation. A vague promise such
|
||||
as “identify any faults” is not a valid informational outcome, and an unstated
|
||||
hidden finding cannot appear after the roll. Player-readable stakes still carry
|
||||
the exact declarative statement; fact IDs, keys, and slugs remain private typed
|
||||
references.
|
||||
|
||||
Authority map:
|
||||
|
||||
- Owner: `WorldFact` owns truth and discovery locations; `ActorState` owns
|
||||
learned access.
|
||||
- Inputs: approved compiler or region-expansion facts, current occupancy,
|
||||
actor knowledge, and the attempted effect.
|
||||
- Output: an assessment whose information deltas are a subset of existing
|
||||
accessible facts.
|
||||
- Derived state: the visible stake and narration are projections of that exact
|
||||
assessment and committed revision.
|
||||
- Forbidden writers: Personas, Interpreters, assessors, narrators, and
|
||||
`apply_world_effect` cannot create facts.
|
||||
- Shared path: player `Attempt` and `ResolveNpcAction` call the same fact-access
|
||||
validator and atomic commit primitive.
|
||||
- Cut line: the former promotion of arbitrary knowledge prose into a new
|
||||
branch-local `WorldFact` is deleted.
|
||||
|
||||
### Actor and world action loop
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GhostlightDungeon</title> <script type="module" crossorigin src="/assets/index-DmZm_kJC.js"></script>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GhostlightDungeon</title> <script type="module" crossorigin src="/assets/index-CicytASo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DLoOYHpE.css">
|
||||
</head>
|
||||
<body><header><p class="eyebrow">STARFIRE / PRIVATE VERSE</p><h1>GhostlightDungeon</h1><p id="status" role="status" aria-live="polite">Reading the world…</p></header><main>
|
||||
|
|
|
|||
|
|
@ -198,6 +198,22 @@ async function showPreview(result: any) {
|
|||
? `Evidence use: ${evidenceCounts.direct_seed ?? 0} direct · ${evidenceCounts.setting_background ?? 0} background · ${evidenceCounts.excluded ?? 0} excluded`
|
||||
: "Evidence use: no retrieved sources were admitted to this seed.", "quiet"),
|
||||
);
|
||||
const branchFacts = Array.isArray(preview.branch_facts) ? preview.branch_facts : [];
|
||||
const factDisclosure = node("details");
|
||||
factDisclosure.append(node("summary", `Branch-local world facts (${branchFacts.length}) · contains operator spoilers`));
|
||||
if (branchFacts.length) {
|
||||
const list = node("ul");
|
||||
for (const fact of branchFacts) {
|
||||
const locations = Array.isArray(fact.discoverable_at_location_ids) && fact.discoverable_at_location_ids.length
|
||||
? ` · discoverable at ${fact.discoverable_at_location_ids.join(", ")}`
|
||||
: " · not directly discoverable";
|
||||
list.append(node("li", `${fact.statement}${locations}`));
|
||||
}
|
||||
factDisclosure.append(list);
|
||||
} else {
|
||||
factDisclosure.append(node("p", "No branch-local facts are proposed."));
|
||||
}
|
||||
card.append(factDisclosure);
|
||||
const approve = node("button", "Approve and enter");
|
||||
approve.type = "button";
|
||||
approve.addEventListener("click", async () => { await compilerPost(`/api/compiler/approve/${result.preview_id}`, {}); await refresh(); });
|
||||
|
|
|
|||
Loading…
Reference in a new issue