//! # Behavioral Traits — the SDK building blocks
//!
//! Everything an entity can *do* is a trait. A chess pawn has `Movable` (can change
//! position), `Ownable` (belongs to a player), `Tangible` (on the board), and `Featured`
//! (shown in UI). Three rules guide the trait system:
//!
//! 1. **Traits describe behaviors, not concepts.** `Movable` is a trait (a thing you DO).
//!    `ChessPiece` is not — it's just data rules that a plugin reads from traits.
//! 2. **One trait per verb.** If a behavior needs new logic, it earns its own trait.
//!    Don't stuff booleans into existing data.
//! 3. **The host enforces structure, the plugin provides logic.** The host knows how
//!    `Spatial` relates to the board. It doesn't know what makes a "pawn" different
//!    from a "queen" — that's the plugin's job.
//!
//! ## Two trait shapes
//!
//! ### Data traits (most traits)
//! The trait carries information: `NamedData { display_name, icon, description }`,
//! `FeaturedData { display_priority, tags }`, `TangibleData { position }`, etc.
//! `Featured` implies `Named` — every sidebar entry must have display data, but not
//! every entity with display data is a sidebar entry (spells, conditions, items).
//! To add traits to an entity:
//!
//! ```ignore
//! session.spawn(PAWN_TYPE)
//!     .named(NamedData::new("Pawn"))
//!     .featured(FeaturedData::new())            // sidebar entry
//!     .tangible(TangibleData::at(Position::Face(FaceId(8))))
//!     .movable(MovableData::default())
//!     .build();
//! ```
//!
//! Data traits live in per-trait `HashMap<EntityId, XxxData>` on `EntityState` and the
//! bit for the trait is set in `entity_traits`.
//!
//! ## Adding a new trait
//!
//! 1. Define the data (or unit struct for a marker) in this file.
//! 2. Add a bit to the `TraitSet` bitflags below.
//! 3. Add the `HashMap<EntityId, XxxData>` field to `EntityState` (store.rs).
//! 4. Add the `add_xxx()` / `remove_xxx()` mutators on `EntityState` (store.rs).
//! 5. Add the `Option<XxxData>` field to `EntityBlueprint` (blueprint.rs) + builder method.
//! 6. Add the same `Option<XxxData>` field to `EntityView` (store.rs) so clients see it.
//! 7. Update `trait_set()`, `destroy()`, `verify_invariant()`, `apply_event_to_store`.
//! 8. If your trait has cross-trait invariants (e.g. `Featured` ⇒ `Named`), enforce them
//!    in `verify_invariant()` so debug builds catch violations.
//! 9. If your trait has side effects on specific events, register a handler in your
//!    plugin via `reg.on_xxx_react(priority, |event, ctx| {...})`. Traits describe
//!    what the entity IS; handlers describe what happens.
//!
//! ## Featured vs Tangible — a worked example
//!
//! - `Featured`: the entity appears in the sidebar/UI. Players can see it in lists,
//!   inspect it, read its name. Cards in your hand, heroes, resources (# gold, # kids).
//! - `Tangible`: the entity has a position on the board and is rendered there. Pieces,
//!   tokens, placed objects. Tangible carries `TangibleData { position }`.
//!
//! Most pieces are both (a hero is listed in the sidebar AND has a position on the map).
//! A resource tracker (gold counter) is Featured but not Tangible. A card inside a
//! shuffled deck is neither. A faceless terrain hex might be Tangible without being
//! Featured (the map renders it, but it's not a game entity).

use bitflags::bitflags;
use crate::types::{AssetTag, EntityId, PlayerId, PoolId, SeatId};

// ---------------------------------------------------------------------------
// TraitSet bitfield
// ---------------------------------------------------------------------------

bitflags! {
    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct TraitSet: u64 {
        const FEATURED        = 1 << 0;
        const OBSERVABLE      = 1 << 1;
        const OBSCURABLE      = 1 << 2;
        const OWNABLE         = 1 << 3;
        // Bit 4 was SPATIAL — merged into TANGIBLE. TANGIBLE now carries position.
        const MOVABLE         = 1 << 5;
        const CONTAINER       = 1 << 6;
        // Bit 7 was STACKABLE — merged into Container (contents is ordered; stack
        // position = index in container.contents).
        // Bit 8 was UPGRADEABLE — merged into Quantified (level = pool `:level`).
        const QUANTIFIED      = 1 << 9;
        const ACTIVATABLE     = 1 << 10;
        const MORPHABLE       = 1 << 11;
        // TANGIBLE: entity has a board position and is rendered there.
        // TangibleData { position } carries the position — no separate SpatialData.
        const TANGIBLE        = 1 << 12;
        // ATTACHED: entity is attached to a target (conditions, auras, buffs).
        // `AttachedData { target, stacks }` — the attached_to/attachments indices
        // remain the same; this trait makes the relationship first-class.
        const ATTACHED        = 1 << 13;
        // CONNECTOR: entity links two or more other entities (roads, pipes, edges,
        // graph connections). `ConnectorData { connects: Vec<EntityId>, rendering }`
        // — the connected entities define where the connector sits on the board.
        const CONNECTOR       = 1 << 14;
        // NAMED: entity carries display data (display_name, icon, description).
        // `NamedData { display_name, icon, description }`. `FEATURED` implies `NAMED`
        // — anything with sidebar visibility must also be Named, but Named entities
        // (spells, conditions, items) need not be Featured.
        const NAMED           = 1 << 15;
        // SUSTAINED: entity represents a sustained effect (concentration, an
        // ongoing performance, a song). `SustainedData { group }` ties the
        // entity to a `SustainGroup`. The host enforces mutual exclusivity:
        // creating a new `Sustained` entity in the same `(group, owner)` pair
        // auto-destroys the previous one (cascaded through the pipeline so its
        // on_destroyed handlers fire).
        const SUSTAINED       = 1 << 19;
        // TEMPORAL: entity has a finite duration that the host automatically
        // ticks down at a chosen lifecycle event. `TemporalData { remaining,
        // max_duration, tick_on }`. When `remaining` reaches 0 the host
        // synthesizes `EntityDestroyed` (and `Detached` if the entity is
        // attached) so on_destroyed handlers fire normally. Replaces the
        // manual on_phase_start tick handler that condition plugins used to
        // write. See S05.
        const TEMPORAL        = 1 << 18;
        // RANGED: entity carries a range constraint that the host enforces on
        // activation events. `RangedData { range: RangeKind }`. Pairs with
        // `Activatable` — when an `Activated` event names an `Activatable +
        // Ranged` entity, the host validates the distance from the activator
        // to the target before the event commits. See S03.
        const RANGED          = 1 << 16;
        // AREAL: entity carries an `AreaShape` describing the spatial extent
        // it affects (radius / square / line / cone). `ArealData { shape:
        // AreaShape }`. Plugins consult `GameContext::entities_in_area` (and
        // its directional sibling) to enumerate the entities a given Areal
        // entity touches when activated at a position. The host does NOT
        // automatically apply effects — the trait is a query helper that
        // unifies the "what entities are inside this AoE?" question across
        // games. See S04.
        const AREAL           = 1 << 17;
        // GATED: entity carries a `Predicate` describing when it is offered /
        // selectable / activatable. `GatedData { when }`. The host evaluates
        // the predicate against the current store and exposes `explain`
        // output for client UIs (greying out unselectable options and
        // surfacing replacement candidates). Plugins typically declare
        // gates via the `gate!` proc-macro that lowers to `Predicate`.
        const GATED           = 1 << 20;
        // S26 — Perceivability model (2026-05-04)
        // Marker bits — set/cleared via TraitAdded/TraitRemoved; no backing data struct.
        // Ownership shortcut: owner always gets all flags regardless of these bits.
        /// Entity existence is detectable by non-owners (entity appears in view as a shell).
        const PRESENT         = 1 << 21;
        /// Board position is knowable by non-owners (TangibleData included in shell view).
        const LOCATED         = 1 << 22;
        /// SenseData trait: entity grants perceivability overrides to its owner.
        /// Always paired with ATTACHED. `SenseData { can_override, target }`.
        const SENSE           = 1 << 23;
        // Bits 24-63 reserved for upcoming traits (TS1 widened backing
        // u32 → u64 on 2026-05-09; ~5 years of headroom at current cadence).
    }
}

