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<String>,
    #[serde(default)]
    pub pushed_from: Option<String>,
}

/// 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<String>,
    pub tool_calls: Vec<AdjudicationToolCall>,
}

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<AdjudicationModelResponse, ProviderError>;
}

#[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),
    #[error("bound actor resource is missing or ambiguous: {0}")]
    BoundActor(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<String, TrustedSkill>,
}

#[derive(Debug, Clone)]
struct TrustedSkill {
    name: String,
    value: u8,
}

#[derive(Debug, Clone, Copy)]
struct TrustedItem {
    usable: bool,
    modifier: Option<i8>,
}

/// 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<String, TrustedActor>,
    items: BTreeMap<String, TrustedItem>,
}

impl AdjudicationCatalog {
    pub fn from_bundle(bundle: &ResourceBundle) -> Result<Self, CatalogError> {
        let persona = select_bound_actor(&bundle.personas, &bundle.entry_persona.0, |persona| {
            &persona.header.id.0
        })?;
        let mut actors = BTreeMap::new();
        insert_actor(
            &mut actors,
            "player",
            persona
                .skills
                .iter()
                .map(|skill| (&skill.skill, skill.value)),
        )?;
        for character in &bundle.characters {
            insert_actor(
                &mut actors,
                &actor_id_from_resource(&character.header.id.0),
                character
                    .skills
                    .iter()
                    .map(|skill| (&skill.skill, skill.value)),
            )?;
        }

        Ok(Self {
            actors,
            items: trusted_items(&bundle.item_specs)?,
        })
    }

    pub fn from_resources(
        personas: &[Persona],
        characters: &[CharacterCard],
        item_specs: &[ItemSpec],
    ) -> Result<Self, CatalogError> {
        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)),
            )?;
        }

        Ok(Self {
            actors,
            items: trusted_items(item_specs)?,
        })
    }

    fn resolve_check(
        &self,
        request: &TurnRequest,
        state: &RuntimeState,
        proposed: &HiddenCheckRequest,
    ) -> Result<CheckRecord, AdjudicationError> {
        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 trusted_item = self
                .items
                .get(&instance.spec_ref.0)
                .ok_or_else(|| AdjudicationError::UnknownItem(item_id.clone()))?;
            if !trusted_item.usable {
                return Err(AdjudicationError::ItemNotUsable(item_id.clone()));
            }
            let item_modifier = trusted_item
                .modifier
                .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: result_for_difficulty(roll, target, proposed.difficulty),
            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: Model,
    catalog: AdjudicationCatalog,
    max_steps: usize,
}

impl<Model> AdjudicatingTurnPlanProvider<Model> {
    #[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<Model: AdjudicationModel> AdjudicatingTurnPlanProvider<Model> {
    pub fn plan_adjudicated_turn(
        &mut self,
        request: &TurnRequest,
        state: &RuntimeState,
    ) -> Result<TurnPlan, AdjudicationRunError> {
        let mut records = Vec::new();
        let mut last_outcome = None;
        let mut check_ids = state
            .checks
            .iter()
            .map(|check| check.id.clone())
            .collect::<BTreeSet<_>>();

        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<Model: AdjudicationModel> TurnPlanProvider for AdjudicatingTurnPlanProvider<Model> {
    fn plan_turn(
        &mut self,
        request: &TurnRequest,
        state: &RuntimeState,
    ) -> Result<TurnPlan, ProviderError> {
        self.plan_adjudicated_turn(request, state)
            .map_err(|error| match error {
                AdjudicationRunError::Provider(error) => error,
                AdjudicationRunError::Rejected(_) => ProviderError::InvalidModelOutput {
                    kind: InvalidModelOutputKind::InvalidPlan,
                },
            })
    }
}

fn select_bound_actor<'a, T>(
    resources: &'a [T],
    expected_id: &str,
    resource_id: impl Fn(&T) -> &String,
) -> Result<&'a T, CatalogError> {
    let matches = resources
        .iter()
        .filter(|resource| resource_id(resource).as_str() == expected_id)
        .collect::<Vec<_>>();
    match matches.as_slice() {
        [selected] => Ok(*selected),
        _ => Err(CatalogError::BoundActor(expected_id.to_owned())),
    }
}

fn actor_id_from_resource(resource_id: &str) -> String {
    resource_id
        .rsplit(['.', '/', ':'])
        .find(|part| !part.is_empty())
        .unwrap_or(resource_id)
        .to_owned()
}

fn trusted_items(item_specs: &[ItemSpec]) -> Result<BTreeMap<String, TrustedItem>, CatalogError> {
    let mut items = BTreeMap::new();
    for item in item_specs {
        let id = item.header.id.0.trim();
        if id.is_empty() {
            return Err(CatalogError::EmptyItemSpecId);
        }
        if items
            .insert(
                id.to_owned(),
                TrustedItem {
                    usable: item.mechanics.usable,
                    modifier: item.mechanics.check_modifier,
                },
            )
            .is_some()
        {
            return Err(CatalogError::DuplicateItemSpec(id.to_owned()));
        }
    }
    Ok(items)
}

