diff --git a/crates/nana-runtime/src/adjudication.rs b/crates/nana-runtime/src/adjudication.rs new file mode 100644 index 0000000..8df736d --- /dev/null +++ b/crates/nana-runtime/src/adjudication.rs @@ -0,0 +1,1367 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use nana_domain::{ + CharacterCard, CheckDifficulty, CheckRecord, CheckResult, ItemPlacement, ItemSpec, Persona, + ResourceBundle, RuntimeState, StateOp, TurnIntent, TurnRequest, stable_json_hash, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{InvalidModelOutputKind, ProviderError, TurnPlan, TurnPlanProvider}; + +pub const HIDDEN_CHECK_TOOL_NAME: &str = "request_hidden_check"; +pub const DEFAULT_MAX_ADJUDICATION_STEPS: usize = 4; + +/// A typed hidden-check request proposed by the narrative model. +/// +/// All numeric authority is intentionally absent. The engine looks up the +/// actor's skill and any requested item modifiers in trusted content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HiddenCheckRequest { + pub check_id: String, + pub actor_id: String, + pub skill: String, + pub difficulty: CheckDifficulty, + #[serde(default)] + pub item_ids: Vec, + #[serde(default)] + pub pushed_from: Option, +} + +/// The only check information returned to the model. +/// +/// This type deliberately cannot carry the target, roll, difficulty, or item +/// modifier. Those values remain in the engine-created `CheckRecord`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct QualitativeCheckOutcome { + pub check_id: String, + pub result: CheckResult, + pub pushed: bool, +} + +/// One model tool call. The model either asks the engine for a hidden check or +/// submits the single final plan for the turn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdjudicationToolCall { + RequestHiddenCheck(HiddenCheckRequest), + SubmitTurnPlan(TurnPlan), +} + +/// Raw shape returned by the model adapter. +/// +/// Keeping text and tool calls separate lets the trusted loop reject ambiguous +/// text-plus-tool and multi-tool responses before any state is changed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdjudicationModelResponse { + pub text: Option, + pub tool_calls: Vec, +} + +impl AdjudicationModelResponse { + #[must_use] + pub fn tool(tool_call: AdjudicationToolCall) -> Self { + Self { + text: None, + tool_calls: vec![tool_call], + } + } +} + +/// Input for one step of the model conversation. +/// +/// The model adapter is a trusted boundary: on `BeginTurn` it may compile the +/// supplied state into narrative context, but must not serialize hidden check +/// records into model-visible text. After a check, the adapter receives only +/// the qualitative outcome. +#[derive(Debug, Clone, Copy)] +pub enum AdjudicationModelInput<'a> { + BeginTurn { + request: &'a TurnRequest, + state: &'a RuntimeState, + }, + CheckResolved(&'a QualitativeCheckOutcome), +} + +/// Testable seam for a future LAPP tool-calling adapter. +/// +/// The adapter owns one transcript for the duration of a call to +/// `plan_adjudicated_turn`: +/// +/// 1. `BeginTurn` resets the transcript and compiles `state` through whichever +/// opaque, serializable safe-context type the application supplies. +/// 2. When it returns a hidden-check tool call, it retains the assistant +/// message and provider tool-call id internally. +/// 3. `CheckResolved` is serialized as the matching tool-role message and the +/// same transcript is submitted again. +/// +/// This keeps the loop independent of any concrete context compiler while +/// preserving native LAPP assistant/tool message ordering. An adapter must +/// never serialize `RuntimeState::checks`; the resolved continuation is the +/// sole model-visible check result. +pub trait AdjudicationModel { + fn respond( + &mut self, + input: AdjudicationModelInput<'_>, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CatalogError { + #[error("trusted actor id is empty")] + EmptyActorId, + #[error("trusted actor id is duplicated: {0}")] + DuplicateActor(String), + #[error("trusted actor has an invalid skill: {actor}/{skill}")] + InvalidSkill { actor: String, skill: String }, + #[error("trusted actor skill is duplicated: {actor}/{skill}")] + DuplicateSkill { actor: String, skill: String }, + #[error("trusted item spec id is empty")] + EmptyItemSpecId, + #[error("trusted item spec id is duplicated: {0}")] + DuplicateItemSpec(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AdjudicationError { + #[error("model response must contain exactly one tool call and no text")] + AmbiguousResponse, + #[error("model requested an invalid check id")] + InvalidCheckId, + #[error("model requested an unknown actor: {0}")] + UnknownActor(String), + #[error("model requested an unknown skill: {actor}/{skill}")] + UnknownSkill { actor: String, skill: String }, + #[error("model requested an unknown item instance: {0}")] + UnknownItem(String), + #[error("model requested an item not usable by the actor: {0}")] + ItemNotUsable(String), + #[error("model requested an item without a check modifier: {0}")] + ItemHasNoModifier(String), + #[error("model requested an item more than once: {0}")] + DuplicateItem(String), + #[error("check id was already used: {0}")] + DuplicateCheckId(String), + #[error("model supplied a RecordCheck state operation")] + ModelSuppliedRecordCheck, + #[error("adjudication exceeded its tool-step budget")] + StepBudgetExceeded, + #[error("pushed check did not match the player-authorized failed check")] + PushedCheckMismatch, +} + +#[derive(Debug, Error)] +pub enum AdjudicationRunError { + #[error(transparent)] + Provider(#[from] ProviderError), + #[error(transparent)] + Rejected(#[from] AdjudicationError), +} + +#[derive(Debug, Clone)] +struct TrustedActor { + skills: BTreeMap, +} + +#[derive(Debug, Clone)] +struct TrustedSkill { + name: String, + value: u8, +} + +/// Trusted numeric authority for hidden checks. +/// +/// Actor skill values come only from `Persona` and `CharacterCard` resources; +/// item modifiers come only from `ItemSpec` resources. +#[derive(Debug, Clone)] +pub struct AdjudicationCatalog { + actors: BTreeMap, + item_modifiers: BTreeMap>, +} + +impl AdjudicationCatalog { + pub fn from_bundle(bundle: &ResourceBundle) -> Result { + Self::from_resources(&bundle.personas, &bundle.characters, &bundle.item_specs) + } + + pub fn from_resources( + personas: &[Persona], + characters: &[CharacterCard], + item_specs: &[ItemSpec], + ) -> Result { + let mut actors = BTreeMap::new(); + for persona in personas { + insert_actor( + &mut actors, + &persona.header.id.0, + persona + .skills + .iter() + .map(|skill| (&skill.skill, skill.value)), + )?; + } + for character in characters { + insert_actor( + &mut actors, + &character.header.id.0, + character + .skills + .iter() + .map(|skill| (&skill.skill, skill.value)), + )?; + } + + let mut item_modifiers = BTreeMap::new(); + for item in item_specs { + let id = item.header.id.0.trim(); + if id.is_empty() { + return Err(CatalogError::EmptyItemSpecId); + } + if item_modifiers + .insert(id.to_owned(), item.mechanics.check_modifier) + .is_some() + { + return Err(CatalogError::DuplicateItemSpec(id.to_owned())); + } + } + + Ok(Self { + actors, + item_modifiers, + }) + } + + fn resolve_check( + &self, + request: &TurnRequest, + state: &RuntimeState, + proposed: &HiddenCheckRequest, + ) -> Result { + if !valid_identifier(&proposed.check_id) { + return Err(AdjudicationError::InvalidCheckId); + } + + let actor_id = proposed.actor_id.trim(); + if actor_id != proposed.actor_id { + return Err(AdjudicationError::UnknownActor(proposed.actor_id.clone())); + } + let actor = self + .actors + .get(actor_id) + .ok_or_else(|| AdjudicationError::UnknownActor(proposed.actor_id.clone()))?; + let skill_key = normalize_skill(&proposed.skill); + let trusted_skill = + actor + .skills + .get(&skill_key) + .ok_or_else(|| AdjudicationError::UnknownSkill { + actor: proposed.actor_id.clone(), + skill: proposed.skill.clone(), + })?; + + let mut modifier = 0_i32; + let mut seen_items = BTreeSet::new(); + for item_id in &proposed.item_ids { + if !seen_items.insert(item_id) { + return Err(AdjudicationError::DuplicateItem(item_id.clone())); + } + let instance = state + .items + .iter() + .find(|item| item.id == *item_id) + .ok_or_else(|| AdjudicationError::UnknownItem(item_id.clone()))?; + if instance.holder != actor_id + || instance.quantity == 0 + || matches!( + instance.placement, + ItemPlacement::Scene | ItemPlacement::Hidden + ) + { + return Err(AdjudicationError::ItemNotUsable(item_id.clone())); + } + let item_modifier = self + .item_modifiers + .get(&instance.spec_ref.0) + .ok_or_else(|| AdjudicationError::UnknownItem(item_id.clone()))? + .ok_or_else(|| AdjudicationError::ItemHasNoModifier(item_id.clone()))?; + modifier = modifier.saturating_add(i32::from(item_modifier)); + } + + let target = + u8::try_from((i32::from(trusted_skill.value) + modifier).clamp(1, 99)).unwrap_or(99); + let roll = deterministic_roll( + &request.story_id, + &request.branch_id, + &request.action_id, + &proposed.check_id, + ); + + Ok(CheckRecord { + id: proposed.check_id.clone(), + action_id: request.action_id.clone(), + actor: actor_id.to_owned(), + skill: trusted_skill.name.clone(), + target, + difficulty: proposed.difficulty, + bonus_dice: 0, + roll, + result: classify_roll(roll, target), + pushed_from: proposed.pushed_from.clone(), + // The final node identity is not known until the model submits its + // one TurnPlan. It is filled before returning to TurnEngine. + node_id: String::new(), + }) + } +} + +/// Engine-controlled model/check/model loop. +/// +/// It performs no persistence. Check records are held locally and merged into +/// the one final plan, so the outer `TurnEngine` still performs one atomic +/// reducer/store append. +#[derive(Debug)] +pub struct AdjudicatingTurnPlanProvider { + model: Model, + catalog: AdjudicationCatalog, + max_steps: usize, +} + +impl AdjudicatingTurnPlanProvider { + #[must_use] + pub fn new(model: Model, catalog: AdjudicationCatalog) -> Self { + Self { + model, + catalog, + max_steps: DEFAULT_MAX_ADJUDICATION_STEPS, + } + } + + #[must_use] + pub fn with_max_steps(model: Model, catalog: AdjudicationCatalog, max_steps: usize) -> Self { + Self { + model, + catalog, + max_steps, + } + } + + #[must_use] + pub const fn model(&self) -> &Model { + &self.model + } + + #[must_use] + pub fn into_model(self) -> Model { + self.model + } +} + +impl AdjudicatingTurnPlanProvider { + pub fn plan_adjudicated_turn( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + ) -> Result { + let mut records = Vec::new(); + let mut last_outcome = None; + let mut check_ids = state + .checks + .iter() + .map(|check| check.id.clone()) + .collect::>(); + + for _ in 0..self.max_steps { + let input = last_outcome.as_ref().map_or( + AdjudicationModelInput::BeginTurn { request, state }, + AdjudicationModelInput::CheckResolved, + ); + let response = self.model.respond(input)?; + let tool_call = exactly_one_tool(response)?; + match tool_call { + AdjudicationToolCall::RequestHiddenCheck(proposed) => { + if !check_ids.insert(proposed.check_id.clone()) { + return Err(AdjudicationError::DuplicateCheckId(proposed.check_id).into()); + } + if matches!(request.intent, TurnIntent::PushCheck) && !records.is_empty() { + return Err(AdjudicationError::PushedCheckMismatch.into()); + } + + let record = self.catalog.resolve_check(request, state, &proposed)?; + validate_push(request, state, &record)?; + let outcome = QualitativeCheckOutcome { + check_id: record.id.clone(), + result: record.result, + pushed: record.pushed_from.is_some(), + }; + records.push(record); + last_outcome = Some(outcome); + } + AdjudicationToolCall::SubmitTurnPlan(mut plan) => { + if plan + .delta + .ops + .iter() + .any(|op| matches!(op, StateOp::RecordCheck { .. })) + { + return Err(AdjudicationError::ModelSuppliedRecordCheck.into()); + } + if matches!(request.intent, TurnIntent::PushCheck) + && (records.len() != 1 || records[0].pushed_from.is_none()) + { + return Err(AdjudicationError::PushedCheckMismatch.into()); + } + + for mut record in records { + record.node_id.clone_from(&plan.committed_node_id); + plan.delta.ops.push(StateOp::RecordCheck { check: record }); + } + return Ok(plan); + } + } + } + + Err(AdjudicationError::StepBudgetExceeded.into()) + } +} + +impl TurnPlanProvider for AdjudicatingTurnPlanProvider { + fn plan_turn( + &mut self, + request: &TurnRequest, + state: &RuntimeState, + ) -> Result { + self.plan_adjudicated_turn(request, state) + .map_err(|error| match error { + AdjudicationRunError::Provider(error) => error, + AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput { + kind: InvalidModelOutputKind::InvalidPlan, + }, + }) + } +} + +fn insert_actor<'a>( + actors: &mut BTreeMap, + actor_id: &str, + skills: impl Iterator, +) -> Result<(), CatalogError> { + let actor_id = actor_id.trim(); + if actor_id.is_empty() { + return Err(CatalogError::EmptyActorId); + } + if actors.contains_key(actor_id) { + return Err(CatalogError::DuplicateActor(actor_id.to_owned())); + } + + let mut trusted_skills = BTreeMap::new(); + for (skill, value) in skills { + let normalized = normalize_skill(skill); + if normalized.is_empty() || value > 100 { + return Err(CatalogError::InvalidSkill { + actor: actor_id.to_owned(), + skill: skill.clone(), + }); + } + if trusted_skills + .insert( + normalized, + TrustedSkill { + name: skill.trim().to_owned(), + value, + }, + ) + .is_some() + { + return Err(CatalogError::DuplicateSkill { + actor: actor_id.to_owned(), + skill: skill.clone(), + }); + } + } + actors.insert( + actor_id.to_owned(), + TrustedActor { + skills: trusted_skills, + }, + ); + Ok(()) +} + +fn exactly_one_tool( + response: AdjudicationModelResponse, +) -> Result { + if response + .text + .as_deref() + .is_some_and(|text| !text.trim().is_empty()) + || response.tool_calls.len() != 1 + { + return Err(AdjudicationError::AmbiguousResponse); + } + response + .tool_calls + .into_iter() + .next() + .ok_or(AdjudicationError::AmbiguousResponse) +} + +fn validate_push( + request: &TurnRequest, + state: &RuntimeState, + record: &CheckRecord, +) -> Result<(), AdjudicationError> { + if !matches!(request.intent, TurnIntent::PushCheck) { + return if record.pushed_from.is_none() { + Ok(()) + } else { + Err(AdjudicationError::PushedCheckMismatch) + }; + } + + let Some(pushed_from) = record.pushed_from.as_deref() else { + return Err(AdjudicationError::PushedCheckMismatch); + }; + let expected = if request.input.trim().is_empty() { + state.checks.iter().rev().find(|check| { + check.pushed_from.is_none() + && matches!(check.result, CheckResult::Failure | CheckResult::Fumble) + }) + } else { + state + .checks + .iter() + .find(|check| check.id == request.input.trim()) + }; + let Some(original) = expected else { + return Err(AdjudicationError::PushedCheckMismatch); + }; + + if original.id != pushed_from + || original.pushed_from.is_some() + || !matches!(original.result, CheckResult::Failure | CheckResult::Fumble) + || original.actor != record.actor + || normalize_skill(&original.skill) != normalize_skill(&record.skill) + || original.target != record.target + || original.difficulty != record.difficulty + || original.bonus_dice != record.bonus_dice + { + return Err(AdjudicationError::PushedCheckMismatch); + } + Ok(()) +} + +#[must_use] +pub fn deterministic_roll(story_id: &str, branch_id: &str, action_id: &str, check_id: &str) -> u8 { + let identity = format!("{story_id}\0{branch_id}\0{action_id}\0{check_id}"); + let digest = stable_json_hash(identity.as_bytes()); + let hex = digest.trim_start_matches("sha256:"); + let prefix = hex.bytes().take(16).fold(0_u64, |value, digit| { + value + .wrapping_mul(16) + .wrapping_add(u64::from(hex_nibble(digit))) + }); + u8::try_from(prefix % 100 + 1).unwrap_or(100) +} + +#[must_use] +pub const fn classify_roll(roll: u8, target: u8) -> CheckResult { + if roll == 1 { + CheckResult::CriticalSuccess + } else if roll == 100 || (target < 50 && roll >= 96) { + CheckResult::Fumble + } else if roll <= target / 5 { + CheckResult::ExtremeSuccess + } else if roll <= target / 2 { + CheckResult::HardSuccess + } else if roll <= target { + CheckResult::Success + } else { + CheckResult::Failure + } +} + +fn normalize_skill(skill: &str) -> String { + skill.trim().to_lowercase() +} + +const fn hex_nibble(value: u8) -> u8 { + match value { + b'0'..=b'9' => value - b'0', + b'a'..=b'f' => value - b'a' + 10, + b'A'..=b'F' => value - b'A' + 10, + _ => 0, + } +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, VecDeque}; + + use nana_domain::{ + AcquisitionMode, BeatKind, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, + ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, PresentationBeat, + PresentationCharacter, PresentationScene, PresentationSnapshot, RelationshipAxes, + ResourceHeader, ResourceId, ResourceKind, SkillValue, StateDelta, StateOp, TurnIntent, + VisualDirective, + }; + + use super::*; + + #[derive(Debug)] + struct ScriptedModel { + responses: VecDeque>, + begins: usize, + outcomes: Vec, + } + + impl ScriptedModel { + fn new( + responses: impl IntoIterator>, + ) -> Self { + Self { + responses: responses.into_iter().collect(), + begins: 0, + outcomes: Vec::new(), + } + } + } + + impl AdjudicationModel for ScriptedModel { + fn respond( + &mut self, + input: AdjudicationModelInput<'_>, + ) -> Result { + match input { + AdjudicationModelInput::BeginTurn { request, state } => { + self.begins += 1; + assert_eq!(request.expected_node_id, state.current_node); + } + AdjudicationModelInput::CheckResolved(outcome) => { + self.outcomes.push(outcome.clone()); + } + } + self.responses + .pop_front() + .unwrap_or(Err(ProviderError::FixtureExhausted)) + } + } + + fn header(id: &str, kind: ResourceKind) -> ResourceHeader { + ResourceHeader { + id: ResourceId(id.into()), + kind, + schema_version: 1, + revision: "1".into(), + content_hash: format!("sha256:{id}"), + dependencies: Vec::new(), + } + } + + fn persona(id: &str, skills: &[(&str, u8)]) -> Persona { + Persona { + header: header(id, ResourceKind::Persona), + name: id.into(), + identity: "investigator".into(), + traits: Vec::new(), + skills: skills + .iter() + .map(|(skill, value)| SkillValue { + skill: (*skill).into(), + value: *value, + }) + .collect(), + initial_items: Vec::new(), + } + } + + fn character(id: &str, skills: &[(&str, u8)]) -> CharacterCard { + CharacterCard { + header: header(id, ResourceKind::Character), + name: id.into(), + identity: "character".into(), + personality: Vec::new(), + values: Vec::new(), + boundaries: Vec::new(), + style: CharacterStyle { + speech: String::new(), + mannerisms: Vec::new(), + forbidden_player_assumptions: Vec::new(), + }, + initial_relationship: RelationshipAxes::neutral(), + judgment_rules: Vec::new(), + skills: skills + .iter() + .map(|(skill, value)| SkillValue { + skill: (*skill).into(), + value: *value, + }) + .collect(), + default_expression: "neutral".into(), + } + } + + fn item_spec(id: &str, modifier: Option) -> ItemSpec { + ItemSpec { + header: header(id, ResourceKind::ItemSpec), + name: id.into(), + description: String::new(), + tags: Vec::new(), + lore_refs: Vec::new(), + mechanics: ItemMechanics { + usable: true, + grants_tags: Vec::new(), + check_modifier: modifier, + }, + hidden_facts: Vec::new(), + } + } + + fn item(id: &str, spec: &str, holder: &str, placement: ItemPlacement) -> ItemInstance { + ItemInstance { + id: id.into(), + spec_ref: ResourceId(spec.into()), + owner: holder.into(), + holder: holder.into(), + placement, + quantity: 1, + condition: "intact".into(), + state_tags: Vec::new(), + acquisition: ItemAcquisition { + mode: AcquisitionMode::Initial, + from: None, + at_node: "node_1".into(), + }, + } + } + + fn catalog() -> AdjudicationCatalog { + AdjudicationCatalog::from_resources( + &[persona("player", &[("Spot Hidden", 55)])], + &[character("nana", &[("Listen", 40)])], + &[item_spec("magnifier", Some(15)), item_spec("coin", None)], + ) + .expect("valid trusted resources") + } + + fn state() -> RuntimeState { + RuntimeState { + story_id: "story_1".into(), + current_node: "node_1".into(), + current_branch: "branch_main".into(), + world_flags: BTreeMap::new(), + relationships: BTreeMap::new(), + relationship_states: Vec::new(), + promises: Vec::new(), + knowledge: Vec::new(), + items: vec![item("lens_1", "magnifier", "player", ItemPlacement::Hand)], + clocks: Vec::new(), + checks: Vec::new(), + } + } + + fn request(intent: TurnIntent) -> TurnRequest { + TurnRequest { + story_id: "story_1".into(), + branch_id: "branch_main".into(), + expected_node_id: "node_1".into(), + action_id: "action_2".into(), + intent, + input: "Inspect the timetable.".into(), + } + } + + fn hidden_check(id: &str) -> HiddenCheckRequest { + HiddenCheckRequest { + check_id: id.into(), + actor_id: "player".into(), + skill: "Spot Hidden".into(), + difficulty: CheckDifficulty::Regular, + item_ids: vec!["lens_1".into()], + pushed_from: None, + } + } + + fn plan() -> TurnPlan { + TurnPlan { + committed_node_id: "node_2".into(), + presentation: PresentationSnapshot { + scene: PresentationScene { + id: "station".into(), + title: "Old Station".into(), + }, + character: PresentationCharacter { + id: "nana".into(), + name: "Nana".into(), + expression: Some("watchful".into()), + pose: None, + }, + beats: vec![PresentationBeat { + id: "beat_1".into(), + kind: BeatKind::Narration, + speaker: None, + text: "A faded mark becomes visible.".into(), + visual: Some(VisualDirective { + character: None, + expression: None, + pose: None, + scene: None, + }), + }], + suggestions: Vec::new(), + can_continue: true, + }, + delta: StateDelta { ops: Vec::new() }, + } + } + + #[allow(clippy::unnecessary_wraps)] + fn tool(call: AdjudicationToolCall) -> Result { + Ok(AdjudicationModelResponse::tool(call)) + } + + fn recorded_check(plan: &TurnPlan) -> &CheckRecord { + plan.delta + .ops + .iter() + .find_map(|op| match op { + StateOp::RecordCheck { check } => Some(check), + _ => None, + }) + .expect("engine-created check") + } + + #[test] + fn deterministic_roll_is_replayable_and_identity_bound() { + let first = deterministic_roll("story", "branch", "action", "check"); + assert_eq!( + first, + deterministic_roll("story", "branch", "action", "check") + ); + assert!((1..=100).contains(&first)); + assert_ne!( + first, + deterministic_roll("story", "branch", "other-action", "check") + ); + } + + #[test] + fn coc7_result_tiers_and_fumbles_are_classified() { + assert_eq!(classify_roll(1, 60), CheckResult::CriticalSuccess); + assert_eq!(classify_roll(12, 60), CheckResult::ExtremeSuccess); + assert_eq!(classify_roll(13, 60), CheckResult::HardSuccess); + assert_eq!(classify_roll(30, 60), CheckResult::HardSuccess); + assert_eq!(classify_roll(31, 60), CheckResult::Success); + assert_eq!(classify_roll(60, 60), CheckResult::Success); + assert_eq!(classify_roll(61, 60), CheckResult::Failure); + assert_eq!(classify_roll(96, 60), CheckResult::Failure); + assert_eq!(classify_roll(96, 40), CheckResult::Fumble); + assert_eq!(classify_roll(100, 99), CheckResult::Fumble); + } + + #[test] + fn engine_resolves_trusted_skill_and_item_modifier_then_records_once() { + let original = state(); + let model = ScriptedModel::new([ + tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check( + "check_1", + ))), + tool(AdjudicationToolCall::SubmitTurnPlan(plan())), + ]); + let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog()); + + let planned = provider + .plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &original) + .expect("check then final plan"); + let check = recorded_check(&planned); + + assert_eq!(check.target, 70); + assert_eq!( + check.roll, + deterministic_roll("story_1", "branch_main", "action_2", "check_1") + ); + assert_eq!(check.node_id, "node_2"); + assert_eq!(check.action_id, "action_2"); + assert_eq!(check.bonus_dice, 0); + assert_eq!(original, state(), "loop must not mutate or persist state"); + + let model = provider.into_model(); + assert_eq!(model.begins, 1); + assert_eq!( + model.outcomes, + vec![QualitativeCheckOutcome { + check_id: "check_1".into(), + result: check.result, + pushed: false, + }] + ); + let visible_outcome = + serde_json::to_value(&model.outcomes[0]).expect("qualitative outcome serializes"); + assert!(visible_outcome.get("roll").is_none()); + assert!(visible_outcome.get("target").is_none()); + assert!(visible_outcome.get("difficulty").is_none()); + } + + #[test] + fn multiple_distinct_checks_are_buffered_until_one_final_plan() { + let mut second = hidden_check("check_2"); + second.actor_id = "nana".into(); + second.skill = "Listen".into(); + second.item_ids.clear(); + let model = ScriptedModel::new([ + tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check( + "check_1", + ))), + tool(AdjudicationToolCall::RequestHiddenCheck(second)), + tool(AdjudicationToolCall::SubmitTurnPlan(plan())), + ]); + let mut provider = AdjudicatingTurnPlanProvider::new(model, catalog()); + + let planned = provider + .plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()) + .expect("two checks then one plan"); + let ids = planned + .delta + .ops + .iter() + .filter_map(|op| match op { + StateOp::RecordCheck { check } => Some(check.id.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(ids, ["check_1", "check_2"]); + } + + #[test] + fn unknown_actors_and_skills_are_rejected() { + let mut unknown_actor = hidden_check("check_1"); + unknown_actor.actor_id = "intruder".into(); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck( + unknown_actor, + ))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::UnknownActor(_) + )) + )); + + let mut unknown_skill = hidden_check("check_1"); + unknown_skill.skill = "Occult".into(); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck( + unknown_skill, + ))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::UnknownSkill { .. } + )) + )); + } + + #[test] + fn item_modifiers_require_a_unique_usable_trusted_item() { + let cases = [ + (vec!["missing".into()], state(), "unknown"), + (vec!["lens_1".into(), "lens_1".into()], state(), "duplicate"), + ( + vec!["lens_1".into()], + { + let mut value = state(); + value.items[0].holder = "nana".into(); + value + }, + "unusable", + ), + ( + vec!["lens_1".into()], + { + let mut value = state(); + value.items[0].placement = ItemPlacement::Hidden; + value + }, + "unusable", + ), + ( + vec!["coin_1".into()], + { + let mut value = state(); + value + .items + .push(item("coin_1", "coin", "player", ItemPlacement::Bag)); + value + }, + "no_modifier", + ), + ]; + + for (item_ids, state, expected) in cases { + let mut check = hidden_check("check_1"); + check.item_ids = item_ids; + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck(check))]), + catalog(), + ); + let error = provider + .plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state) + .expect_err("invalid item request"); + match expected { + "unknown" => assert!(matches!( + error, + AdjudicationRunError::Rejected(AdjudicationError::UnknownItem(_)) + )), + "duplicate" => assert!(matches!( + error, + AdjudicationRunError::Rejected(AdjudicationError::DuplicateItem(_)) + )), + "unusable" => assert!(matches!( + error, + AdjudicationRunError::Rejected(AdjudicationError::ItemNotUsable(_)) + )), + "no_modifier" => assert!(matches!( + error, + AdjudicationRunError::Rejected(AdjudicationError::ItemHasNoModifier(_)) + )), + _ => unreachable!(), + } + } + } + + #[test] + fn targets_are_clamped_after_trusted_modifiers() { + let catalog = AdjudicationCatalog::from_resources( + &[persona("player", &[("Low", 2), ("High", 95)])], + &[], + &[ + item_spec("penalty", Some(-10)), + item_spec("bonus", Some(10)), + ], + ) + .expect("catalog"); + let mut state = state(); + state.items = vec![ + item("penalty_1", "penalty", "player", ItemPlacement::Hand), + item("bonus_1", "bonus", "player", ItemPlacement::Hand), + ]; + let low = HiddenCheckRequest { + check_id: "low".into(), + actor_id: "player".into(), + skill: "Low".into(), + difficulty: CheckDifficulty::Regular, + item_ids: vec!["penalty_1".into()], + pushed_from: None, + }; + let high = HiddenCheckRequest { + check_id: "high".into(), + skill: "High".into(), + item_ids: vec!["bonus_1".into()], + ..low.clone() + }; + assert_eq!( + catalog + .resolve_check(&request(TurnIntent::SpeakOrAct), &state, &low) + .expect("low check") + .target, + 1 + ); + assert_eq!( + catalog + .resolve_check(&request(TurnIntent::SpeakOrAct), &state, &high) + .expect("high check") + .target, + 99 + ); + } + + #[test] + fn duplicate_check_ids_are_rejected_from_history_or_current_loop() { + let mut historical_state = state(); + historical_state + .checks + .push(existing_failed_check("check_existing")); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck({ + let mut check = hidden_check("check_existing"); + check.item_ids.clear(); + check + }))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &historical_state), + Err(AdjudicationRunError::Rejected( + AdjudicationError::DuplicateCheckId(_) + )) + )); + + let first = hidden_check("same"); + let second = first.clone(); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([ + tool(AdjudicationToolCall::RequestHiddenCheck(first)), + tool(AdjudicationToolCall::RequestHiddenCheck(second)), + ]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::DuplicateCheckId(_) + )) + )); + } + + #[test] + fn ambiguous_text_or_tool_shapes_are_rejected() { + let responses = [ + AdjudicationModelResponse { + text: Some("I also narrate.".into()), + tool_calls: vec![AdjudicationToolCall::SubmitTurnPlan(plan())], + }, + AdjudicationModelResponse { + text: None, + tool_calls: Vec::new(), + }, + AdjudicationModelResponse { + text: None, + tool_calls: vec![ + AdjudicationToolCall::SubmitTurnPlan(plan()), + AdjudicationToolCall::SubmitTurnPlan(plan()), + ], + }, + ]; + for response in responses { + let mut provider = + AdjudicatingTurnPlanProvider::new(ScriptedModel::new([Ok(response)]), catalog()); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::AmbiguousResponse + )) + )); + } + } + + #[test] + fn model_supplied_record_check_is_always_rejected() { + let mut forged = plan(); + forged.delta.ops.push(StateOp::RecordCheck { + check: existing_failed_check("forged"), + }); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::SubmitTurnPlan(forged))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::ModelSuppliedRecordCheck + )) + )); + } + + #[test] + fn loop_stops_at_the_configured_step_budget() { + let mut provider = AdjudicatingTurnPlanProvider::with_max_steps( + ScriptedModel::new([ + tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check( + "one", + ))), + tool(AdjudicationToolCall::RequestHiddenCheck(hidden_check( + "two", + ))), + ]), + catalog(), + 2, + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::StepBudgetExceeded + )) + )); + } + + #[test] + fn matching_failed_check_can_be_pushed_once() { + let mut state = state(); + state.items.clear(); + state.checks.push(existing_failed_check("failed_1")); + let mut pushed = hidden_check("push_1"); + pushed.item_ids.clear(); + pushed.pushed_from = Some("failed_1".into()); + let mut request = request(TurnIntent::PushCheck); + request.input = "failed_1".into(); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([ + tool(AdjudicationToolCall::RequestHiddenCheck(pushed)), + tool(AdjudicationToolCall::SubmitTurnPlan(plan())), + ]), + catalog(), + ); + + let planned = provider + .plan_adjudicated_turn(&request, &state) + .expect("matching pushed check"); + assert_eq!( + recorded_check(&planned).pushed_from.as_deref(), + Some("failed_1") + ); + assert!(provider.into_model().outcomes[0].pushed); + } + + #[test] + fn pushed_checks_reject_mismatched_reference_actor_skill_or_target() { + let mut base_state = state(); + base_state.checks.push(existing_failed_check("failed_1")); + let mut request = request(TurnIntent::PushCheck); + request.input = "failed_1".into(); + + let mut cases = Vec::new(); + let mut wrong_reference = hidden_check("push_ref"); + wrong_reference.item_ids.clear(); + wrong_reference.pushed_from = Some("other".into()); + cases.push(wrong_reference); + let mut wrong_actor = hidden_check("push_actor"); + wrong_actor.actor_id = "nana".into(); + wrong_actor.skill = "Listen".into(); + wrong_actor.item_ids.clear(); + wrong_actor.pushed_from = Some("failed_1".into()); + cases.push(wrong_actor); + let mut wrong_skill = hidden_check("push_skill"); + wrong_skill.skill = "Spot Hidden".into(); + wrong_skill.item_ids.clear(); + wrong_skill.difficulty = CheckDifficulty::Hard; + wrong_skill.pushed_from = Some("failed_1".into()); + cases.push(wrong_skill); + let mut changed_target = hidden_check("push_target"); + changed_target.pushed_from = Some("failed_1".into()); + cases.push(changed_target); + + for proposed in cases { + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck(proposed))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request, &base_state), + Err(AdjudicationRunError::Rejected( + AdjudicationError::PushedCheckMismatch + )) + )); + } + } + + #[test] + fn push_intent_requires_one_push_and_regular_turn_forbids_one() { + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::SubmitTurnPlan(plan()))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::PushCheck), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::PushedCheckMismatch + )) + )); + + let mut proposed = hidden_check("push_1"); + proposed.pushed_from = Some("failed_1".into()); + let mut provider = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([tool(AdjudicationToolCall::RequestHiddenCheck(proposed))]), + catalog(), + ); + assert!(matches!( + provider.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Rejected( + AdjudicationError::PushedCheckMismatch + )) + )); + } + + #[test] + fn model_failures_are_forwarded_and_rejections_are_redacted_by_provider_trait() { + let mut unavailable = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([Err(ProviderError::Upstream { code: None })]), + catalog(), + ); + assert!(matches!( + unavailable.plan_adjudicated_turn(&request(TurnIntent::SpeakOrAct), &state()), + Err(AdjudicationRunError::Provider(ProviderError::Upstream { + code: None + })) + )); + + let mut rejected = AdjudicatingTurnPlanProvider::new( + ScriptedModel::new([Ok(AdjudicationModelResponse { + text: Some("invalid".into()), + tool_calls: Vec::new(), + })]), + catalog(), + ); + assert!(matches!( + TurnPlanProvider::plan_turn(&mut rejected, &request(TurnIntent::SpeakOrAct), &state()), + Err(ProviderError::InvalidModelOutput { + kind: InvalidModelOutputKind::InvalidPlan + }) + )); + } + + #[test] + fn trusted_catalog_rejects_ambiguous_actor_skill_and_item_definitions() { + assert!(matches!( + AdjudicationCatalog::from_resources( + &[persona("same", &[])], + &[character("same", &[])], + &[] + ), + Err(CatalogError::DuplicateActor(_)) + )); + assert!(matches!( + AdjudicationCatalog::from_resources( + &[persona("player", &[("Listen", 40), (" listen ", 50)])], + &[], + &[] + ), + Err(CatalogError::DuplicateSkill { .. }) + )); + assert!(matches!( + AdjudicationCatalog::from_resources( + &[], + &[], + &[item_spec("same", Some(1)), item_spec("same", Some(2))] + ), + Err(CatalogError::DuplicateItemSpec(_)) + )); + } + + fn existing_failed_check(id: &str) -> CheckRecord { + CheckRecord { + id: id.into(), + action_id: "earlier_action".into(), + actor: "player".into(), + skill: "Spot Hidden".into(), + target: 55, + difficulty: CheckDifficulty::Regular, + bonus_dice: 0, + roll: 80, + result: CheckResult::Failure, + pushed_from: None, + node_id: "node_1".into(), + } + } +} diff --git a/crates/nana-runtime/src/lapp_provider.rs b/crates/nana-runtime/src/lapp_provider.rs index f7d6dba..a9899ce 100644 --- a/crates/nana-runtime/src/lapp_provider.rs +++ b/crates/nana-runtime/src/lapp_provider.rs @@ -37,8 +37,9 @@ return exactly one bare JSON object with the same arguments and no Markdown fenc The result must contain only scene, character, beats, delta, suggestions, and canContinue. Never construct or return PlayerView, hidden reasoning, provider details, credentials, or exact relationship values in -narrative text. Never decide the player's speech, actions, or inner thoughts. State changes are -proposals only; the trusted reducer will validate and commit them."; +narrative text. Never decide the player's speech, actions, or inner thoughts. Never submit a +RecordCheck operation; hidden checks are requested and recorded only through the trusted engine. +State changes are proposals only; the trusted reducer will validate and commit them."; /// Synchronous seam around one non-streaming LAPP chat operation. /// @@ -407,6 +408,11 @@ fn validate_generated_plan(request: &TurnRequest, plan: &TurnPlan) -> Result<(), || presentation.beats.is_empty() || presentation.beats.len() > MAX_BEATS || plan.delta.ops.len() > MAX_STATE_OPS + || plan + .delta + .ops + .iter() + .any(|op| matches!(op, nana_domain::StateOp::RecordCheck { .. })) || presentation.suggestions.len() > MAX_SUGGESTIONS { return Err(invalid_output(InvalidModelOutputKind::InvalidPlan)); @@ -704,6 +710,36 @@ mod tests { )); } + #[test] + fn model_cannot_submit_a_record_check_operation() { + let mut value = plan_value(); + value["delta"]["ops"] = json!([{ + "op": "record_check", + "check": { + "id": "forged_check", + "action_id": "action_2", + "actor": "player", + "skill": "Spot Hidden", + "target": 99, + "difficulty": "regular", + "bonus_dice": 0, + "roll": 1, + "result": "critical_success", + "pushed_from": null, + "node_id": "node_forged" + } + }]); + let executor = ScriptedExecutor::returning(Ok(response(value.to_string(), Vec::new()))); + let mut provider = LappTurnPlanProvider::new(executor); + + assert!(matches!( + provider.plan_turn(&request(), &state()), + Err(ProviderError::InvalidModelOutput { + kind: InvalidModelOutputKind::InvalidPlan + }) + )); + } + #[test] fn upstream_failures_remain_redacted_and_map_to_provider_unavailable() { let executor = ScriptedExecutor::returning(Err(ProviderError::Upstream { diff --git a/crates/nana-runtime/src/lib.rs b/crates/nana-runtime/src/lib.rs index 3ddc8b9..7b26818 100644 --- a/crates/nana-runtime/src/lib.rs +++ b/crates/nana-runtime/src/lib.rs @@ -8,9 +8,16 @@ use nana_engine::{ReduceError, apply_delta}; use nana_store::{StoreError, StoryStore}; use thiserror::Error; +mod adjudication; mod context; mod lapp_provider; +pub use adjudication::{ + AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationError, AdjudicationModel, + AdjudicationModelInput, AdjudicationModelResponse, AdjudicationRunError, AdjudicationToolCall, + CatalogError, DEFAULT_MAX_ADJUDICATION_STEPS, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, + QualitativeCheckOutcome, classify_roll, deterministic_roll, +}; pub use context::{ CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard, ContextCompileError, ContextInventoryItem, ContextJudgmentRule, ContextPersona,