impl Default for TraitSet {
    fn default() -> Self {
        Self::empty()
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for TraitSet {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        // TS1 (2026-05-09): widened to u64 for ~5 years of headroom on
        // new trait bits. The wire format hash bumps automatically;
        // pre-TS1 clients/snapshots are wire-incompatible (1.0 hadn't
        // shipped yet, so no migration cost).
        s.serialize_u64(self.bits())
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for TraitSet {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let bits = u64::deserialize(d)?;
        Ok(TraitSet::from_bits_truncate(bits))
    }
}

// Convenience composite constants
pub const HERO: TraitSet = TraitSet::from_bits_truncate(
    TraitSet::FEATURED.bits()
        | TraitSet::NAMED.bits()
        | TraitSet::OBSERVABLE.bits()
        | TraitSet::OWNABLE.bits()
        | TraitSet::TANGIBLE.bits()
        | TraitSet::MOVABLE.bits()
        | TraitSet::QUANTIFIED.bits(),
);

/// A condition is a Named + Attached + Quantified entity — e.g. "poisoned (3 turns)".
/// Conditions are *not* Featured (they don't appear in the sidebar as top-level
/// entries), but they have display data (name, icon) so cross-system handlers can
/// render condition badges via the `Named` trait.
pub const CONDITION: TraitSet = TraitSet::from_bits_truncate(
    TraitSet::NAMED.bits()
        | TraitSet::ATTACHED.bits()
        | TraitSet::QUANTIFIED.bits(),
);

// Passive modifiers are purely a Registrar pattern: register
// `on_pool_changed_modify` and self-filter by the attached entity's target.

// ---------------------------------------------------------------------------
// Shared OnX types
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetScope {
    Entity(EntityId),
    Owner(SeatId),
    Opponent,
    Any,
    InRange(EntityId, u32),
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PipelineRole {
    Filter,
    Modify,
    React,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnScope {
    Mine,
    Any,
    Specific(SeatId),
}

// ---------------------------------------------------------------------------
// Resource pool types
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolType {
    Depletable,
    Accumulating,
    Bidirectional,
    Threshold,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolDisplay {
    Bar,
    Boxes(u32),
    Numeric,
    Pips,
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct ResourcePool {
    pub id: PoolId,
    pub current: i64,
    pub max: Option<i64>,
    pub min: i64,
    pub pool_type: PoolType,
    pub display: PoolDisplay,
}

impl ResourcePool {
    pub fn depletable(id: PoolId, current: i64, max: i64) -> Self {
        Self {
            id,
            current,
            max: Some(max),
            min: 0,
            pool_type: PoolType::Depletable,
            display: PoolDisplay::Bar,
        }
    }

    pub fn accumulating(id: PoolId) -> Self {
        Self {
            id,
            current: 0,
            max: None,
            min: 0,
            pool_type: PoolType::Accumulating,
            display: PoolDisplay::Numeric,
        }
    }

    pub fn with_display(mut self, display: PoolDisplay) -> Self {
        self.display = display;
        self
    }

    pub fn with_current(mut self, current: i64) -> Self {
        self.current = current;
        self
    }

    pub fn fraction(&self) -> f32 {
        match self.max {
            Some(max) if max > self.min => {
                (self.current - self.min) as f32 / (max - self.min) as f32
            }
            _ => 1.0,
        }
    }
}

// ---------------------------------------------------------------------------
// Morph types
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MorphCondition {
    Automatic,            // host applies morph when trigger fires (e.g. back-rank in checkers)
    PlayerChoice,         // player picks which form via Suspension (e.g. chess promotion)
    PoolDepleted(PoolId), // pool hitting min triggers the morph
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TraitChangeSet {
    pub set_named:       Option<NamedData>,
    pub set_sustained:   Option<SustainedData>,
    pub set_temporal:    Option<TemporalData>,
    pub set_ranged:      Option<RangedData>,
    pub set_areal:       Option<ArealData>,
    pub set_featured:    Option<FeaturedData>,
    pub set_observable:  Option<ObservableData>,
    pub set_ownable:     Option<OwnableData>,
    pub set_tangible:    Option<TangibleData>,
    pub set_movable:     Option<MovableData>,
    pub set_quantified:  Option<QuantifiedData>,
    pub set_activatable: Option<ActivatableData>,
    pub set_morphable:   Option<MorphableData>,
    pub remove_named:       bool,
    pub remove_sustained:   bool,
    pub remove_temporal:    bool,
    pub remove_ranged:      bool,
    pub remove_areal:       bool,
    pub remove_featured:    bool,
    pub remove_morphable:   bool,
    pub remove_activatable: bool,
    pub remove_movable:     bool,
    pub remove_observable:  bool,
    pub remove_tangible:    bool,
}

impl TraitChangeSet {
    pub fn remove_morphable() -> Self {
        Self { remove_morphable: true, ..Default::default() }
    }

    /// Compute the trait diff from a `from` blueprint to a `to` blueprint.
    ///
    /// For each trait: if `to` has it → `set_X = Some(...)`; if `from` has it
    /// and `to` doesn't → `remove_X = true`. Unchanged traits produce nothing.
    ///
    /// Pairs naturally with `#[derive(EntityType)]`:
    ///
    /// ```ignore
    /// #[derive(EntityType)] struct Settlement { owner: SeatId, position: Position }
    /// #[derive(EntityType)] struct City       { owner: SeatId, position: Position, /* extra traits */ }
    ///
    /// impl From<Settlement> for City {
    ///     fn from(s: Settlement) -> Self { City { owner: s.owner, position: s.position } }
    /// }
    ///
    /// // In the MorphForm declaration:
    /// let changes = TraitChangeSet::diff(&settlement.blueprint(), &city.blueprint());
    /// ```
    pub fn diff(from: &crate::blueprint::EntityBlueprint, to: &crate::blueprint::EntityBlueprint) -> Self {
        let mut cs = Self::default();

        macro_rules! diff_with_remove {
            ($field:ident, $set:ident, $remove:ident) => {
                match (&from.$field, &to.$field) {
                    (_, Some(d)) => { cs.$set = Some(d.clone()); }
                    (Some(_), None) => { cs.$remove = true; }
                    (None, None) => {}
                }
            };
        }
        macro_rules! diff_set_only {
            ($field:ident, $set:ident) => {
                if let Some(d) = &to.$field {
                    cs.$set = Some(d.clone());
                }
            };
        }

        diff_with_remove!(named,       set_named,       remove_named);
        diff_with_remove!(sustained,   set_sustained,   remove_sustained);
        diff_with_remove!(temporal,    set_temporal,    remove_temporal);
        diff_with_remove!(ranged,      set_ranged,      remove_ranged);
        diff_with_remove!(areal,       set_areal,       remove_areal);
        diff_with_remove!(featured,    set_featured,    remove_featured);
        // observable is a bool marker in blueprint; perceivability_overrides hold per-seat data
        {
            let from_active = from.observable || !from.perceivability_overrides.is_empty();
            let to_active   = to.observable   || !to.perceivability_overrides.is_empty();
            if to_active {
                let mut obs = ObservableData::default();
                for (seat, flags) in &to.perceivability_overrides {
                    obs.set_perceivability(*seat, *flags);
                }
                cs.set_observable = Some(obs);
            } else if from_active {
                cs.remove_observable = true;
            }
        }
        diff_with_remove!(tangible,    set_tangible,    remove_tangible);
        diff_with_remove!(movable,     set_movable,     remove_movable);
        diff_with_remove!(morphable,   set_morphable,   remove_morphable);
        diff_with_remove!(activatable, set_activatable, remove_activatable);
        // Fields without a remove_X bool flag:
        diff_set_only!(ownable,    set_ownable);
        diff_set_only!(quantified, set_quantified);

        cs
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct MorphForm {
    pub name: String,
    pub condition: MorphCondition,
    pub trait_changes: TraitChangeSet,
}

// ---------------------------------------------------------------------------
// TargetMode (for Activatable)
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetMode {
    Immediate,
    SelectEntity,
    SelectPosition,
}

// ---------------------------------------------------------------------------
// Container type
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerType {
    Bag,
    Ordered,
    Slot,
}

// ---------------------------------------------------------------------------
// Movement type
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovementType {
    Step,
    Slide,
    Jump,
    Teleport,
    Custom,
}

// ---------------------------------------------------------------------------
// Trait data structs — Identity & Presentation
// ---------------------------------------------------------------------------

/// **Named** — "this entity carries display data."
///
/// Holds the human-readable identity used by both the sidebar and inline UI:
/// `display_name`, an optional `icon` ([`AssetTag`]), and a `description`. Decoupled
/// from sidebar visibility — spells, conditions, items, and buffs all want display
/// data without being top-level sidebar entries. Cross-system handlers query
/// `TraitSet::NAMED` to find anything renderable.
///
/// `Featured` implies `Named`. The reverse is *not* true: a condition like
/// "Poisoned (3 turns)" is `NAMED + ATTACHED + QUANTIFIED` — it has a name and an
/// icon, but it is never a top-level sidebar entry. The sidebar filter is
/// `view.featured.is_some()`; the "has display data" filter is `view.named.is_some()`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct NamedData {
    pub display_name: String,
    pub icon: Option<AssetTag>,
    pub description: String,
}

impl NamedData {
    pub fn new(display_name: impl Into<String>) -> Self {
        Self {
            display_name: display_name.into(),
            icon: None,
            description: String::new(),
        }
    }

    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
        self.icon = Some(AssetTag::new(icon));
        self
    }

    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = description.into();
        self
    }
}

/// A logical bucket of sustained effects that are mutually exclusive within an
/// owner. Two `SustainedData` entities sharing the same `(group, owner)` cannot
/// coexist — the host destroys the older one when the newer one is created.
///
/// Equality and hashing are by the `&'static str` slug. Use the SDK-provided
/// constants for cross-game vocabulary (`CONCENTRATION`, `PERFORMANCE`); plugins
/// may define their own with `SustainGroup("dnd5e:reaction")`.
///
/// Wire serialization is by slug (a plain string); deserialization leaks the
/// slug into static memory to honor the `&'static str` invariant. Sustain
/// groups are a closed, small set in any given game, so this leak is a
/// once-per-distinct-slug cost, not unbounded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SustainGroup(pub &'static str);

#[cfg(feature = "serde")]
impl serde::Serialize for SustainGroup {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(self.0)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for SustainGroup {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        // Leak the slug into static memory so the `&'static str` contract holds.
        // Sustain group slugs are a small closed set; the leak is bounded.
        Ok(SustainGroup(Box::leak(s.into_boxed_str())))
    }
}

/// D&D-style spell concentration. One concentrated spell per caster.
pub const CONCENTRATION: SustainGroup = SustainGroup("concentration");

/// Bardic-style ongoing performance / song. One performance per performer.
pub const PERFORMANCE: SustainGroup = SustainGroup("performance");

/// **Sustained** — "this entity is an active sustained effect bound to its
/// owner."
///
/// Adds concentration / exclusivity semantics: at most one entity per
/// `(group, owner)` exists at any time. Creating a new `Sustained` entity in a
/// group already occupied by the same owner causes the host to synthesize an
/// `EntityDestroyed` for the previous one *before* the new entity lands. The
/// destroy cascades through the pipeline so the previous effect's
/// `on_destroyed_react` handlers fire normally — letting plugins clean up
/// modifiers, timers, conditions, etc.
///
/// Two distinct owners can each carry their own concentration without
/// interference. Two distinct groups (e.g. `CONCENTRATION` + `PERFORMANCE`)
/// coexist on the same owner.
///
/// Sustained pairs naturally with [`OwnableData`] — the host needs a known
/// owner to scope the exclusivity check. A `Sustained` entity without an
/// `Owner` is permitted but is never auto-destroyed by the host.
///
/// **Out of scope** (handler land): break-on-damage, save-to-maintain, and
/// other mechanics that *trigger* the end of a sustain. Those plugins should
/// emit `EntityDestroyed` for the relevant sustained entity from their own
/// `on_pool_changed_react` (or similar) handlers.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SustainedData {
    pub group: SustainGroup,
}

impl SustainedData {
    pub fn new(group: SustainGroup) -> Self {
        Self { group }
    }
}

/// Range constraint for an `Activatable + Ranged` entity.
///
/// Equality semantics are by-value; `Distance(n)` and `Distance(m)` differ for
/// `n != m`. The host validates the distance from the activator's
/// `TangibleData.position` to the target before committing an `Activated`
/// event.
///
/// **Distance metric** (v1):
/// - On grid-encoded `Position::Face` targets, the host uses Manhattan
///   distance over the (col, row) coords registered on the `BoardGraph`
///   (8 columns; matches `BoardBuilder::square_grid(8, 8)`). Boards wider
///   than 8 cols still produce stable but pessimistic distances — fine for
///   v1, refinable when the host knows the true `(cols, rows)`.
/// - On `Position::Node` / `Edge` targets, the host falls back to
///   "0 if equal else 1": positions are either co-located or "adjacent" for
///   purposes of Touch/Distance(1). True graph-shortest-path range is
///   future work; plugins that need it use a Custom precondition today.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RangeKind {
    /// Adjacency only — distance must be ≤ 1.
    Touch,
    /// Distance up to and including `n` cells/hops.
    Distance(u32),
    /// No range constraint; the host always allows the activation.
    Unlimited,
    /// The activation must target the activator itself. The host requires
    /// `target == Some(activator)`. Activations with no target are denied.
    Self_,
}

/// **Ranged** — "this entity has a range constraint that the host enforces on
/// activation."
///
/// Pairs with [`ActivatableData`]. When an `Activated { entity, activator,
/// target, .. }` event names an entity with both `Activatable` and `Ranged`,
/// the host validates the distance from the activator's `TangibleData.position`
/// to the target's `TangibleData.position` (resolved through the `target`
/// EntityId). If the distance exceeds the declared range, the host denies the
/// event with reason `"out of range (X > Y)"`.
///
/// **Self_ semantics:** the activation `target` must equal the `activator`.
/// `target = None` is treated as "no target" and denied.
///
/// **Touch:** distance must be ≤ 1.
///
/// **Unlimited:** no validation. Useful when range exists logically but is
/// effectively boundless (e.g. a "sense any willing creature" spell), letting
/// the client still draw a "global" targeting overlay.
///
/// **Out of scope:** line-of-sight, cover, terrain costs, and graph-shortest-
/// path for `Position::Node`/`Edge` topologies. Plugins that need those
/// continue to use a custom `Predicate` in `Activatable::when` or their own
/// filter handlers; the `Ranged` trait is intentionally the simple v1
/// distance check.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RangedData {
    pub range: RangeKind,
    /// AR-1: per-ability override of the distance metric used to evaluate
    /// `RangeKind::Touch`/`Distance(n)`. Falls back to
    /// `SessionConfig.default_distance_metric` if `None`. If neither is
    /// set, the pipeline denies the action with a typed error — distance
    /// without a metric is nonsensical.
    #[cfg_attr(feature = "serde", serde(default))]
    pub metric: Option<crate::distance::DistanceMetric>,
}

impl RangedData {
    pub fn new(range: RangeKind) -> Self { Self { range, metric: None } }
    pub fn touch() -> Self { Self { range: RangeKind::Touch, metric: None } }
    pub fn distance(n: u32) -> Self { Self { range: RangeKind::Distance(n), metric: None } }
    pub fn unlimited() -> Self { Self { range: RangeKind::Unlimited, metric: None } }
    pub fn self_only() -> Self { Self { range: RangeKind::Self_, metric: None } }

    /// AR-1: builder method to set the distance metric for this ability.
    /// Overrides `SessionConfig.default_distance_metric`.
    pub fn with_metric(mut self, m: crate::distance::DistanceMetric) -> Self {
        self.metric = Some(m);
        self
    }
}

/// Spatial shape carried by an `Areal` entity. Describes the area-of-effect
/// footprint the entity covers when applied at a center / origin position.
///
/// **Distance metrics** (matched to `GameContext::entities_in_area`):
/// - `Radius(n)`: includes any entity whose Manhattan distance from the
///   center is `<= n` (matches the same metric used by `Ranged` / S03).
/// - `Square(n)`: Chebyshev distance — `max(|dx|, |dy|) <= n` from center.
///   Distinct from `Radius` (e.g. a 1-square AoE covers the 3x3 box around
///   the center, including diagonals, where Radius(1) excludes diagonals).
/// - `Line { length, width }`: from an origin pointed at a target, a
///   rectangle `length` cells along the direction with `width` cells
///   perpendicular tolerance. Requires a direction — see
///   `GameContext::entities_in_directional_area`.
/// - `Cone { length, angle_degrees }`: from an origin pointed at a target,
///   a fan of length `length` with total opening `angle_degrees`
///   (half-angle = `angle_degrees / 2`). Requires a direction.
///
/// Radius / Square are **center-anchored** (no direction). Line / Cone are
/// **directional** (need an origin and a target). The two helper methods on
/// `GameContext` reflect that split.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AreaShape {
    /// Manhattan-radius circle of radius `n`.
    Radius(u32),
    /// Chebyshev-square: a `(2n+1) x (2n+1)` box centered on the anchor.
    Square(u32),
    /// Origin-anchored rectangle `length` along the direction with `width`
    /// perpendicular tolerance. Requires a target to define direction.
    Line { length: u32, width: u32 },
    /// Origin-anchored fan of `length` cells with total opening
    /// `angle_degrees`. Requires a target to define direction.
    Cone { length: u32, angle_degrees: u32 },
}

/// **Areal** — "this entity carries a spatial extent that the host can
/// enumerate."
///
/// Often paired with `Activatable` and (for directional shapes) `Ranged`,
/// but neither pairing is required: a passive aura entity might be just
/// `Featured + Tangible + Areal`.
///
/// The host does NOT automatically apply effects to entities inside the
/// area. The trait exists to unify the "what entities sit inside this AoE?"
/// question across games. Plugins call
/// `ctx.entities_in_area(center, &shape)` (or its directional sibling for
/// `Line` / `Cone`) inside an `on_activated_react` (or any other handler) to
/// enumerate targets — and then emit per-target events themselves.
///
/// **Distance metric note** (v1):
/// - `Radius` reuses the same Manhattan metric as `Ranged` for `Position::Face`
///   targets (8-col grid encoding) so the two traits compose without
///   surprises.
/// - `Square` uses Chebyshev distance — the metric "square AoE" actually
///   wants on a grid.
/// - `Line` / `Cone` require a direction and are best on grid-encoded
///   positions; on `Position::Node` / `Edge` targets they degrade to the
///   coarse "0 or 1" approximation `Ranged` uses.
///
/// **Out of scope:** terrain blocking, line-of-sight, wall occlusion.
/// Plugins layer those as filter handlers on top of the entity list.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ArealData {
    pub shape: AreaShape,
}

impl ArealData {
    pub fn new(shape: AreaShape) -> Self { Self { shape } }
    pub fn radius(n: u32) -> Self { Self { shape: AreaShape::Radius(n) } }
    pub fn square(n: u32) -> Self { Self { shape: AreaShape::Square(n) } }
    pub fn line(length: u32, width: u32) -> Self {
        Self { shape: AreaShape::Line { length, width } }
    }
    pub fn cone(length: u32, angle_degrees: u32) -> Self {
        Self { shape: AreaShape::Cone { length, angle_degrees } }
    }
}

/// Lifecycle event at which a `Temporal` entity's `remaining` counter is
/// decremented by the host. Choosing the correct trigger is a per-effect
/// decision: a poison condition that ticks at the start of the victim's turn
/// uses `TargetTurnStart`; a damage-over-time effect that ticks when the
/// caster's turn starts uses `CasterTurnStart`; an end-of-round area effect
/// uses `RoundEnd`.
///
/// All variants are `Copy + Eq + Hash` so they can be used freely as map keys
/// or in pattern matches.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TickTrigger {
    /// Decrement at the start of the *target's* turn — i.e. the entity that
    /// the Temporal is attached to (via `AttachedData.target`). Requires the
    /// entity to also be `Attached`. Standard "poisoned for 3 turns" pattern.
    TargetTurnStart,
    /// Decrement at the end of the *target's* turn. Same constraint as
    /// `TargetTurnStart`.
    TargetTurnEnd,
    /// Decrement when the *caster's* (owner's) turn starts. v1 maps "caster"
    /// to `OwnableData.owner` — the entity that produced the effect must own
    /// the Temporal entity. Without an owner the host never ticks this
    /// variant.
    CasterTurnStart,
    /// Decrement at a round-end marker. v1 ticks `RoundEnd` Temporals on every
    /// `TurnEnded` event whose target seat is the *last* player in the
    /// session's player list — i.e. the bottom of the turn order. Plugins
    /// running custom turn orders may need to emit a synthetic `TurnEnded` for
    /// the final seat or rely on a future explicit RoundEnded event.
    RoundEnd,
}