fn insert_actor<'a>(
    actors: &mut BTreeMap<String, TrustedActor>,
    actor_id: &str,
    skills: impl Iterator<Item = (&'a String, u8)>,
) -> 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<AdjudicationToolCall, AdjudicationError> {
    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
    }
}

const fn result_for_difficulty(roll: u8, target: u8, difficulty: CheckDifficulty) -> CheckResult {
    let result = classify_roll(roll, target);
    let passed = match result {
        CheckResult::CriticalSuccess | CheckResult::ExtremeSuccess => true,
        CheckResult::HardSuccess => {
            matches!(difficulty, CheckDifficulty::Regular | CheckDifficulty::Hard)
        }
        CheckResult::Success => matches!(difficulty, CheckDifficulty::Regular),
        CheckResult::Failure | CheckResult::Fumble => false,
    };
    if passed {
        result
    } else if matches!(result, CheckResult::Fumble) {
        CheckResult::Fumble
    } 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<Result<AdjudicationModelResponse, ProviderError>>,
        begins: usize,
        outcomes: Vec<QualitativeCheckOutcome>,
    }

    impl ScriptedModel {
        fn new(
            responses: impl IntoIterator<Item = Result<AdjudicationModelResponse, ProviderError>>,
        ) -> Self {
            Self {
                responses: responses.into_iter().collect(),
                begins: 0,
                outcomes: Vec::new(),
            }
        }
    }

    impl AdjudicationModel for ScriptedModel {
        fn respond(
            &mut self,
            input: AdjudicationModelInput<'_>,
        ) -> Result<AdjudicationModelResponse, ProviderError> {
            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<i8>) -> 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<AdjudicationModelResponse, ProviderError> {
        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);
        assert_eq!(
            result_for_difficulty(31, 60, CheckDifficulty::Hard),
            CheckResult::Failure
        );
        assert_eq!(
            result_for_difficulty(30, 60, CheckDifficulty::Hard),
            CheckResult::HardSuccess
        );
        assert_eq!(
            result_for_difficulty(13, 60, CheckDifficulty::Extreme),
            CheckResult::Failure
        );
        assert_eq!(
            result_for_difficulty(12, 60, CheckDifficulty::Extreme),
            CheckResult::ExtremeSuccess
        );
    }

    #[test]
    fn bundle_catalog_maps_bound_persona_and_resource_tail_actor_ids() {
        let persona = persona("generic.persona.traveler", &[("Notice", 55)]);
        let character = character("generic.character.guide", &[("Listen", 40)]);
        let bundle = ResourceBundle {
            id: "generic.bundle".into(),
            schema_version: 1,
            revision: "1".into(),
            display_name: "Generic".into(),
            entry_character: ResourceId("generic.character.guide".into()),
            entry_persona: ResourceId("generic.persona.traveler".into()),
            entry_plot_module: None,
            characters: vec![character],
            world_books: Vec::new(),
            personas: vec![persona],
            plot_modules: Vec::new(),
            item_specs: Vec::new(),
        };
        let catalog = AdjudicationCatalog::from_bundle(&bundle).expect("bundle catalog");
        let state = state();

        let player = HiddenCheckRequest {
            check_id: "player_check".into(),
            actor_id: "player".into(),
            skill: "Notice".into(),
            difficulty: CheckDifficulty::Regular,
            item_ids: Vec::new(),
            pushed_from: None,
        };
        let guide = HiddenCheckRequest {
            check_id: "guide_check".into(),
            actor_id: "guide".into(),
            skill: "Listen".into(),
            difficulty: CheckDifficulty::Regular,
            item_ids: Vec::new(),
            pushed_from: None,
        };

        assert_eq!(
            catalog
                .resolve_check(&request(TurnIntent::SpeakOrAct), &state, &player)
                .expect("player check")
                .target,
            55
        );
        assert_eq!(
            catalog
                .resolve_check(&request(TurnIntent::SpeakOrAct), &state, &guide)
                .expect("guide check")
                .target,
            40
        );
    }

    #[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::<Vec<_>>();
        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 item_spec_must_mark_the_requested_modifier_as_usable() {
        let mut locked = item_spec("locked_tool", Some(10));
        locked.mechanics.usable = false;
        let catalog = AdjudicationCatalog::from_resources(
            &[persona("player", &[("Notice", 50)])],
            &[],
            &[locked],
        )
        .expect("catalog");
        let mut state = state();
        state.items = vec![item(
            "locked_1",
            "locked_tool",
            "player",
            ItemPlacement::Hand,
        )];
        let proposed = HiddenCheckRequest {
            check_id: "locked_check".into(),
            actor_id: "player".into(),
            skill: "Notice".into(),
            difficulty: CheckDifficulty::Regular,
            item_ids: vec!["locked_1".into()],
            pushed_from: None,
        };

        assert!(matches!(
            catalog.resolve_check(
                &request(TurnIntent::SpeakOrAct),
                &state,
                &proposed
            ),
            Err(AdjudicationError::ItemNotUsable(id)) if id == "locked_1"
        ));
    }

    #[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(),
        }
    }
}