/// **Temporal** — "this entity has a finite duration counted in lifecycle
/// ticks."
///
/// Replaces the hand-rolled "decrement a pool by 1 in `on_phase_start_react`"
/// pattern that condition plugins used to write. The host owns the tick: at
/// the matching [`TickTrigger`] it iterates every Temporal entity, decrements
/// `remaining` by 1, and synthesizes `EntityDestroyed` (cascaded through the
/// pipeline so reactor handlers fire normally) when the counter reaches 0.
///
/// Pairs naturally with [`AttachedData`] for conditions and with
/// [`OwnableData`] for caster-anchored effects. `TARGET*` triggers require
/// `Attached`; `CasterTurnStart` requires `Ownable`. The host silently skips
/// Temporals whose required pair isn't present — better than a hard panic for
/// runtime data, and the absence is observable in tests.
///
/// **Composes with [`QuantifiedData`]:** a "Poisoned (3 turns, 1d6/turn)"
/// condition keeps damage in a Quantified pool while Temporal owns the
/// duration count. The two are independent: ticking the duration does not
/// touch any pools.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TemporalData {
    pub remaining:    u32,
    pub max_duration: u32,
    pub tick_on:      TickTrigger,
}

impl TemporalData {
    /// Create a Temporal with `remaining == max_duration == duration`.
    pub fn new(duration: u32, tick_on: TickTrigger) -> Self {
        Self { remaining: duration, max_duration: duration, tick_on }
    }

    /// Convenience: a Temporal that ticks at the start of its attached
    /// target's turn (the most common condition shape).
    pub fn target_turns(duration: u32) -> Self {
        Self::new(duration, TickTrigger::TargetTurnStart)
    }

    /// Convenience: a Temporal that ticks at the end of its attached target's
    /// turn.
    pub fn target_turn_end(duration: u32) -> Self {
        Self::new(duration, TickTrigger::TargetTurnEnd)
    }

    /// Convenience: a Temporal that ticks at the caster's (owner's) turn
    /// start.
    pub fn caster_turns(duration: u32) -> Self {
        Self::new(duration, TickTrigger::CasterTurnStart)
    }

    /// Convenience: a Temporal that ticks once per round.
    pub fn rounds(duration: u32) -> Self {
        Self::new(duration, TickTrigger::RoundEnd)
    }
}

/// **Featured** — "this entity appears in the sidebar."
///
/// Marks an entity as a top-level entry point in the UI. Featured carries only
/// the *sidebar-specific* metadata — `display_priority` (sort order) and `tags`
/// (state markers like `is_active`). The actual display data — name, icon,
/// description — lives on [`NamedData`]. **`Featured` implies `Named`:**
/// `verify_invariant()` panics in debug if an entity is `Featured` without
/// `Named`.
///
/// For board placement, use [`TangibleData`] — independent of `Featured` (a
/// hero is both; a gold counter is `Featured` but not `Tangible`; a terrain
/// hex might be `Tangible` but not `Featured`).
///
/// Plugins should leave `display_priority = 0` unless a game specifically wants
/// an entity to appear higher in sidebar lists (e.g. active hero above
/// inactive NPCs).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FeaturedData {
    /// Sort key for the sidebar list. Higher = earlier. Default 0.
    pub display_priority: i32,
    // Tags previously lived here; they now live in `Entity.tags`. Kept here as an
    // empty placeholder field-set so the comment travels with the data type.
}

/// **Tangible** — "this entity has a position on the board and is rendered there."
///
/// Carries `position: Position` — this is the merged successor of the former
/// `Spatial` trait. Any entity that exists at a board position is tangible by
/// definition; there is no concept of "positioned but invisible on the board."
/// Independent of [`FeaturedData`]: a hero is both (in sidebar AND on board);
/// a gold counter is `Featured` but not `Tangible`; a faceless terrain tile can
/// be `Tangible` without being `Featured`.
///
/// ## Contained Tangibles
///
/// Tangible is orthogonal to container membership. If a plugin puts a Tangible
/// entity inside another entity's `ContainerData.contents`, the client will still
/// render it on the board at its position. If that's not what you want
/// (e.g. a card moved into a discard pile), emit a `TraitRemoved` event to clear
/// the bit when the entity enters the container, and `TraitAdded` when it leaves.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TangibleData {
    pub position: crate::types::Position,
    /// Elevation in in-game units (feet, metres — plugin-defined). Zero for
    /// ground-level. Client z-sorts tokens by ascending elevation and shows
    /// a small badge for non-zero values. Vision integration (tall entities
    /// seeing over short walls) is future work.
    #[cfg_attr(feature = "serde", serde(default))]
    pub elevation: i32,
    /// How this entity perceives the map. Data-driven: plugins compose the
    /// profile from booleans and ranges rather than picking from a fixed
    /// enum of game-specific modes. TTRPG mappings (D&D darkvision,
    /// Pathfinder tremorsense) are *plugin* concerns built on these
    /// primitives; see `VisionProfile` helpers.
    #[cfg_attr(feature = "serde", serde(default))]
    pub vision: VisionProfile,
}

/// Perception parameters. Generic across game systems — each plugin maps
/// its own vocabulary (darkvision, blindsight, scrying, satellite imagery,
/// sonar) onto these fields.
///
/// - `blocked_by_walls`: if `false`, the viewer sees past occluders.
/// - `range_cells`: maximum Chebyshev (king-move) radius; `None` is unlimited.
///
/// Helper constructors on this type encode common TTRPG patterns without
/// pushing the vocabulary up into the SDK — `VisionProfile::sighted()` is a
/// plain viewer, `VisionProfile::unblocked(range)` sees through walls.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisionProfile {
    pub blocked_by_walls: bool,
    pub range_cells:      Option<u32>,
}

impl Default for VisionProfile {
    fn default() -> Self {
        // Sensible default: ordinary sighted creature. Walls block, range
        // implicit at map size.
        Self { blocked_by_walls: true, range_cells: None }
    }
}

impl VisionProfile {
    /// Ordinary sight — walls block, unlimited range.
    pub fn sighted() -> Self { Self::default() }

    /// Sight with a radius cap (e.g. D&D darkvision).
    pub fn sighted_within(range_cells: u32) -> Self {
        Self { blocked_by_walls: true, range_cells: Some(range_cells) }
    }

    /// Sees through occluders within a range (e.g. blindsight, tremorsense).
    pub fn unblocked_within(range_cells: u32) -> Self {
        Self { blocked_by_walls: false, range_cells: Some(range_cells) }
    }

    /// Unlimited sight that ignores occluders (e.g. truesight, omniscient GM).
    pub fn unlimited() -> Self {
        Self { blocked_by_walls: false, range_cells: None }
    }
}

impl TangibleData {
    pub fn at(position: crate::types::Position) -> Self {
        Self {
            position, elevation: 0,
            vision: VisionProfile::default(),
        }
    }

    pub fn at_elevation(position: crate::types::Position, elevation: i32) -> Self {
        Self {
            position, elevation,
            vision: VisionProfile::default(),
        }
    }

    pub fn with_vision(mut self, vision: VisionProfile) -> Self {
        self.vision = vision;
        self
    }
}

impl FeaturedData {
    /// A bare sidebar entry with default priority. Pair with `NamedData` to
    /// supply the display name/icon/description.
    pub fn new() -> Self {
        Self { display_priority: 0 }
    }

    pub fn with_priority(mut self, priority: i32) -> Self {
        self.display_priority = priority;
        self
    }
}

// ---------------------------------------------------------------------------
// S26 — Perceivability model
// ---------------------------------------------------------------------------

/// Per-(entity, seat) perceivability flags from the S26 model.
/// All bits set = fully perceivable; all bits clear = completely undetectable.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PerceivabilityFlags(pub u8);

impl PerceivabilityFlags {
    pub const PRESENT:    Self = Self(0b001);
    pub const LOCATED:    Self = Self(0b010);
    pub const OBSERVABLE: Self = Self(0b100);

    pub fn all() -> Self { Self(0b111) }
    pub fn none() -> Self { Self(0) }

    pub fn has_present(self)    -> bool { self.0 & 0b001 != 0 }
    pub fn has_located(self)    -> bool { self.0 & 0b010 != 0 }
    pub fn has_observable(self) -> bool { self.0 & 0b100 != 0 }

    pub fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 }
}

impl std::ops::BitOr for PerceivabilityFlags {
    type Output = Self;
    fn bitor(self, rhs: Self) -> Self { Self(self.0 | rhs.0) }
}

impl std::ops::BitAnd for PerceivabilityFlags {
    type Output = Self;
    fn bitand(self, rhs: Self) -> Self { Self(self.0 & rhs.0) }
}

/// Data for the SENSE trait (bit 23). Attached to observer entities to grant
/// perceivability overrides (darkvision, scrying, etc.).
/// Always paired with ATTACHED — the sense entity is attached to the observer.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct SenseData {
    /// Which perceivability flags this sense source can override (grant).
    pub can_override: PerceivabilityFlags,
    /// If None, the override applies to all entities in the observer's view range.
    /// If Some(id), the override applies only to that specific entity.
    pub target: Option<EntityId>,
}

/// Per-seat perceivability overrides stored on an entity.
/// Set by `compute_perceivability` (vision fixture) or via blueprint `.perceivability(seat, flags)`.
/// Excluded from `EntityView` for security (D14).
/// The OBSERVABLE trait bit (bit 1) is set whenever this struct is attached to an entity.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ObservableData {
    pub perceivability: Vec<(SeatId, PerceivabilityFlags)>,
}

impl ObservableData {
    /// Set or replace the perceivability override for one seat.
    pub fn with_perceivability(mut self, seat: SeatId, flags: PerceivabilityFlags) -> Self {
        self.set_perceivability(seat, flags);
        self
    }

    pub fn set_perceivability(&mut self, seat: SeatId, flags: PerceivabilityFlags) {
        if let Some(entry) = self.perceivability.iter_mut().find(|(s, _)| *s == seat) {
            entry.1 = flags;
        } else {
            self.perceivability.push((seat, flags));
        }
    }

    pub fn perceivability_for(&self, seat: SeatId) -> Option<PerceivabilityFlags> {
        self.perceivability.iter().find(|(s, _)| *s == seat).map(|(_, f)| *f)
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct ObscurableData {
    pub revealed: bool,
    pub face_down_icon: Option<AssetTag>,
}

impl ObscurableData {
    pub fn new(revealed: bool) -> Self {
        Self { revealed, face_down_icon: None }
    }
}

// ---------------------------------------------------------------------------
// Trait data structs — Ownership & Control
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct OwnableData {
    pub owner: crate::types::Owner,
}

impl OwnableData {
    /// Un-owned — neutral pieces, board fixtures.
    pub fn unowned() -> Self {
        Self { owner: crate::types::Owner::Neutral }
    }

    pub fn neutral() -> Self {
        Self { owner: crate::types::Owner::Neutral }
    }

    /// Owned by a specific seat.
    pub fn owned_by(seat: SeatId) -> Self {
        Self { owner: crate::types::Owner::Seat(seat) }
    }

    /// Owned by a team (a set of seats).
    pub fn owned_by_team(team: crate::types::TeamId) -> Self {
        Self { owner: crate::types::Owner::Team(team) }
    }
}

/// **Attached** — "this entity is attached to a target."
///
/// Used for conditions, auras, buffs, debuffs — any entity that exists *as a
/// property of* another entity. Carries `target` (the entity it's attached to)
/// and `stacks` (whether multiple instances of the same type stack or replace).
///
/// The `attached_to` / `attachments` indices on `EntityState` stay in sync with
/// this trait. Use `store.attach(entity, target)` / `store.detach(entity)` or
/// the `Attached` / `Detached` events to maintain the link.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttachedData {
    pub target: EntityId,
    /// If `true`, multiple attached entities of the same type coexist.
    /// If `false`, attaching another of the same type replaces the existing one.
    pub stacks: bool,
}

impl AttachedData {
    pub fn to(target: EntityId) -> Self {
        Self { target, stacks: false }
    }

    pub fn stacking(target: EntityId) -> Self {
        Self { target, stacks: true }
    }
}

/// How a connector should be drawn on the board.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConnectorRendering {
    /// Straight line between the first two connected entities' positions.
    Line,
    /// Curved/bezier line (useful for dense graphs).
    Curve,
    /// Don't render — connector is logical-only.
    Invisible,
}

/// **Connector** — "this entity links two or more other entities."
///
/// Roads (link corners), bridges, pipelines, graph edges. Carries an ordered
/// list of connected `EntityId`s; the connector's rendered position is derived
/// from its endpoints. A road between corners C1 and C2: `connects: vec![c1, c2]`.
///
/// For hypergraphs (hex boundaries, faces), include all boundary entities:
/// `connects: vec![c1, c2, c3, c4, c5, c6]` for a hex bounded by six corners.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectorData {
    pub connects: Vec<EntityId>,
    pub rendering: ConnectorRendering,
}

impl ConnectorData {
    pub fn between(a: EntityId, b: EntityId) -> Self {
        Self { connects: vec![a, b], rendering: ConnectorRendering::Line }
    }

    pub fn among(entities: Vec<EntityId>) -> Self {
        Self { connects: entities, rendering: ConnectorRendering::Line }
    }

    pub fn with_rendering(mut self, r: ConnectorRendering) -> Self {
        self.rendering = r;
        self
    }
}

// ---------------------------------------------------------------------------
// Trait data structs — Spatial & Structure
// (SpatialData removed — position now lives in TangibleData)
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct MovableData {
    pub movement_type: MovementType,

    /// Movement pattern. Server-side only — not serialized over the
    /// wire (see [`crate::movement`]). Defaults to [`crate::movement::Step`]
    /// for snapshots that arrive without the field set.
    #[cfg_attr(feature = "serde", serde(skip, default = "default_movement_pattern"))]
    pub pattern: crate::movement::MovementPattern,
}

fn default_movement_pattern() -> crate::movement::MovementPattern {
    crate::movement::MovementPattern::Step(crate::movement::Step)
}

impl MovableData {
    pub fn new(movement_type: MovementType) -> Self {
        Self {
            movement_type,
            pattern: default_movement_pattern(),
        }
    }

    /// Override the default `Step` pattern with a different pattern.
    pub fn with_pattern(
        mut self,
        pattern: impl Into<crate::movement::MovementPattern>,
    ) -> Self {
        self.pattern = pattern.into();
        self
    }
}

impl PartialEq for MovableData {
    /// `pattern` is intentionally excluded from equality — it carries
    /// server-only behavior that doesn't round-trip through serialization
    /// (it's `#[serde(skip)]`). Two `MovableData` are equal iff their
    /// data fields match.
    fn eq(&self, other: &Self) -> bool {
        self.movement_type == other.movement_type
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct ContainerData {
    pub contents: Vec<EntityId>,
    pub capacity: Option<usize>,
    pub container_type: ContainerType,
    /// UC-29-Pre — UI hint for sheet / inspector renderers.
    ///
    /// - `true` (default) → contents render *inline* in the parent view.
    ///   Use for small, stable collections that read better as a flat list
    ///   (a hero's class slot, a single-card holder, a status row).
    /// - `false` → contents render as a "recurse" button that opens the
    ///   container as its own subview. Use for larger or semantically-
    ///   distinct collections (inventory, spellbook, journal).
    ///
    /// Lives on the trait because it describes how players interact with
    /// the container — gameplay-adjacent, not pure cosmetic. Renderers
    /// MUST honour it.
    #[cfg_attr(feature = "serde", serde(default = "container_data_flat_default"))]
    pub flat: bool,
}

#[cfg(feature = "serde")]
fn container_data_flat_default() -> bool { true }

impl ContainerData {
    pub fn new(container_type: ContainerType) -> Self {
        Self { contents: vec![], capacity: None, container_type, flat: true }
    }

    pub fn with_capacity(mut self, capacity: usize) -> Self {
        self.capacity = Some(capacity);
        self
    }

    /// Builder: mark this container as a non-flat (recurse-button) section.
    /// Default is flat=true.
    pub fn deep(mut self) -> Self {
        self.flat = false;
        self
    }
}

// StackableData removed — merged into Container. Stack position is the index
// in `ContainerData.contents` (Vec<EntityId>). Containers are inherently ordered.

// ---------------------------------------------------------------------------
// Trait data structs — Progression & Resources
// ---------------------------------------------------------------------------

// UpgradeableData removed — merged into Quantified. Express a "level" as a
// Quantified pool with PoolId "game:level" (bounded min=1, max=max_level).

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct QuantifiedData {
    pub pools: Vec<ResourcePool>,
}

impl QuantifiedData {
    pub fn new() -> Self {
        Self { pools: vec![] }
    }

    pub fn with_pool(mut self, pool: ResourcePool) -> Self {
        self.pools.push(pool);
        self
    }
}

impl Default for QuantifiedData {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// Trait data structs — Player-Initiated
// ---------------------------------------------------------------------------

/// Display hint for what an `Activatable` action costs. Purely UI — the actual
/// cost *enforcement* lives in `ActivatableData::when` (a `Predicate`). Use
/// `CostHint::Pool` alongside a matching `Predicate::Cmp` over the pool so the
/// client can render a cost badge on the action button whose shape matches the
/// pool's display style.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum CostHint {
    /// Spends a specific amount from a pool (e.g. `Pool { id: pool_mp(), amount: 3 }`
    /// renders as "3 MP"). Pair with a matching `Predicate` in `ActivatableData::when`
    /// to actually block activation when the pool is short.
    Pool { id: PoolId, amount: i64 },
    /// The action consumes the actor's standard turn action.
    Action,
    /// An out-of-turn reaction (doesn't consume the actor's action).
    Reaction,
    /// No resource or turn cost — only `when` gates activation.
    Free,
    /// Plugin-defined free-text for costs that don't fit the above (e.g. "Discard a card").
    /// Prefer the typed variants when possible; the client can render those with icons.
    Custom(String),
}

/// Namespaced action-category key. Use `plugin_id:local` format (e.g.
/// `"combat:attack"`, `"dnd5e:spell"`). Bare unnamespaced names (e.g.
/// `"attack"`) are accepted but discouraged — they collide across plugins.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct CategoryKey(pub String);

impl CategoryKey {
    pub fn new(key: impl Into<String>) -> Self { Self(key.into()) }
    pub fn as_str(&self) -> &str { &self.0 }
}

impl From<&str> for CategoryKey {
    fn from(s: &str) -> Self { Self(s.to_owned()) }
}

impl From<String> for CategoryKey {
    fn from(s: String) -> Self { Self(s) }
}

impl std::fmt::Display for CategoryKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct ActivatableData {
    pub category: CategoryKey,
    pub description: String,
    pub target_mode: TargetMode,
    pub cost_hint: Option<CostHint>,
    /// Predicate IR that must evaluate to true for the action to be available.
    /// `Predicate::All(vec![])` (the default produced by `new`) always passes.
    pub when: crate::predicate::Predicate,
    /// WI1 phase 4 — input requirements collected before the activation
    /// commits. When `Some(spec)`, the engine's activation wizard composer
    /// drives the player through the spec's groups, populating
    /// `Activated.qualifiers` with the resolved bindings before re-proposing
    /// the event at root depth.
    ///
    /// `None` (the default) means the activation fires immediately with
    /// whatever qualifiers the caller supplied — the legacy behavior.
    ///
    /// Skipped from serde because `RequirementSpec` carries fn-pointer
    /// closures (dynamic options/counts/conditions). The spec is static
    /// plugin metadata, not runtime state; the wire protocol carries the
    /// resolved bindings, not the spec.
    #[cfg_attr(feature = "serde", serde(skip, default))]
    pub requires: Option<crate::requirement::RequirementSpec>,
}

impl ActivatableData {
    pub fn new(category: impl Into<CategoryKey>, description: impl Into<String>) -> Self {
        Self {
            category: category.into(),
            description: description.into(),
            target_mode: TargetMode::Immediate,
            cost_hint: None,
            when: crate::predicate::Predicate::All(vec![]),
            requires: None,
        }
    }

    pub fn with_cost_hint(mut self, hint: CostHint) -> Self {
        self.cost_hint = Some(hint);
        self
    }

    /// Replace the activation `when` predicate.
    pub fn with_when(mut self, when: crate::predicate::Predicate) -> Self {
        self.when = when;
        self
    }

    /// WI1 phase 4 — attach an input `RequirementSpec` that the engine's
    /// activation wizard composer will walk before committing the
    /// activation. Replaces hand-written `#[wizard]` methods.
    pub fn with_requires(mut self, spec: crate::requirement::RequirementSpec) -> Self {
        self.requires = Some(spec);
        self
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct MorphableData {
    pub current_form: String,
    pub available_forms: Vec<MorphForm>,
}

impl MorphableData {
    pub fn new(initial_form: impl Into<String>) -> Self {
        Self { current_form: initial_form.into(), available_forms: vec![] }
    }

    pub fn with_form(mut self, form: MorphForm) -> Self {
        self.available_forms.push(form);
        self
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub struct NarratedData {
    pub prompt: String,
    pub narrator: Option<PlayerId>,
}

impl NarratedData {
    pub fn new(prompt: impl Into<String>) -> Self {
        Self { prompt: prompt.into(), narrator: None }
    }
}

// ---------------------------------------------------------------------------
// Gated — entity is offered / selectable / activatable only when a
// `Predicate` evaluates true against the current store.
// ---------------------------------------------------------------------------

/// **Gated** — "this entity is only offered / selectable / activatable when
/// `when` evaluates true."
///
/// `GatedData { when }` carries a [`crate::predicate::Predicate`] expression
/// (the engine's IR for prerequisites). The host evaluates the predicate
/// against the current entity store at offer / activation time and exposes
/// `Predicate::explain` output so client UIs can grey out unselectable
/// options and surface replacement candidates.
///
/// Plugins generally do not write `Predicate` values by hand — they use the
/// `gate!` proc-macro (Phase D) which lowers a small DSL to this IR.
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GatedData {
    pub when: crate::predicate::Predicate,
}

impl GatedData {
    pub fn when(p: crate::predicate::Predicate) -> Self {
        Self { when: p }
    }
}

// ---------------------------------------------------------------------------
// Event-reactive traits: removed.
//
// Handlers for events (OnMoved, OnPoolChanged, etc.) now dispatch globally via
// `Registrar.on_xxx_react/filter/modify(priority, closure)` and self-filter.
// The entity-level OnX data was redundant documentation — it never drove
// routing (D22). Trait bits and data types for OnX were removed; the Registrar
// event-kind-keyed storage is the single mechanism. See docs/architecture/TRAITS.md.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod gated_tests {
    use super::*;
    use crate::predicate::Predicate;

    #[test]
    fn gated_bit_is_20() {
        assert_eq!(TraitSet::GATED.bits(), 1 << 20);
    }

    #[test]
    fn gated_data_carries_predicate() {
        let g = GatedData::when(Predicate::All(vec![]));
        assert_eq!(g.when, Predicate::All(vec![]));
    }

    #[test]
    fn activatable_when_carries_predicate() {
        use crate::predicate::{Op, Value};
        let p = Predicate::Cmp(Value::Const(1), Op::Eq, Value::Const(1));
        let a = ActivatableData::new("attack", "Strike a foe").with_when(p.clone());
        assert_eq!(a.when, Predicate::Cmp(Value::Const(1), Op::Eq, Value::Const(1)));
        // Default constructor uses an empty All() that always passes.
        let d = ActivatableData::new("noop", "noop");
        assert_eq!(d.when, Predicate::All(vec![]));
    }
}

// UC-29-Pre — ContainerData.flat
#[cfg(test)]
mod container_data_flat_tests {
    use super::*;

    #[test]
    fn container_data_new_defaults_flat_to_true() {
        // Pre-1: new() returns a flat container.
        let c = ContainerData::new(ContainerType::Bag);
        assert!(c.flat, "ContainerData::new must default to flat=true");
    }

    #[test]
    fn container_data_deep_builder_flips_to_false() {
        // Pre-1: .deep() opts out of flat rendering.
        let c = ContainerData::new(ContainerType::Bag).deep();
        assert!(!c.flat, "ContainerData::deep() must set flat=false");
    }

    #[cfg(feature = "serde")]
    #[test]
    fn container_data_flat_serde_roundtrip_preserves_field() {
        let c = ContainerData::new(ContainerType::Bag).deep();
        let bytes = postcard::to_allocvec(&c).expect("serialize");
        let round: ContainerData = postcard::from_bytes(&bytes).expect("deserialize");
        assert!(!round.flat);
    }

    #[cfg(feature = "serde")]
    #[test]
    fn container_data_flat_serde_default_when_field_missing() {
        // Pre-2: a JSON payload predating the field deserializes with flat=true.
        // (postcard is positional, so we use JSON for missing-field semantics.)
        let json = r#"{"contents":[],"capacity":null,"container_type":"Bag"}"#;
        let c: ContainerData = serde_json::from_str(json)
            .expect("pre-UC-29-Pre payload must still deserialize");
        assert!(c.flat, "missing `flat` field must default to true");
    }
}

#[cfg(test)]
mod movable_pattern_tests {
    use super::*;
    use crate::movement::{Hop, MovementPattern, Step};

    #[test]
    fn movable_data_carries_default_step_pattern() {
        let mv = MovableData::new(MovementType::Step);
        assert_eq!(mv.pattern, MovementPattern::Step(Step));
    }

    #[test]
    fn movable_data_with_pattern_overrides_default() {
        let mv = MovableData::new(MovementType::Step).with_pattern(Hop);
        // Smoke check: enum is callable. Use a minimal `BoardGraphView`
        // mock so this test doesn't depend on the concrete `BoardGraph`
        // (which lives in `maat-sdk`).
        struct EmptyView;
        impl crate::board::BoardGraphView for EmptyView {
            fn node_count(&self) -> usize { 0 }
            fn edge_count(&self) -> usize { 0 }
            fn face_count(&self) -> usize { 0 }
            fn contains_node(&self, _: crate::board::BoardNodeId) -> bool { false }
            fn contains_edge(&self, _: crate::board::EdgeId) -> bool { false }
            fn contains_face(&self, _: crate::board::FaceId) -> bool { false }
            fn neighbors(&self, _: crate::board::BoardNodeId) -> Vec<crate::board::BoardNodeId> { vec![] }
            fn edges_at(&self, _: crate::board::BoardNodeId) -> Vec<crate::board::EdgeId> { vec![] }
            fn endpoints(&self, _: crate::board::EdgeId) -> Option<(crate::board::BoardNodeId, crate::board::BoardNodeId)> { None }
            fn face_bounding(&self, _: crate::board::FaceId) -> Option<Vec<crate::board::BoardNodeId>> { None }
            fn faces_at_node(&self, _: crate::board::BoardNodeId) -> Vec<crate::board::FaceId> { vec![] }
            fn faces_at_edge(&self, _: crate::board::EdgeId) -> Vec<crate::board::FaceId> { vec![] }
            fn adjacent_faces(&self, _: crate::board::FaceId) -> Vec<crate::board::FaceId> { vec![] }
            fn version(&self) -> u64 { 0 }
        }
        let g = EmptyView;
        let _: Vec<_> = mv.pattern.neighbors(crate::board::BoardNodeId(0), &g, &crate::movement::AlwaysOpen);
    }

    #[test]
    fn movable_data_clone_clones_pattern() {
        let mv = MovableData::new(MovementType::Step).with_pattern(Hop);
        let cloned = mv.clone();
        assert_eq!(cloned.pattern, MovementPattern::Hop(Hop));
    }

    #[test]
    fn movable_data_eq_ignores_pattern() {
        // Equality compares only data fields — different pattern, same
        // movement_type ⇒ equal.
        let a = MovableData::new(MovementType::Step).with_pattern(Step);
        let b = MovableData::new(MovementType::Step).with_pattern(Hop);
        assert_eq!(a, b);
    }

    #[cfg(feature = "wire")]
    #[test]
    fn movable_data_serializes_without_pattern_and_round_trips_default() {
        let mv = MovableData::new(MovementType::Slide).with_pattern(Hop);
        let bytes = postcard::to_allocvec(&mv).expect("serialize");
        let back: MovableData = postcard::from_bytes(&bytes).expect("deserialize");
        assert_eq!(back.movement_type, MovementType::Slide);
        // Pattern is #[serde(skip)] — deserialized form gets the default Step.
        assert_eq!(back.pattern, MovementPattern::Step(Step));
    }
}
