// DiceRollRequested is auto-fulfilled by the host (HostRng) in GameSession::propose.
// A future PlayerInput mode will suppress auto-fulfillment and await a client response.

use crate::blueprint::EntityBlueprint;
use crate::types::{AuctionId, DecisionId, DecisionInputValue, DecisionOutcome, DiceSpec, EntityId, EntityTypeId, GroupDecisionConfig, PhaseId, PlayerId, PoolId, Position, RollerId, SeatId, TurnState, VoteId};

// ---------------------------------------------------------------------------
// GameEvent â€” the source of truth; no Custom variant
// ---------------------------------------------------------------------------

// CC5: #[non_exhaustive] added so that runtime/ and plugin/ match arms do not
// need to be updated when new variants land. Code in core/sdk/ (the defining
// crate) is exempt from the exhaustiveness check and keeps its existing
// exhaustive matches. Code in core/engine/ that matches on every variant
// (e.g. apply_event_to_store) gets a wildcard arm added below.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq)]
pub enum GameEvent {
    // Entity lifecycle
    EntityCreated { id: EntityId, blueprint: Box<EntityBlueprint> },
    EntityDestroyed { id: EntityId },

    // Entity state
    Moved {
        entity: EntityId,
        from: Position,
        to: Position,
        /// S02: Cross-system metadata entities characterizing this move (e.g. "forced", "teleport").
        /// Handlers read `ctx.store.entity(q).unwrap().named().unwrap().display_name` to interpret. Default: empty.
        #[cfg_attr(feature = "serde", serde(default))]
        qualifiers: Vec<EntityId>,
    },
    /// Place an off-board entity onto the board for the first time.
    ///
    /// The engine adds the `TANGIBLE` trait with `position = to` on commit —
    /// no follow-up `Moved` is needed. Filterable: placement-legality handlers
    /// run before the entity gains a board position. Analogous to `Moved` but
    /// without a `from`; safe to undo (restores entity to off-board state).
    Deployed {
        entity: EntityId,
        to: Position,
        #[cfg_attr(feature = "serde", serde(default))]
        qualifiers: Vec<EntityId>,
    },
    OwnerChanged { entity: EntityId, from: crate::types::Owner, to: crate::types::Owner },
    Inserted { container: EntityId, entity: EntityId },
    Removed { container: EntityId, entity: EntityId },
    // Upgraded event removed â€” express level changes as PoolChanged on a "game:level" pool.
    Revealed { entity: EntityId },
    Concealed { entity: EntityId },
    Morphed { entity: EntityId, from_form: String, to_form: String },
    /// The display-facing fields on `FeaturedData` changed. `None` means
    /// "leave unchanged"; `Some(None)` clears, `Some(Some(v))` sets. Use
    /// for the Sheet-To-Token pattern (sync actor portrait â†’ token image).
    FeaturedChanged {
        entity: EntityId,
        icon: Option<Option<crate::types::AssetTag>>,
        display_name: Option<String>,
        /// UC-09-notes — free-text GM/player notes about this entity.
        /// `None` ≡ "don't touch"; `Some(s)` overwrites
        /// `NamedData.description`. Empty string is a legitimate
        /// "clear notes" value.
        #[cfg_attr(feature = "serde", serde(default))]
        description: Option<String>,
    },

    // Resources
    PoolChanged {
        entity: EntityId,
        pool: PoolId,
        old_value: i64,
        new_value: i64,
        delta: i64,
        /// S02: Cross-system metadata entities characterizing this pool change
        /// (e.g. damage type entity with NamedData("Fire")). Handlers without
        /// system-specific knowledge inspect named data on each qualifier.
        #[cfg_attr(feature = "serde", serde(default))]
        qualifiers: Vec<EntityId>,
    },
    PoolDepleted { entity: EntityId, pool: PoolId },
    PoolFilled { entity: EntityId, pool: PoolId },

    // Activation
    Activated {
        entity: EntityId,
        activator: EntityId,
        target: Option<EntityId>,
        /// S02 + WI1: cross-system metadata entities AND resolved wizard
        /// input bindings for this activation. After WI1 phase 3 widened the
        /// carrier from `Vec<EntityId>` to `Vec<RequirementBinding>`,
        /// `qualifiers[i]` can be an `Entity` (the legacy case), an
        /// `EntitySet` (multi-pick), a `PoolSpend` (resource consumption,
        /// e.g. Magic Missile slot upcast), text/number, or `Optional`.
        /// Handlers that only care about entity refs should call
        /// `event.entity_qualifiers()` to project down to `Vec<EntityId>`.
        #[cfg_attr(feature = "serde", serde(default))]
        qualifiers: Vec<crate::requirement::RequirementBinding>,
    },

    // Session flow
    TurnAdvanced { seat: SeatId },
    TurnEnded { seat: SeatId },
    TurnOrderResolved { order: Vec<SeatId> },       // committed actor order for current phase
    TurnNominated { nominator: SeatId, candidate: SeatId },
    TurnNominationAccepted { by: SeatId, candidate: SeatId },
    TurnStateChanged { seat: SeatId, state: TurnState },

    // Phase events (S09: replaces the single PhaseChanged sentinel)
    /// First entry into a phase (was PhaseChanged { from: PhaseId::new(""), to }).
    PhaseEntered { phase: PhaseId },
    /// Repeat restart of the same phase (was PhaseChanged { from: p, to: p }).
    PhaseRestarted { phase: PhaseId },
    /// Transition from one phase to a different phase (was PhaseChanged { from, to } where from != to).
    PhaseTransitioned { from: PhaseId, to: PhaseId },
    PhasePushed { pushed: PhaseId, suspended: PhaseId },
    PhasePopped { popped: PhaseId, resumed: PhaseId },
    DiceRollRequested { roller: RollerId, spec: DiceSpec, tag: String },
    DiceRolled {
        roller: RollerId,
        spec: DiceSpec,
        results: Vec<u32>,
        total: i64,
        tag: String,
        /// S02: Cross-system metadata entities (e.g. "attack" vs "save" vs "skill check").
        #[cfg_attr(feature = "serde", serde(default))]
        qualifiers: Vec<EntityId>,
    },

    // Tags (S17)
    TagAdded { entity: EntityId, tag: String },
    TagRemoved { entity: EntityId, tag: String },

    // Narration
    NarrationResolved {
        entity: EntityId,
        narrator: PlayerId,
        prompt: String,
        resolution_text: String,
        effects: Vec<GameEvent>,
    },

    // Attachment
    Attached { entity: EntityId, target: EntityId },
    Detached { entity: EntityId, target: EntityId },

    // Timer
    TimerExpired { entity: EntityId },

    // Fixture: Initiative
    TurnOrderSet { order: Vec<EntityId> },

    // Fixture: Deck
    Drew { from_deck: EntityId, entity: EntityId },
    DeckExhausted { deck: EntityId },
    DeckReshuffled { deck: EntityId },

    // Fixture: Vote
    VoteResolved {
        motion_id: VoteId,
        motion: String,
        winner: String,
        tally: Vec<(PlayerId, String)>,
    },

    // Fixture: Auction
    AuctionResolved {
        auction_id: AuctionId,
        item: EntityId,
        winner: Option<SeatId>,
        amount: i64,
    },

    // Fixture: Vision (S26/S27 — perceivability model)
    /// Update one seat's perceivability of one entity. Stored in ObservableData.perceivability.
    /// Emitted by `compute_perceivability` in the vision fixture.
    PerceivabilityChanged {
        entity: EntityId,
        seat:   SeatId,
        flags:  crate::trait_data::PerceivabilityFlags,
    },

    // Structural â€” emitted when a trait is dynamically added/removed (e.g. via Morphed).
    // Applied to entity_traits bitfield so event log replay reconstructs the full TraitSet.
    TraitAdded   { entity: EntityId, trait_bit: u64 },
    TraitRemoved { entity: EntityId, trait_bit: u64 },

    // Zone fixture (S15)
    MovedToZone  { entity: EntityId, zone: EntityId },
    ZoneShuffled { zone: EntityId },
    ZoneExhausted { zone: EntityId },
    ZoneReshuffled { zone: EntityId },

    // Group decision fixture (S16 â€” unifies Vote + Auction)
    GroupDecisionStarted  { id: DecisionId, config: GroupDecisionConfig },
    GroupDecisionInput    { id: DecisionId, seat: SeatId, value: DecisionInputValue },
    GroupDecisionResolved { id: DecisionId, outcome: DecisionOutcome },

    // Simultaneous play (S14)
    /// A player submits an action during a simultaneous-submission window.
    Submitted    { seat: SeatId, action: Box<GameEvent> },
    PoolClosed   { pool_id: EntityId },
    PoolResolved { pool_id: EntityId, outcomes: Vec<GameEvent> },

    // Board graph mutations (Phase 0)
    /// A batch of graph topology mutations was committed. Contains the
    /// resolved `GraphOp` log (LocalIndex already replaced by real NodeIds).
    GraphMutated { ops: Vec<crate::board::GraphOp> },

    /// (Composable F) A seat is requesting GM approval to use an entity whose
    /// source isn't currently in `active_sources` (or where some other
    /// `Gated`/source-bound predicate fails). The pipeline catches this in a
    /// dedicated filter handler that converts it into a 4-option
    /// `Suspension::Single` routed to `Decider::Narrator`. Resolution of that
    /// suspension is special-cased in `GameSession::resolve_suspension` —
    /// the chosen option mutates `active_sources` / `source_overrides` /
    /// the one-shot bypass flag accordingly.
    SourceRequestProposed {
        seat:        SeatId,
        entity_type: EntityTypeId,
        source:      crate::predicate::types::SourceKey,
    },

    /// CR1 — generalised entity-creation request. The engine looks up the
    /// type's registered `creation_spec` and opens an engine-composed wizard.
    /// Filter handlers gate timing/role/cost; Modify can adjust prefill;
    /// React is reserved for cross-cutting bookkeeping. On Resolve the
    /// engine calls the type's `compose(history, ctx)` and emits the
    /// returned events.
    EntityCreationRequested {
        type_id: EntityTypeId,
        by_seat: SeatId,
        prefill: std::collections::HashMap<crate::staged_decision::StageId, crate::creation::PrefillValue>,
    },

    /// LU1 (CR1-C) — request leveling up an entity to `target_level`.
    /// The engine looks up the entity type's registered `level_up_spec_fn`
    /// (a per-level spec) and opens an engine-composed wizard. Filter
    /// handlers gate timing/role/cost (e.g. "only during downtime
    /// phase", "target_level must be current_level + 1"); React is
    /// reserved for cross-cutting bookkeeping. On Resolve the engine
    /// calls the type's `level_up_compose_fn(target_level, history, ctx, entity)`
    /// and emits the returned events.
    EntityLevelUpRequested {
        entity: EntityId,
        by_seat: SeatId,
        target_level: u32,
    },

    /// AD1 (CR1-C) — wraps a batch of player-initiated mutations to a
    /// character sheet (e.g. "rename", "tweak ability score", "buy
    /// equipment"). The engine's `SheetEditPolicy` filter gates the
    /// whole batch with one Narrator suspension under
    /// `GmApprovalRequired`. On Allow, the engine's React handler at
    /// `Priority::PREEMPT` re-proposes each mutation in order via
    /// `propose_batch`. On Deny, no mutations apply.
    SheetEdited {
        entity: EntityId,
        by_seat: SeatId,
        /// The mutations the player wants to apply. Each is a real
        /// pipeline event; the wrapper exists so a single Filter can
        /// adjudicate the whole batch without ten separate suspensions.
        mutations: Vec<GameEvent>,
    },

    // -----------------------------------------------------------------------
    // UC-08 Phase D — Undo / Redo events
    // -----------------------------------------------------------------------

    /// Player or GM requests undoing a target log entry's decision tree.
    /// Pipeline filter handlers determine permission; on Allow, emits
    /// `UndoExecuted`. `actor: None` denotes a system/host-issued request.
    UndoRequested {
        actor:      Option<SeatId>,
        target_seq: u64,
        reason:     Option<String>,
    },

    /// Result of an approved `UndoRequested`. Marks the cascade-tree of seqs
    /// rooted at `target_root_seq` as undone. React handlers apply to log +
    /// snapshots. In the uninvertible-exempt set — cannot itself be undone.
    UndoExecuted {
        target_root_seq: u64,
        marked_undone:   Vec<u64>,
        approved_by:     Option<SeatId>,
    },

    /// Player or GM requests redoing a previously-undone target.
    /// Strict invalidation: any non-undo/redo event between `UndoExecuted`
    /// and now invalidates the redo. Filter handlers enforce.
    RedoRequested {
        actor:           Option<SeatId>,
        target_undo_seq: u64,
        reason:          Option<String>,
    },

    /// Result of an approved `RedoRequested`. Un-marks the seqs that the
    /// targeted `UndoExecuted` had marked. Also in the uninvertible-exempt
    /// set — cannot itself be undone.
    RedoExecuted {
        target_undo_seq: u64,
        unmarked:        Vec<u64>,
    },

    // -----------------------------------------------------------------------
    // SD1 — staged-decision wizard milestones
    // -----------------------------------------------------------------------

    /// A wizard (`StagedDecision`) has just opened. Filter handlers can
    /// `Decision::Deny` this event to abort the wizard before it
    /// presents its first group (the Counterspell-style intercept
    /// pattern, applied to wizard openings rather than `Activated`
    /// directly).
    WizardOpened {
        kind:    crate::staged_decision::WizardKind,
        decider: Decider,
    },

    /// A decider has submitted a choice for a stage in the
    /// currently-pending wizard. Fired before the engine appends to
    /// `OpenGroup.partial`. Filter handlers can `Decision::Deny`
    /// (rejected pick — client retries the same stage),
    /// `Decision::Modify` (rewrite the chosen value, e.g. force a
    /// pick), or `Decision::Allow` (default).
    WizardChoiceProposed {
        kind:     crate::staged_decision::WizardKind,
        group_id: crate::staged_decision::GroupId,
        stage_id: crate::staged_decision::StageId,
        value:    crate::staged_decision::ChoiceValue,
    },

    /// A wizard has logically completed and the plugin's `Resolve`
    /// callback has produced its final pipeline effects. Carries the
    /// full audit-trail history so replay / undo can rewind the
    /// wizard as one unit.
    WizardResolved {
        kind:    crate::staged_decision::WizardKind,
        history: Vec<crate::staged_decision::ResolvedGroup>,
    },
}

/// Discriminant for `GameEvent`. Used as a HashMap key in the Registrar (R01)
/// to route handlers without matching on every variant.
///
/// Intentionally NOT `#[non_exhaustive]`: pipeline routing in the engine
/// matches exhaustively on EventKind to dispatch handlers. Forcing the
/// engine to handle every kind is the safety contract.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventKind {
    EntityCreated,
    EntityDestroyed,
    Moved,
    Deployed,
    OwnerChanged,
    Inserted,
    Removed,
    Revealed,
    Concealed,
    Morphed,
    FeaturedChanged,
    PoolChanged,
    PoolDepleted,
    PoolFilled,
    Activated,
    TurnAdvanced,
    TurnEnded,
    TurnOrderResolved,
    TurnNominated,
    TurnNominationAccepted,
    TurnStateChanged,
    PhaseEntered,
    PhaseRestarted,
    PhaseTransitioned,
    PhasePushed,
    PhasePopped,
    DiceRollRequested,
    DiceRolled,
    TagAdded,
    TagRemoved,
    NarrationResolved,
    Attached,
    Detached,
    TimerExpired,
    TurnOrderSet,
    Drew,
    DeckExhausted,
    DeckReshuffled,
    VoteResolved,
    AuctionResolved,
    PerceivabilityChanged,
    TraitAdded,
    TraitRemoved,
    MovedToZone,
    ZoneShuffled,
    ZoneExhausted,
    ZoneReshuffled,
    GroupDecisionStarted,
    GroupDecisionInput,
    GroupDecisionResolved,
    Submitted,
    PoolClosed,
    PoolResolved,
    GraphMutated,
    SourceRequestProposed,
    EntityCreationRequested,
    EntityLevelUpRequested,
    SheetEdited,
    UndoRequested,
    UndoExecuted,
    RedoRequested,
    RedoExecuted,
    // SD1 — staged-decision wizard milestones
    WizardOpened,
    WizardChoiceProposed,
    WizardResolved,
}

impl GameEvent {
    /// UC-08 F6 — which seat performed (or is the subject of) the action
    /// that produced this event, when applicable. Used by adjudication
    /// scoping (`GmScope::Seats`) to decide which GMs see a suspension
    /// raised from this event.
    ///
    /// Returns `None` for events with no natural actor (host-emitted
    /// fixture events, phase transitions without an actor, etc.).
    /// 5650 — proposer seat as carried by the event's payload, for the
    /// auth cross-check. This is the STRICT subset of [`actor_seat`] —
    /// only events whose typed field IS "the seat that proposed this"
    /// (named `by_seat` / `actor` / `nominator` / `by` / `approved_by`)
    /// return Some. Events where the actor field means "the affected
    /// seat" (e.g. `TurnAdvanced { seat }` = whose turn STARTS,
    /// `PerceivabilityChanged { seat }` = whose perception changed,
    /// `AuctionResolved { winner }` = who won) return None.
    ///
    /// The pipeline pre-check uses this to validate the event's claimed
    /// proposer matches the authed sender; mismatches surface a
    /// spoofed-by_seat deny.
    pub fn proposer_seat(&self) -> Option<crate::types::SeatId> {
        match self {
            GameEvent::EntityCreationRequested { by_seat, .. }
            | GameEvent::EntityLevelUpRequested { by_seat, .. }
            | GameEvent::SheetEdited { by_seat, .. } => Some(*by_seat),

            GameEvent::TurnEnded { seat }
            | GameEvent::TurnStateChanged { seat, .. }
            | GameEvent::GroupDecisionInput { seat, .. }
            | GameEvent::Submitted { seat, .. }
            | GameEvent::SourceRequestProposed { seat, .. } => Some(*seat),

            GameEvent::TurnNominated { nominator, .. } => Some(*nominator),
            GameEvent::TurnNominationAccepted { by, .. } => Some(*by),

            GameEvent::UndoRequested { actor, .. }
            | GameEvent::RedoRequested { actor, .. } => *actor,
            GameEvent::UndoExecuted { approved_by, .. } => *approved_by,

            // Affected-seat events (NOT proposer): TurnAdvanced.seat,
            // PerceivabilityChanged.seat, AuctionResolved.winner. These
            // are typically engine-emitted via cascade; if a player
            // proposes them directly, the affected seat is data, not
            // an auth claim.
            _ => None,
        }
    }

    pub fn actor_seat(&self) -> Option<crate::types::SeatId> {
        match self {
            GameEvent::TurnAdvanced { seat }
            | GameEvent::TurnEnded { seat }
            | GameEvent::TurnStateChanged { seat, .. }
            | GameEvent::GroupDecisionInput { seat, .. }
            | GameEvent::Submitted { seat, .. }
            | GameEvent::SourceRequestProposed { seat, .. }
            | GameEvent::PerceivabilityChanged { seat, .. } => Some(*seat),

            GameEvent::TurnNominated { nominator, .. } => Some(*nominator),
            GameEvent::TurnNominationAccepted { by, .. } => Some(*by),

            GameEvent::EntityCreationRequested { by_seat, .. }
            | GameEvent::EntityLevelUpRequested { by_seat, .. }
            | GameEvent::SheetEdited { by_seat, .. } => Some(*by_seat),

            GameEvent::AuctionResolved { winner, .. } => *winner,

            GameEvent::UndoRequested { actor, .. }
            | GameEvent::RedoRequested { actor, .. } => *actor,

            GameEvent::UndoExecuted { approved_by, .. } => *approved_by,

            // Events without a natural actor seat in their typed shape.
            _ => None,
        }
    }

    /// UC-08 F6 — which pool (if any) is touched by this event. Used by
    /// adjudication scoping (`GmScope::Pools`) to decide which GMs see
    /// a suspension raised from this event.
    pub fn pool_id(&self) -> Option<&crate::types::PoolId> {
        match self {
            GameEvent::PoolChanged { pool, .. }
            | GameEvent::PoolDepleted { pool, .. }
            | GameEvent::PoolFilled { pool, .. } => Some(pool),
            _ => None,
        }
    }

    /// WI1 phase 3 — extract entity refs from `Activated.qualifiers`.
    ///
    /// Returns the flat list of `EntityId`s present in the qualifier
    /// vector, expanding `RequirementBinding::EntitySet` into its
    /// members and skipping non-entity bindings (Text / Number /
    /// PoolSpend / unbound Optional). Handlers that only care about
    /// entity references — most do — should use this instead of
    /// pattern-matching the qualifier bindings directly.
    ///
    /// Returns an empty Vec for any non-Activated event.
    pub fn entity_qualifiers(&self) -> Vec<crate::types::EntityId> {
        let GameEvent::Activated { qualifiers, .. } = self else {
            return Vec::new();
        };
        let mut out = Vec::with_capacity(qualifiers.len());
        for q in qualifiers {
            match q {
                crate::requirement::RequirementBinding::Entity(e) => out.push(*e),
                crate::requirement::RequirementBinding::EntitySet(v) => out.extend_from_slice(v),
                crate::requirement::RequirementBinding::Optional(inner) => {
                    if let Some(b) = inner.as_deref() {
                        match b {
                            crate::requirement::RequirementBinding::Entity(e) => out.push(*e),
                            crate::requirement::RequirementBinding::EntitySet(v) => out.extend_from_slice(v),
                            _ => {}
                        }
                    }
                }
                _ => {}
            }
        }
        out
    }

    /// Discriminant extraction â€” exhaustive match, no wildcard.
    pub fn kind(&self) -> EventKind {
        match self {
            GameEvent::EntityCreated { .. } => EventKind::EntityCreated,
            GameEvent::EntityDestroyed { .. } => EventKind::EntityDestroyed,
            GameEvent::Moved { .. } => EventKind::Moved,
            GameEvent::Deployed { .. } => EventKind::Deployed,
            GameEvent::OwnerChanged { .. } => EventKind::OwnerChanged,
            GameEvent::Inserted { .. } => EventKind::Inserted,
            GameEvent::Removed { .. } => EventKind::Removed,
            GameEvent::Revealed { .. } => EventKind::Revealed,
            GameEvent::Concealed { .. } => EventKind::Concealed,
            GameEvent::Morphed { .. } => EventKind::Morphed,
            GameEvent::FeaturedChanged { .. } => EventKind::FeaturedChanged,
            GameEvent::PoolChanged { .. } => EventKind::PoolChanged,
            GameEvent::PoolDepleted { .. } => EventKind::PoolDepleted,
            GameEvent::PoolFilled { .. } => EventKind::PoolFilled,
            GameEvent::Activated { .. } => EventKind::Activated,
            GameEvent::TurnAdvanced { .. } => EventKind::TurnAdvanced,
            GameEvent::TurnEnded { .. } => EventKind::TurnEnded,
            GameEvent::TurnOrderResolved { .. } => EventKind::TurnOrderResolved,
            GameEvent::TurnNominated { .. } => EventKind::TurnNominated,
            GameEvent::TurnNominationAccepted { .. } => EventKind::TurnNominationAccepted,
            GameEvent::TurnStateChanged { .. } => EventKind::TurnStateChanged,
            GameEvent::PhaseEntered { .. } => EventKind::PhaseEntered,
            GameEvent::PhaseRestarted { .. } => EventKind::PhaseRestarted,
            GameEvent::PhaseTransitioned { .. } => EventKind::PhaseTransitioned,
            GameEvent::PhasePushed { .. } => EventKind::PhasePushed,
            GameEvent::PhasePopped { .. } => EventKind::PhasePopped,
            GameEvent::DiceRollRequested { .. } => EventKind::DiceRollRequested,
            GameEvent::DiceRolled { .. } => EventKind::DiceRolled,
            GameEvent::TagAdded { .. } => EventKind::TagAdded,
            GameEvent::TagRemoved { .. } => EventKind::TagRemoved,
            GameEvent::NarrationResolved { .. } => EventKind::NarrationResolved,
            GameEvent::Attached { .. } => EventKind::Attached,
            GameEvent::Detached { .. } => EventKind::Detached,
            GameEvent::TimerExpired { .. } => EventKind::TimerExpired,
            GameEvent::TurnOrderSet { .. } => EventKind::TurnOrderSet,
            GameEvent::Drew { .. } => EventKind::Drew,
            GameEvent::DeckExhausted { .. } => EventKind::DeckExhausted,
            GameEvent::DeckReshuffled { .. } => EventKind::DeckReshuffled,
            GameEvent::VoteResolved { .. } => EventKind::VoteResolved,
            GameEvent::AuctionResolved { .. } => EventKind::AuctionResolved,
            GameEvent::PerceivabilityChanged { .. } => EventKind::PerceivabilityChanged,
            GameEvent::TraitAdded { .. } => EventKind::TraitAdded,
            GameEvent::TraitRemoved { .. } => EventKind::TraitRemoved,
            GameEvent::MovedToZone { .. } => EventKind::MovedToZone,
            GameEvent::ZoneShuffled { .. } => EventKind::ZoneShuffled,
            GameEvent::ZoneExhausted { .. } => EventKind::ZoneExhausted,
            GameEvent::ZoneReshuffled { .. } => EventKind::ZoneReshuffled,
            GameEvent::GroupDecisionStarted { .. } => EventKind::GroupDecisionStarted,
            GameEvent::GroupDecisionInput { .. } => EventKind::GroupDecisionInput,
            GameEvent::GroupDecisionResolved { .. } => EventKind::GroupDecisionResolved,
            GameEvent::Submitted { .. } => EventKind::Submitted,
            GameEvent::PoolClosed { .. } => EventKind::PoolClosed,
            GameEvent::PoolResolved { .. } => EventKind::PoolResolved,
            GameEvent::GraphMutated { .. } => EventKind::GraphMutated,
            GameEvent::SourceRequestProposed { .. } => EventKind::SourceRequestProposed,
            GameEvent::EntityCreationRequested { .. } => EventKind::EntityCreationRequested,
            GameEvent::EntityLevelUpRequested { .. } => EventKind::EntityLevelUpRequested,
            GameEvent::SheetEdited { .. } => EventKind::SheetEdited,
            GameEvent::UndoRequested { .. } => EventKind::UndoRequested,
            GameEvent::UndoExecuted { .. } => EventKind::UndoExecuted,
            GameEvent::RedoRequested { .. } => EventKind::RedoRequested,
            GameEvent::RedoExecuted { .. } => EventKind::RedoExecuted,
            GameEvent::WizardOpened { .. } => EventKind::WizardOpened,
            GameEvent::WizardChoiceProposed { .. } => EventKind::WizardChoiceProposed,
            GameEvent::WizardResolved { .. } => EventKind::WizardResolved,
        }
    }
}

// â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
// R01 regression tests â€” EventKind discriminant extraction
// â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
#[cfg(test)]
mod tests {
    use super::{EventKind, GameEvent};
    use crate::types::{EntityId, PhaseId, Position, SeatId};
    use crate::board::FaceId;

    #[test]
    fn moved_maps_to_moved_kind() {
        let ev = GameEvent::Moved {
            entity: EntityId::from_raw(1),
            from:   Position::Face(FaceId(0)),
            to:     Position::Face(FaceId(1)), qualifiers: Vec::new() };
        assert_eq!(ev.kind(), EventKind::Moved);
    }

    #[test]
    fn turn_advanced_maps_to_turn_advanced_kind() {
        assert_eq!(GameEvent::TurnAdvanced { seat: SeatId::from_raw(0) }.kind(), EventKind::TurnAdvanced);
    }

    #[test]
    fn turn_ended_maps_to_turn_ended_kind() {
        assert_eq!(GameEvent::TurnEnded { seat: SeatId::from_raw(0) }.kind(), EventKind::TurnEnded);
    }

    #[test]
    fn phase_transitioned_maps_to_correct_kind() {
        assert_eq!(
            GameEvent::PhaseTransitioned { from: PhaseId::new("test:a"), to: PhaseId::new("test:b") }.kind(),
            EventKind::PhaseTransitioned,
        );
    }

    #[test]
    fn phase_entered_and_restarted_are_distinct_kinds() {
        assert_eq!(GameEvent::PhaseEntered  { phase: PhaseId::new("test:x") }.kind(), EventKind::PhaseEntered);
        assert_eq!(GameEvent::PhaseRestarted { phase: PhaseId::new("test:x") }.kind(), EventKind::PhaseRestarted);
        assert_ne!(EventKind::PhaseEntered, EventKind::PhaseRestarted);
    }

    #[test]
    fn entity_destroyed_maps_to_entity_destroyed_kind() {
        assert_eq!(GameEvent::EntityDestroyed { id: EntityId::from_raw(5) }.kind(), EventKind::EntityDestroyed);
    }

    // R01: EventKind must be Hash + Eq + Copy to serve as a HashMap key in the Registrar
    #[test]
    fn event_kind_is_usable_as_hashmap_key() {
        use std::collections::HashMap;
        let k = EventKind::Moved;
        let k_copy = k; // Copy
        assert_eq!(k, k_copy); // Eq
        let mut map: HashMap<EventKind, i32> = HashMap::new();
        map.insert(EventKind::Moved, 1);
        map.insert(EventKind::PoolChanged, 2);
        assert_eq!(map[&k_copy], 1);
        assert_eq!(map[&EventKind::PoolChanged], 2);
        assert!(!map.contains_key(&EventKind::Activated));
    }

    // R01: distinct variants must produce distinct EventKinds
    #[test]
    fn distinct_variants_produce_distinct_kinds() {
        assert_ne!(EventKind::Moved,          EventKind::PoolChanged);
        assert_ne!(EventKind::PhaseEntered,   EventKind::PhaseRestarted);
        assert_ne!(EventKind::PhaseTransitioned, EventKind::PhaseEntered);
        assert_ne!(EventKind::TurnEnded,      EventKind::TurnAdvanced);
        assert_ne!(EventKind::EntityCreated,  EventKind::EntityDestroyed);
    }

    #[test]
    fn kind_is_deterministic() {
        let ev = GameEvent::TurnAdvanced { seat: SeatId::from_raw(1) };
        assert_eq!(ev.kind(), ev.kind());
    }

    // -----------------------------------------------------------------------
    // UC-08 Phase D — undo/redo variants
    // -----------------------------------------------------------------------

    #[test]
    fn undo_requested_maps_to_undo_requested_kind() {
        let ev = GameEvent::UndoRequested {
            actor:      Some(SeatId::from_raw(1)),
            target_seq: 42,
            reason:     Some("forgot fire resistance".into()),
        };
        assert_eq!(ev.kind(), EventKind::UndoRequested);
    }

    #[test]
    fn undo_executed_maps_to_undo_executed_kind() {
        let ev = GameEvent::UndoExecuted {
            target_root_seq: 42,
            marked_undone:   vec![42, 43, 44, 45],
            approved_by:     Some(SeatId::from_raw(0)),
        };
        assert_eq!(ev.kind(), EventKind::UndoExecuted);
    }

    #[test]
    fn redo_requested_maps_to_redo_requested_kind() {
        let ev = GameEvent::RedoRequested {
            actor:           Some(SeatId::from_raw(1)),
            target_undo_seq: 50,
            reason:          None,
        };
        assert_eq!(ev.kind(), EventKind::RedoRequested);
    }

    #[test]
    fn redo_executed_maps_to_redo_executed_kind() {
        let ev = GameEvent::RedoExecuted {
            target_undo_seq: 50,
            unmarked:        vec![42, 43, 44, 45],
        };
        assert_eq!(ev.kind(), EventKind::RedoExecuted);
    }

    #[test]
    fn undo_redo_kinds_are_distinct() {
        assert_ne!(EventKind::UndoRequested, EventKind::UndoExecuted);
        assert_ne!(EventKind::UndoRequested, EventKind::RedoRequested);
        assert_ne!(EventKind::UndoExecuted,  EventKind::RedoExecuted);
        assert_ne!(EventKind::RedoRequested, EventKind::RedoExecuted);
    }

    #[cfg(feature = "wire")]
    #[test]
    fn undo_requested_serde_round_trip() {
        let event = GameEvent::UndoRequested {
            actor:      Some(SeatId::from_raw(1)),
            target_seq: 42,
            reason:     Some("forgot fire resistance".into()),
        };
        let bytes = postcard::to_allocvec(&event).unwrap();
        let back: GameEvent = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(event, back);
    }

    #[cfg(feature = "wire")]
    #[test]
    fn undo_requested_serde_round_trip_no_actor_no_reason() {
        let event = GameEvent::UndoRequested {
            actor:      None,
            target_seq: 0,
            reason:     None,
        };
        let bytes = postcard::to_allocvec(&event).unwrap();
        let back: GameEvent = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(event, back);
    }

    #[cfg(feature = "wire")]
    #[test]
    fn undo_executed_serde_round_trip() {
        let event = GameEvent::UndoExecuted {
            target_root_seq: 42,
            marked_undone:   vec![42, 43, 44, 45],
            approved_by:     Some(SeatId::from_raw(0)),
        };
        let bytes = postcard::to_allocvec(&event).unwrap();
        let back: GameEvent = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(event, back);
    }

    #[cfg(feature = "wire")]
    #[test]
    fn redo_requested_serde_round_trip() {
        let event = GameEvent::RedoRequested {
            actor:           Some(SeatId::from_raw(2)),
            target_undo_seq: 100,
            reason:          Some("changed mind".into()),
        };
        let bytes = postcard::to_allocvec(&event).unwrap();
        let back: GameEvent = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(event, back);
    }

    #[cfg(feature = "wire")]
    #[test]
    fn redo_executed_serde_round_trip() {
        let event = GameEvent::RedoExecuted {
            target_undo_seq: 100,
            unmarked:        vec![100, 101, 102],
        };
        let bytes = postcard::to_allocvec(&event).unwrap();
        let back: GameEvent = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(event, back);
    }
}

// â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
// Variant-sync test â€” guarantees GameEvent and EventKind stay aligned.
// â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
#[cfg(test)]
mod variant_sync {
    use super::{EventKind, GameEvent};
    use crate::blueprint::EntityBlueprint;
    use crate::board::FaceId;
    use crate::types::{
        AssetTag, AuctionId, DecisionId, DecisionInput, DecisionInputValue, DecisionOutcome,
        DecisionResolution, DiceSpec, EntityId, EntityTypeId, GroupDecisionConfig, Owner, PhaseId,
        PlayerId, PoolId, Position, RollerId, SeatId, TurnState, VoteId,
    };

    /// Build one minimal instance per `GameEvent` variant. The compiler's
    /// exhaustiveness check on this match guarantees every `GameEvent`
    /// variant produces an `EventKind`. If a new `GameEvent` variant lands
    /// without a corresponding `kind()` arm, this fails to compile.
    fn one_per_game_event() -> Vec<GameEvent> {
        let entity = EntityId::from_raw(1);
        let seat = SeatId::from_raw(0);
        let player = PlayerId::from_raw(0);
        let phase = PhaseId::new("test:phase");
        let pool_id = PoolId::new("game:hp", "HP");
        let dice = DiceSpec::new(1, 6, "test");

        vec![
            GameEvent::EntityCreated {
                id: entity,
                blueprint: Box::new(EntityBlueprint::new(EntityTypeId::from_raw(0))),
            },
            GameEvent::EntityDestroyed { id: entity },
            GameEvent::Moved {
                entity,
                from: Position::Face(FaceId(0)),
                to: Position::Face(FaceId(1)),
                qualifiers: Vec::new(),
            },
            GameEvent::Deployed {
                entity,
                to: Position::Face(FaceId(2)),
                qualifiers: Vec::new(),
            },
            GameEvent::OwnerChanged {
                entity,
                from: Owner::Neutral,
                to: Owner::Seat(seat),
            },
            GameEvent::Inserted { container: entity, entity },
            GameEvent::Removed { container: entity, entity },
            GameEvent::Revealed { entity },
            GameEvent::Concealed { entity },
            GameEvent::Morphed {
                entity,
                from_form: "a".into(),
                to_form: "b".into(),
            },
            GameEvent::FeaturedChanged {
                entity,
                icon: Some(Some(AssetTag::new("foo"))),
                display_name: None,
                description: None,
            },
            GameEvent::PoolChanged {
                entity,
                pool: pool_id.clone(),
                old_value: 0,
                new_value: 1,
                delta: 1,
                qualifiers: Vec::new(),
            },
            GameEvent::PoolDepleted { entity, pool: pool_id.clone() },
            GameEvent::PoolFilled { entity, pool: pool_id.clone() },
            GameEvent::Activated { entity, activator: entity, target: None, qualifiers: Vec::new() },
            GameEvent::TurnAdvanced { seat },
            GameEvent::TurnEnded { seat },
            GameEvent::TurnOrderResolved { order: vec![seat] },
            GameEvent::TurnNominated { nominator: seat, candidate: seat },
            GameEvent::TurnNominationAccepted { by: seat, candidate: seat },
            GameEvent::TurnStateChanged { seat, state: TurnState::Normal },
            GameEvent::PhaseEntered { phase },
            GameEvent::PhaseRestarted { phase },
            GameEvent::PhaseTransitioned { from: phase, to: phase },
            GameEvent::PhasePushed { pushed: phase, suspended: phase },
            GameEvent::PhasePopped { popped: phase, resumed: phase },
            GameEvent::DiceRollRequested {
                roller: RollerId::Host,
                spec: dice.clone(),
                tag: "t".into(),
            },
            GameEvent::DiceRolled {
                roller: RollerId::Host,
                spec: dice,
                results: vec![3],
                total: 3,
                tag: "t".into(),
                qualifiers: Vec::new(),
            },
            GameEvent::TagAdded { entity, tag: "tag".into() },
            GameEvent::TagRemoved { entity, tag: "tag".into() },
            GameEvent::NarrationResolved {
                entity,
                narrator: player,
                prompt: String::new(),
                resolution_text: String::new(),
                effects: Vec::new(),
            },
            GameEvent::Attached { entity, target: entity },
            GameEvent::Detached { entity, target: entity },
            GameEvent::TimerExpired { entity },
            GameEvent::TurnOrderSet { order: vec![entity] },
            GameEvent::Drew { from_deck: entity, entity },
            GameEvent::DeckExhausted { deck: entity },
            GameEvent::DeckReshuffled { deck: entity },
            GameEvent::VoteResolved {
                motion_id: VoteId(0),
                motion: String::new(),
                winner: String::new(),
                tally: Vec::new(),
            },
            GameEvent::AuctionResolved {
                auction_id: AuctionId(0),
                item: entity,
                winner: None,
                amount: 0,
            },
            GameEvent::PerceivabilityChanged {
                entity,
                seat,
                flags: crate::trait_data::PerceivabilityFlags::none(),
            },
            GameEvent::TraitAdded { entity, trait_bit: 0 },
            GameEvent::TraitRemoved { entity, trait_bit: 0 },
            GameEvent::MovedToZone { entity, zone: entity },
            GameEvent::ZoneShuffled { zone: entity },
            GameEvent::ZoneExhausted { zone: entity },
            GameEvent::ZoneReshuffled { zone: entity },
            GameEvent::GroupDecisionStarted {
                id: DecisionId(0),
                config: GroupDecisionConfig {
                    input:        DecisionInput::Choice { options: vec!["a".into()] },
                    close:        crate::types::DecisionClose::AllSubmitted,
                    resolution:   DecisionResolution::Plurality,
                    participants: vec![seat],
                },
            },
            GameEvent::GroupDecisionInput {
                id: DecisionId(0),
                seat,
                value: DecisionInputValue::Choice(0),
            },
            GameEvent::GroupDecisionResolved {
                id: DecisionId(0),
                outcome: DecisionOutcome::Tie,
            },
            GameEvent::Submitted {
                seat,
                action: Box::new(GameEvent::TurnEnded { seat }),
            },
            GameEvent::PoolClosed { pool_id: entity },
            GameEvent::PoolResolved { pool_id: entity, outcomes: Vec::new() },
            GameEvent::GraphMutated { ops: Vec::new() },
            GameEvent::SourceRequestProposed {
                seat,
                entity_type: EntityTypeId::from_raw(0),
                source: crate::predicate::types::SourceKey::new("test:src"),
            },
            GameEvent::EntityCreationRequested {
                type_id: EntityTypeId::from_raw(0),
                by_seat: seat,
                prefill: std::collections::HashMap::new(),
            },
            GameEvent::EntityLevelUpRequested {
                entity: EntityId::from_raw(0),
                by_seat: seat,
                target_level: 1,
            },
            GameEvent::SheetEdited {
                entity: EntityId::from_raw(0),
                by_seat: seat,
                mutations: Vec::new(),
            },
            GameEvent::UndoRequested {
                actor:      Some(seat),
                target_seq: 0,
                reason:     None,
            },
            GameEvent::UndoExecuted {
                target_root_seq: 0,
                marked_undone:   Vec::new(),
                approved_by:     Some(seat),
            },
            GameEvent::RedoRequested {
                actor:           Some(seat),
                target_undo_seq: 0,
                reason:          None,
            },
            GameEvent::RedoExecuted {
                target_undo_seq: 0,
                unmarked:        Vec::new(),
            },
        ]
    }

    /// Listing of every `EventKind` variant. Inside the SDK crate this match
    /// is exhaustive (EventKind is not `#[non_exhaustive]`), so a new variant
    /// here forces a compile error until added.
    fn all_event_kinds() -> Vec<EventKind> {
        // Build from a dummy match-on-self to force exhaustiveness.
        // Since EventKind is Copy, we list each variant explicitly here;
        // the helper below verifies completeness against `one_per_game_event`.
        vec![
            EventKind::EntityCreated,
            EventKind::EntityDestroyed,
            EventKind::Moved,
            EventKind::Deployed,
            EventKind::OwnerChanged,
            EventKind::Inserted,
            EventKind::Removed,
            EventKind::Revealed,
            EventKind::Concealed,
            EventKind::Morphed,
            EventKind::FeaturedChanged,
            EventKind::PoolChanged,
            EventKind::PoolDepleted,
            EventKind::PoolFilled,
            EventKind::Activated,
            EventKind::TurnAdvanced,
            EventKind::TurnEnded,
            EventKind::TurnOrderResolved,
            EventKind::TurnNominated,
            EventKind::TurnNominationAccepted,
            EventKind::TurnStateChanged,
            EventKind::PhaseEntered,
            EventKind::PhaseRestarted,
            EventKind::PhaseTransitioned,
            EventKind::PhasePushed,
            EventKind::PhasePopped,
            EventKind::DiceRollRequested,
            EventKind::DiceRolled,
            EventKind::TagAdded,
            EventKind::TagRemoved,
            EventKind::NarrationResolved,
            EventKind::Attached,
            EventKind::Detached,
            EventKind::TimerExpired,
            EventKind::TurnOrderSet,
            EventKind::Drew,
            EventKind::DeckExhausted,
            EventKind::DeckReshuffled,
            EventKind::VoteResolved,
            EventKind::AuctionResolved,
            EventKind::PerceivabilityChanged,
            EventKind::TraitAdded,
            EventKind::TraitRemoved,
            EventKind::MovedToZone,
            EventKind::ZoneShuffled,
            EventKind::ZoneExhausted,
            EventKind::ZoneReshuffled,
            EventKind::GroupDecisionStarted,
            EventKind::GroupDecisionInput,
            EventKind::GroupDecisionResolved,
            EventKind::Submitted,
            EventKind::PoolClosed,
            EventKind::PoolResolved,
            EventKind::GraphMutated,
            EventKind::SourceRequestProposed,
            EventKind::EntityCreationRequested,
            EventKind::EntityLevelUpRequested,
            EventKind::SheetEdited,
            EventKind::UndoRequested,
            EventKind::UndoExecuted,
            EventKind::RedoRequested,
            EventKind::RedoExecuted,
        ]
    }

    /// Compile-time guarantee: every EventKind variant is named in
    /// `all_event_kinds`. If a new EventKind variant lands without
    /// being added to that vec, this match fails to compile.
    #[allow(dead_code)]
    fn assert_all_kinds_listed(k: EventKind) -> &'static str {
        match k {
            EventKind::EntityCreated => "",
            EventKind::EntityDestroyed => "",
            EventKind::Moved => "",
            EventKind::Deployed => "",
            EventKind::OwnerChanged => "",
            EventKind::Inserted => "",
            EventKind::Removed => "",
            EventKind::Revealed => "",
            EventKind::Concealed => "",
            EventKind::Morphed => "",
            EventKind::FeaturedChanged => "",
            EventKind::PoolChanged => "",
            EventKind::PoolDepleted => "",
            EventKind::PoolFilled => "",
            EventKind::Activated => "",
            EventKind::TurnAdvanced => "",
            EventKind::TurnEnded => "",
            EventKind::TurnOrderResolved => "",
            EventKind::TurnNominated => "",
            EventKind::TurnNominationAccepted => "",
            EventKind::TurnStateChanged => "",
            EventKind::PhaseEntered => "",
            EventKind::PhaseRestarted => "",
            EventKind::PhaseTransitioned => "",
            EventKind::PhasePushed => "",
            EventKind::PhasePopped => "",
            EventKind::DiceRollRequested => "",
            EventKind::DiceRolled => "",
            EventKind::TagAdded => "",
            EventKind::TagRemoved => "",
            EventKind::NarrationResolved => "",
            EventKind::Attached => "",
            EventKind::Detached => "",
            EventKind::TimerExpired => "",
            EventKind::TurnOrderSet => "",
            EventKind::Drew => "",
            EventKind::DeckExhausted => "",
            EventKind::DeckReshuffled => "",
            EventKind::VoteResolved => "",
            EventKind::AuctionResolved => "",
            EventKind::PerceivabilityChanged => "",
            EventKind::TraitAdded => "",
            EventKind::TraitRemoved => "",
            EventKind::MovedToZone => "",
            EventKind::ZoneShuffled => "",
            EventKind::ZoneExhausted => "",
            EventKind::ZoneReshuffled => "",
            EventKind::GroupDecisionStarted => "",
            EventKind::GroupDecisionInput => "",
            EventKind::GroupDecisionResolved => "",
            EventKind::Submitted => "",
            EventKind::PoolClosed => "",
            EventKind::PoolResolved => "",
            EventKind::GraphMutated => "",
            EventKind::SourceRequestProposed => "",
            EventKind::EntityCreationRequested => "",
            EventKind::EntityLevelUpRequested => "",
            EventKind::SheetEdited => "",
            EventKind::UndoRequested => "",
            EventKind::UndoExecuted => "",
            EventKind::RedoRequested => "",
            EventKind::RedoExecuted => "",
            EventKind::WizardOpened => "",
            EventKind::WizardChoiceProposed => "",
            EventKind::WizardResolved => "",
        }
    }

    #[test]
    fn every_event_kind_has_at_least_one_game_event() {
        let events = one_per_game_event();
        let kinds: std::collections::HashSet<EventKind> =
            events.iter().map(|e| e.kind()).collect();
        let all_kinds = all_event_kinds();

        for &kind in &all_kinds {
            assert!(
                kinds.contains(&kind),
                "EventKind::{:?} has no GameEvent variant in one_per_game_event()",
                kind,
            );
        }
        assert_eq!(
            kinds.len(),
            all_kinds.len(),
            "EventKind has {} variants but one_per_game_event() produced {} distinct kinds â€” check for divergence",
            all_kinds.len(),
            kinds.len(),
        );
    }
}

// ---------------------------------------------------------------------------
// Suspension â€” structured decision gate
// ---------------------------------------------------------------------------

/// UC-08 F6 — opaque identifier stamped onto a suspension when it's
/// raised. Used by the state-hash conflict-detection path on
/// `ClientFrame::SubmitWizardChoice`: the client echoes back
/// `blake3(id.to_le_bytes() || current_seq.to_le_bytes())` so the
/// server can reject stale resolutions (e.g. when another GM resolved
/// the same suspension between this client receiving it and clicking).
///
/// Today the engine stamps `SuspensionId(session.log.len())` at the
/// instant the suspension is parked in `pending_suspension`. That value
/// is monotonically unique within a session — exactly the property
/// needed for the hash input.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct SuspensionId(pub u64);

impl SuspensionId {
    pub const fn raw(self) -> u64 { self.0 }
}

/// UC-08 F6 — compute the suspension state hash used by the
/// state-hash conflict-detection path. Inputs: the SuspensionId
/// stamped at raise time and the session log length at resolve time.
/// Both client and server compute the same value; mismatch means
/// another resolution landed first.
///
/// Lives here so client and server share one definition.
pub fn compute_suspension_state_hash(id: SuspensionId, current_seq: u64) -> [u8; 32] {
    let mut input = [0u8; 16];
    input[..8].copy_from_slice(&id.0.to_le_bytes());
    input[8..].copy_from_slice(&current_seq.to_le_bytes());
    *blake3::hash(&input).as_bytes()
}

/// A decision gate that pauses the pipeline until a decider responds.
///
/// One prompt, one set of options (or a `DiceInput`/`TextInput`/`Race` kind).
/// Resolved with a single client round-trip via `ClientFrame::ResolveSuspension*`.
///
/// Multi-step wizards use `StagedDecision` / `Decision::Wizard` instead (SD1-D).
// CC5: plugin SuspensionRenderer hooks match on Suspension variants; new variants are planned (Staged).
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub enum Suspension {
    Single {
        prompt: String,
        decider: Decider,
        options: Vec<SuspensionOption>,
        /// Richer input discriminator. Defaults to `Choose`.
        #[cfg_attr(feature = "serde", serde(default))]
        kind: SuspensionKind,
        /// CD1: cascade-overflow recovery suspensions carry the chain
        /// of breadcrumbs that got us here. `None` for plugin-emitted
        /// suspensions. `Option<T>` encodes compactly via postcard's
        /// presence byte; `serde(default)` keeps JSON fixtures
        /// forward-compatible. *No `skip_serializing_if` — the wire
        /// format is positional and would mis-parse if a field were
        /// dropped at encode time.*
        #[cfg_attr(feature = "serde", serde(default))]
        cascade_trace: Option<crate::cascade_trace::CascadeTrace>,
    },
}

impl Default for Suspension {
    fn default() -> Self {
        Suspension::Single {
            prompt: String::new(),
            decider: Decider::Narrator,
            options: vec![],
            kind: SuspensionKind::Choose,
            cascade_trace: None,
        }
    }
}

impl Suspension {
    pub fn prompt(&self) -> &str {
        let Suspension::Single { prompt, .. } = self;
        prompt.as_str()
    }

    pub fn decider(&self) -> &Decider {
        let Suspension::Single { decider, .. } = self;
        decider
    }

    pub fn kind(&self) -> &SuspensionKind {
        let Suspension::Single { kind, .. } = self;
        kind
    }

    pub fn options(&self) -> &[SuspensionOption] {
        let Suspension::Single { options, .. } = self;
        options.as_slice()
    }

    /// UC-08 F6 — which seat's action triggered this suspension?
    /// Delegates to `trigger.actor_seat()`. The `trigger` argument is
    /// the original `GameEvent` that produced this suspension (carried
    /// alongside the suspension in `sus_queue.pending_original_event`
    /// rather than inside the Suspension itself — keeps the wire shape
    /// flat and avoids duplicating event data).
    pub fn affected_seat(&self, trigger: &GameEvent) -> Option<crate::types::SeatId> {
        let Suspension::Single { .. } = self;
        trigger.actor_seat()
    }

    /// UC-08 F6 — which pool (if any) this suspension involves.
    /// Delegates to `trigger.pool_id()`.
    pub fn affected_pool<'a>(&self, trigger: &'a GameEvent) -> Option<&'a crate::types::PoolId> {
        let Suspension::Single { .. } = self;
        trigger.pool_id()
    }
}

/// What kind of input the suspension expects from the decider. Plugins can
/// register a `SuspensionRenderer` hook matching on the kind to style the
/// prompt; default widgets handle all kinds out of the box.
// CC5: SuspensionRenderer hooks in plugins match on SuspensionKind to style prompts.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Default)]
pub enum SuspensionKind {
    /// The classic case: pick one of `options` by index. Default.
    #[default]
    Choose,
    /// Decider enters dice results manually (physical dice at the table, or
    /// virtual). `spec` describes the expected roll shape so the UI can
    /// validate.
    DiceInput { spec: crate::types::DiceSpec },
    /// Free-form text narration (GM rulings). The client echoes the text to
    /// the log; no mechanical effect unless the handler is interpreting it.
    TextInput { placeholder: String },
    /// First decider to act wins. The client shows a "claim" button visible
    /// to every eligible player; server accepts whichever arrives first.
    Race,
}

// CC5: plugin handlers check the Decider to decide if they should respond.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Decider {
    #[default]
    Narrator,
    Player(PlayerId),
    EntityOwner(EntityId),
    /// 0680: target a specific seat. The client resolves this against
    /// `vm.my_seats` to decide whether to surface the wizard /
    /// suspension. Used by engine-composed wizards that key off
    /// the originating `by_seat` of the trigger event (creation,
    /// level-up) — the engine doesn't know SeatId→PlayerId mapping
    /// (that's host state in `seat_assignments`), so it expresses
    /// targeting in seat coordinates and the client matches.
    Seat(SeatId),
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct SuspensionOption {
    pub label: String,
    pub effects: Vec<GameEvent>,
    /// Pipeline result that REPLACES the original event's outcome when this option is chosen (S07).
    ///
    /// - `None` (default): after applying `effects`, the original event commits as Allow.
    /// - `Some(Decision::Deny(reason))`: original event is Denied; effects still applied.
    /// - `Some(Decision::Modify(new_event))`: original event is replaced with `new_event`.
    ///
    /// Enables Counterspell/Shield/Moat-style reactions where the player choice
    /// short-circuits the triggering event after the reaction's costs resolve.
    #[cfg_attr(feature = "serde", serde(default))]
    pub pipeline_result: Option<crate::handler::Decision>,
}

impl SuspensionOption {
    /// Construct an option that just emits effects and lets the original event Allow through.
    pub fn new(label: impl Into<String>, effects: Vec<GameEvent>) -> Self {
        Self { label: label.into(), effects, pipeline_result: None }
    }

    /// Build a "deny the triggering event" option (Counterspell-style).
    /// `effects` apply first (e.g. spend reaction/slot), then the original event is Denied.
    pub fn denying(label: impl Into<String>, reason: impl Into<String>, effects: Vec<GameEvent>) -> Self {
        Self {
            label: label.into(),
            effects,
            pipeline_result: Some(crate::handler::Decision::Deny(reason.into())),
        }
    }

    /// Build a "modify the triggering event" option.
    pub fn modifying(label: impl Into<String>, replacement: GameEvent, effects: Vec<GameEvent>) -> Self {
        Self {
            label: label.into(),
            effects,
            pipeline_result: Some(crate::handler::Decision::Modify(replacement)),
        }
    }
}

impl Suspension {
    pub fn narrator(prompt: impl Into<String>) -> Self {
        Self::Single {
            prompt: prompt.into(),
            decider: Decider::Narrator,
            options: vec![],
            kind: SuspensionKind::Choose,
            cascade_trace: None,
        }
    }

    pub fn player(player: PlayerId, prompt: impl Into<String>, options: Vec<SuspensionOption>) -> Self {
        Self::Single {
            prompt: prompt.into(),
            decider: Decider::Player(player),
            options,
            kind: SuspensionKind::Choose,
            cascade_trace: None,
        }
    }

    /// Ask the decider for manual dice results matching `spec`.
    pub fn dice_input(player: PlayerId, prompt: impl Into<String>, spec: crate::types::DiceSpec) -> Self {
        Self::Single {
            prompt: prompt.into(),
            decider: Decider::Player(player),
            options: vec![],
            kind: SuspensionKind::DiceInput { spec },
            cascade_trace: None,
        }
    }

    /// Ask the decider for free text (GM ruling).
    pub fn text_input(decider: Decider, prompt: impl Into<String>, placeholder: impl Into<String>) -> Self {
        Self::Single {
            prompt: prompt.into(),
            decider,
            options: vec![],
            kind: SuspensionKind::TextInput { placeholder: placeholder.into() },
            cascade_trace: None,
        }
    }

    /// First player to claim wins â€” "who goes next?" decisions.
    pub fn race(prompt: impl Into<String>) -> Self {
        Self::Single {
            prompt: prompt.into(),
            decider: Decider::Narrator, // anyone eligible can respond
            options: vec![],
            kind: SuspensionKind::Race,
            cascade_trace: None,
        }
    }

    /// CD1: cascade-depth-limit recovery. Two real options — "Roll back"
    /// (the default-correct answer; the propose chain didn't reach a
    /// fixed point, keep pre-propose state) and "Commit cascade up to
    /// this depth" (DANGEROUS — surfaced for genuine deep-but-finite
    /// cascades). Heuristic prompt copy mentions recursion when three
    /// or more consecutive crumbs share an `emitted_by` handler.
    pub fn depth_limit(trace: crate::cascade_trace::CascadeTrace) -> Self {
        let recursive = trace.looks_recursive();
        let depth = trace.len();
        let prompt = if recursive {
            format!("Cascade depth limit reached — possible reactor recursion (depth {depth})")
        } else {
            format!("Cascade depth limit reached at depth {depth}")
        };
        Self::Single {
            prompt,
            decider: Decider::Narrator,
            options: vec![
                SuspensionOption {
                    label: "Roll back this propose".into(),
                    effects: vec![],
                    pipeline_result: Some(crate::handler::Decision::Deny(
                        "narrator rolled back cascade-overflow propose".into(),
                    )),
                },
                SuspensionOption {
                    label: "Commit cascade up to this depth (DANGEROUS)".into(),
                    effects: vec![],
                    pipeline_result: Some(crate::handler::Decision::Allow),
                },
            ],
            kind: SuspensionKind::Choose,
            cascade_trace: Some(trace),
        }
    }

    /// CD1: modify-restart-limit recovery. Same shape as `depth_limit`
    /// but a different prompt (modify oscillation, not depth).
    pub fn modify_limit(trace: crate::cascade_trace::CascadeTrace) -> Self {
        let restarts = trace.len();
        Self::Single {
            prompt: format!(
                "Modify restart limit reached after {restarts} oscillation(s)"
            ),
            decider: Decider::Narrator,
            options: vec![
                SuspensionOption {
                    label: "Roll back this propose".into(),
                    effects: vec![],
                    pipeline_result: Some(crate::handler::Decision::Deny(
                        "narrator rolled back modify-restart-overflow propose".into(),
                    )),
                },
                SuspensionOption {
                    label: "Commit current event despite oscillation (DANGEROUS)".into(),
                    effects: vec![],
                    pipeline_result: Some(crate::handler::Decision::Allow),
                },
            ],
            kind: SuspensionKind::Choose,
            cascade_trace: Some(trace),
        }
    }

    /// Read accessor for the cascade trace, if any.
    pub fn cascade_trace(&self) -> Option<&crate::cascade_trace::CascadeTrace> {
        let Suspension::Single { cascade_trace, .. } = self;
        cascade_trace.as_ref()
    }
}

// ---------------------------------------------------------------------------
// RuleResult — retired in favor of `HandlerResponse` + `Decision` (see
// `core/sdk/src/handler.rs`). The enum has been deleted. New handlers
// return `HandlerResponse` (or bare `Decision`, which converts via `From`).
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// ProposeResult â€” what the session returns to callers
// ---------------------------------------------------------------------------

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
#[must_use = "ProposeResult may be Denied or Suspended -- ignoring it loses the error"]
#[non_exhaustive]
pub enum ProposeResult {
    Committed(Vec<GameEvent>),
    Denied(String),
    Suspended(Suspension),
    /// SD1: a Filter or Modify handler returned `Decision::Wizard`,
    /// pausing the pipeline pending wizard resolution. The session's
    /// `sus_queue.pending_decision` carries the same `StagedDecision`;
    /// clients call `submit_choice` to drive it.
    WizardOpened(crate::staged_decision::StagedDecision),
}

impl ProposeResult {
    pub fn is_committed(&self) -> bool {
        matches!(self, Self::Committed(_))
    }

    pub fn is_denied(&self) -> bool {
        matches!(self, Self::Denied(_))
    }

    pub fn deny_reason(&self) -> Option<&str> {
        if let Self::Denied(msg) = self { Some(msg.as_str()) } else { None }
    }

    pub fn committed_events(&self) -> Option<&[GameEvent]> {
        if let Self::Committed(events) = self { Some(events) } else { None }
    }
}

// ---------------------------------------------------------------------------
// Event log
// ---------------------------------------------------------------------------

/// One handler's contribution during the Modify phase. Records the event as
/// the handler received it (`before`) and as the handler returned it
/// (`after`), plus a handler identifier for diagnostics.
///
/// v1 records only **actual** modifications (handlers that returned
/// `Decision::Modify(_)`). No-op handlers do not appear in the trail.
///
/// See `docs/superpowers/specs/2026-04-30-composable-rules-and-capability-layer.md`
/// § 8 "Modification audit trail on LogEntry".
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ModificationRecord {
    /// Handler that produced the modification (the `&'static str` registered
    /// with `NamedHandler.name`). Stored as `String` for wire compatibility.
    pub handler_id: String,
    /// Event as the handler received it.
    pub before: GameEvent,
    /// Event as the handler returned it (after `Decision::Modify`).
    pub after: GameEvent,
    /// Optional handler-supplied diagnostic. v1: always `None`; future
    /// handlers may populate this via a richer return shape.
    #[cfg_attr(feature = "serde", serde(default))]
    pub note: Option<String>,
}

/// Structural marker on a log entry — used to anchor undo scopes and to
/// label log navigation in the client. Most entries have `tag: None`; only
/// entries that begin or end a structural episode (turn / round / combat /
/// phase) carry a tag.
///
/// `TurnStart` is emitted automatically by the engine on `TurnAdvanced`
/// commit. `RoundStart`, `RoundEnd`, `CombatStart`, `CombatEnd` are
/// plugin-defined — plugins emit them via dedicated events that the engine
/// tags here when the dedicated emission sites land. `PhaseTransition` is
/// emitted automatically on `PhaseTransitioned` commit.
///
/// See `docs/superpowers/specs/2026-05-01-uc-08-undo-with-configurable-scope.md`
/// § 3 "State reconstruction — log + tags + snapshots".
// CC5: plugins use log-tag queries for context; new tags will be added as phase/combat features land.
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LogTag {
    /// Start of a turn. `seat` is `Option<SeatId>` for forward-compat with
    /// per-entity turn boundaries (S25): when the active entity has
    /// `Owner::Team` or `Owner::Neutral`, no single SeatId can describe the
    /// stretch. In S24-lite, every emission site has a concrete SeatId so
    /// `seat` is always `Some(seat)`.
    TurnStart {
        seat: Option<crate::types::SeatId>,
        id: crate::types::TagId,
    },
    /// End of a turn. `seat` is `Option<SeatId>` for forward-compat with
    /// S25 (per-entity turn boundaries). `actor_of_seq` walks forward to the
    /// next `TurnEnded` tag and returns this `seat`.
    TurnEnded {
        seat: Option<crate::types::SeatId>,
        id: crate::types::TagId,
    },
    RoundStart {
        number: u32,
        id: crate::types::TagId,
    },
    RoundEnd {
        number: u32,
        id: crate::types::TagId,
    },
    CombatStart {
        id_combat: crate::types::CombatId,
        id: crate::types::TagId,
    },
    CombatEnd {
        id_combat: crate::types::CombatId,
        id: crate::types::TagId,
    },
    PhaseTransition {
        from: crate::types::PhaseId,
        to: crate::types::PhaseId,
        id: crate::types::TagId,
    },
}

impl LogTag {
    /// Returns the unique tag identity. Every boundary LogTag carries
    /// a `TagId` (UC-08-F4) used as the snapshot anchor.
    pub fn id(&self) -> crate::types::TagId {
        match self {
            LogTag::TurnStart { id, .. }
            | LogTag::TurnEnded { id, .. }
            | LogTag::RoundStart { id, .. }
            | LogTag::RoundEnd { id, .. }
            | LogTag::CombatStart { id, .. }
            | LogTag::CombatEnd { id, .. }
            | LogTag::PhaseTransition { id, .. } => *id,
        }
    }
}

/// UC-08-F3: typed source of a `LogEntry.caused_by` reference.
///
/// Distinguishes player decision-roots (`caused_by == None`) from system /
/// capability sources. Decision-root walkers (undo authority resolution,
/// `last_player_action_seq`) treat `Some(Capability(_))` and
/// `Some(System(_))` as non-player roots and skip them when looking for
/// the next player action to authorize.
///
/// `Event(seq)` is the cascade-parent shape: emitted by the pipeline when
/// the entry is a React-phase child of `seq`. This is the most common
/// non-None variant.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CausedBySource {
    /// Cascade-parent in the React tree. `seq` is the parent's
    /// `LogEntry.seq`. Equivalent to UC-08 v1's `Option<u64>::Some(seq)`.
    Event(u64),
    /// Capability fixture (audio scheduler, journal store, …) emitted this
    /// entry as a side-effect. The `name` is the capability's canonical id
    /// (e.g. `"capability:audio"`). Excluded from decision-root walkers.
    Capability(std::borrow::Cow<'static, str>),
    /// Engine-internal system source. Excluded from decision-root walkers.
    System(SystemSource),
}

/// UC-08-F3 — engine-internal source class for `CausedBySource::System`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum SystemSource {
    /// Automatic phase advance / phase machinery.
    Phase,
    /// Timer / temporal expiry fixture.
    Timer,
    /// Scheduled-effect resolution (host scheduler).
    Scheduler,
}

impl CausedBySource {
    /// Returns the cascade-parent sequence if this is an `Event(_)`
    /// variant, else `None`. Used by cascade-tree walkers that only care
    /// about parent linkage and treat capability/system sources as roots.
    pub fn event_seq(&self) -> Option<u64> {
        match self {
            CausedBySource::Event(seq) => Some(*seq),
            CausedBySource::Capability(_) | CausedBySource::System(_) => None,
        }
    }

    /// `true` for the variants that decision-root walkers should *skip
    /// over* when looking for the next player action — capability and
    /// system sources are not player decisions.
    pub fn is_non_player_root(&self) -> bool {
        matches!(self, CausedBySource::Capability(_) | CausedBySource::System(_))
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct LogEntry {
    pub seq: u64,
    pub event: GameEvent,
    pub caused_by: Option<CausedBySource>,
    /// Sequence of Modify-phase handler invocations that contributed to the
    /// final committed `event`. Empty if the event passed Modify unchanged.
    /// `modified_by[0]` is the first modification; the last record's `after`
    /// matches the committed `event`.
    #[cfg_attr(feature = "serde", serde(default))]
    pub modified_by: Vec<ModificationRecord>,
    /// Whether this entry has been marked as undone via an `UndoExecuted`
    /// cascade. Replay-with-skip filters these out. Default `false`.
    ///
    /// Defaults via `#[serde(default)]` for backward-compatible deserialization
    /// of pre-UC-08 wire payloads / snapshots.
    #[cfg_attr(feature = "serde", serde(default))]
    pub undone: bool,
    /// Optional structural tag. Most entries have `None`; entries at the
    /// start/end of a turn / round / combat / phase carry the appropriate
    /// `LogTag`. Used as navigation labels in the client and as anchors for
    /// undo-scope queries (`UndoHorizon::floor_seq`).
    ///
    /// Defaults via `#[serde(default)]` for backward-compatible deserialization.
    #[cfg_attr(feature = "serde", serde(default))]
    pub tag: Option<LogTag>,
}

impl LogEntry {
    /// UC-08-F3 convenience: returns the cascade-parent sequence if
    /// `caused_by` is `Some(CausedBySource::Event(seq))`. Returns `None`
    /// for player roots, capability sources, and system sources alike.
    pub fn cause_event_seq(&self) -> Option<u64> {
        self.caused_by.as_ref().and_then(|s| s.event_seq())
    }

    /// `true` iff this entry is a player decision-root — `caused_by`
    /// is `None`. Capability and system sources return `false`.
    pub fn is_player_root(&self) -> bool {
        self.caused_by.is_none()
    }
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone)]
pub struct EventLog {
    entries: Vec<LogEntry>,
    next_seq: u64,
}

impl EventLog {
    pub fn new() -> Self {
        Self::default()
    }

    /// Append a cascade entry (the common case). `caused_by: Some(seq)`
    /// wraps into `CausedBySource::Event(seq)`; `None` marks the entry
    /// as a player decision-root. For capability / system sources use
    /// [`append_with_source`].
    pub fn append(
        &mut self,
        event: GameEvent,
        caused_by: Option<u64>,
        modified_by: Vec<ModificationRecord>,
    ) -> u64 {
        self.append_tagged(event, caused_by, modified_by, None)
    }

    /// UC-08-F3: append with an explicit typed `CausedBySource`. Use
    /// this for capability fixture emissions and engine-internal system
    /// sources (timer ticks, auto-advance, scheduler).
    pub fn append_with_source(
        &mut self,
        event: GameEvent,
        caused_by: Option<CausedBySource>,
        modified_by: Vec<ModificationRecord>,
    ) -> u64 {
        self.append_tagged_with_source(event, caused_by, modified_by, None)
    }

    /// Append a log entry with an optional structural [`LogTag`].
    /// `undone` always starts `false`; it is flipped later by an
    /// `UndoExecuted` cascade (Phase F of UC-08).
    pub fn append_tagged(
        &mut self,
        event: GameEvent,
        caused_by: Option<u64>,
        modified_by: Vec<ModificationRecord>,
        tag: Option<LogTag>,
    ) -> u64 {
        let source = caused_by.map(CausedBySource::Event);
        self.append_tagged_with_source(event, source, modified_by, tag)
    }

    /// UC-08-F3: tagged + typed source. The primitive that
    /// `append`, `append_with_source`, and `append_tagged` all delegate
    /// to.
    pub fn append_tagged_with_source(
        &mut self,
        event: GameEvent,
        caused_by: Option<CausedBySource>,
        modified_by: Vec<ModificationRecord>,
        tag: Option<LogTag>,
    ) -> u64 {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.entries.push(LogEntry {
            seq,
            event,
            caused_by,
            modified_by,
            undone: false,
            tag,
        });
        seq
    }

    pub fn get(&self, seq: u64) -> Option<&LogEntry> {
        self.entries.get(seq as usize)
    }

    pub fn range(&self, start: u64, end: u64) -> impl Iterator<Item = &LogEntry> {
        let len = self.entries.len();
        let start = (start as usize).min(len);
        let end = (end as usize).min(len);
        let end = end.max(start);
        self.entries.get(start..end).into_iter().flatten()
    }

    /// Inclusive range over `[start, end_inclusive]`, clamped to the log
    /// length. Used by UC-08 Phase C4 replay-with-skip
    /// (`GameSession::reconstruct_state_at`). Returns an empty iterator
    /// when `start > end_inclusive` or the range falls entirely past
    /// the log tail.
    pub fn range_of_seqs(
        &self,
        start: u64,
        end_inclusive: u64,
    ) -> impl Iterator<Item = &LogEntry> {
        if start > end_inclusive {
            return self.entries.get(0..0).into_iter().flatten();
        }
        let len = self.entries.len();
        let start_i = (start as usize).min(len);
        let end_i = ((end_inclusive as usize).saturating_add(1)).min(len);
        let end_i = end_i.max(start_i);
        self.entries.get(start_i..end_i).into_iter().flatten()
    }

    pub fn all(&self) -> &[LogEntry] {
        &self.entries
    }

    /// Return all entries from `seq` onward (inclusive). Safe if seq > len.
    pub fn since(&self, seq: u64) -> &[LogEntry] {
        let start = (seq as usize).min(self.entries.len());
        self.entries.get(start..).unwrap_or(&[])
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Mark the entry at `seq` as `undone` (or unset it). Returns `true`
    /// if the seq existed and was updated. Used by UC-08 Phase F to apply
    /// a settled `UndoExecuted` cascade to the log; tests use it to
    /// simulate "what if these entries had been undone" before the
    /// proper undo events ship in Phase D.
    pub fn set_undone(&mut self, seq: u64, undone: bool) -> bool {
        match self.entries.get_mut(seq as usize) {
            Some(entry) => { entry.undone = undone; true }
            None => false,
        }
    }

    pub fn truncate(&mut self, len: usize) {
        self.entries.truncate(len);
        self.next_seq = len as u64;
    }

    /// PS1 — Replace the log's contents with a slice of already-persisted
    /// entries (preserving their original `seq` values). `next_seq` is set
    /// to one past the last entry, or 0 if `entries` is empty.
    /// Used by `restore_from_event_log_store` so the live log reflects the
    /// stored history without re-running the pipeline.
    pub fn load_entries(&mut self, entries: Vec<LogEntry>) {
        let next = entries.last().map(|e| e.seq + 1).unwrap_or(0);
        self.entries = entries;
        self.next_seq = next;
    }
}

// ---------------------------------------------------------------------------
// Wire batch types
// ---------------------------------------------------------------------------

/// Per-player filtered view of a committed event.
/// Redacted preserves sequence continuity when content is hidden by Observable rules.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
// wire type: GameEvent size is acceptable here; Vec<VisibleEvent> already heap-allocates
#[allow(clippy::large_enum_variant)]
pub enum VisibleEvent {
    Full(GameEvent),
    Redacted { seq: u64 },
}

/// One cascade's worth of events, sent atomically after full resolution.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct EventBatch {
    pub seq_start: u64,
    pub seq_end:   u64,
    pub events:    Vec<VisibleEvent>,
}

// ---------------------------------------------------------------------------
// LogTag / LogEntry.undone / LogEntry.tag tests (UC-08 Phase A1)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod log_tag_tests {
    use super::{EventLog, GameEvent, LogEntry, LogTag, ModificationRecord};
    use crate::board::FaceId;
    use crate::types::{EntityId, Position, SeatId};
    #[cfg(feature = "serde")]
    use crate::types::{CombatId, PhaseId};

    fn move_event() -> GameEvent {
        GameEvent::Moved {
            entity: EntityId::from_raw(1),
            from: Position::Face(FaceId(0)),
            to: Position::Face(FaceId(1)),
            qualifiers: Vec::new(),
        }
    }

    #[test]
    fn append_defaults_undone_false_and_tag_none() {
        let mut log = EventLog::new();
        let seq = log.append(move_event(), None, Vec::new());
        let entry = log.get(seq).expect("entry");
        assert!(!entry.undone);
        assert_eq!(entry.tag, None);
    }

    #[test]
    fn append_tagged_sets_tag_and_keeps_undone_false() {
        use crate::types::TagId;
        let tag_id = TagId::new();
        let mut log = EventLog::new();
        let seq = log.append_tagged(
            GameEvent::TurnAdvanced { seat: SeatId::from_raw(2) },
            None,
            Vec::new(),
            Some(LogTag::TurnStart { seat: Some(SeatId::from_raw(2)), id: tag_id }),
        );
        let entry = log.get(seq).expect("entry");
        assert!(!entry.undone);
        assert_eq!(entry.tag, Some(LogTag::TurnStart { seat: Some(SeatId::from_raw(2)), id: tag_id }));
    }

    #[test]
    fn log_entry_struct_init_sets_new_fields() {
        use crate::types::TagId;
        // Direct struct initialization works (tests/code constructs LogEntry
        // explicitly in some cases). Both new fields are required by the
        // compiler — but #[serde(default)] supplies defaults on the wire.
        let tag_id = TagId::new();
        let entry = LogEntry {
            seq: 0,
            event: move_event(),
            caused_by: None,
            modified_by: Vec::new(),
            undone: true,
            tag: Some(LogTag::TurnEnded { seat: Some(SeatId::from_raw(0)), id: tag_id }),
        };
        assert!(entry.undone);
        assert_eq!(entry.tag, Some(LogTag::TurnEnded { seat: Some(SeatId::from_raw(0)), id: tag_id }));
    }

    #[cfg(feature = "serde")]
    #[test]
    fn log_entry_round_trip_preserves_undone_and_tag() {
        use crate::types::TagId;
        let tag_id = TagId::new();
        let entry = LogEntry {
            seq: 7,
            event: GameEvent::TurnAdvanced { seat: SeatId::from_raw(3) },
            caused_by: Some(super::CausedBySource::Event(6)),
            modified_by: Vec::new(),
            undone: true,
            tag: Some(LogTag::TurnStart { seat: Some(SeatId::from_raw(3)), id: tag_id }),
        };
        let json = serde_json::to_string(&entry).expect("serialize");
        let decoded: LogEntry = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(decoded.seq, 7);
        assert!(decoded.undone);
        assert_eq!(decoded.tag, Some(LogTag::TurnStart { seat: Some(SeatId::from_raw(3)), id: tag_id }));
    }

    #[cfg(feature = "serde")]
    #[test]
    fn log_entry_legacy_payload_defaults_undone_and_tag() {
        // Backward-compat: a payload from before UC-08 lacks `undone` and `tag`.
        // `#[serde(default)]` should fill in `false` and `None`.
        let legacy_json = serde_json::json!({
            "seq": 4,
            "event": GameEvent::TurnAdvanced { seat: SeatId::from_raw(1) },
            "caused_by": null,
            "modified_by": [],
        });
        let decoded: LogEntry = serde_json::from_value(legacy_json).expect("legacy parse");
        assert_eq!(decoded.seq, 4);
        assert!(!decoded.undone, "missing undone defaults to false");
        assert_eq!(decoded.tag, None, "missing tag defaults to None");
    }

    #[cfg(feature = "serde")]
    #[test]
    fn log_tag_round_trip_each_variant() {
        use crate::types::TagId;
        let tid = TagId::new();
        let tags = vec![
            LogTag::TurnStart { seat: Some(SeatId::from_raw(1)), id: tid },
            LogTag::TurnEnded { seat: Some(SeatId::from_raw(1)), id: tid },
            LogTag::RoundStart { number: 5, id: tid },
            LogTag::RoundEnd { number: 5, id: tid },
            LogTag::CombatStart { id_combat: CombatId::from_raw(42), id: tid },
            LogTag::CombatEnd { id_combat: CombatId::from_raw(42), id: tid },
            LogTag::PhaseTransition { from: PhaseId::new("test:a"), to: PhaseId::new("test:b"), id: tid },
        ];
        for tag in tags {
            let json = serde_json::to_string(&tag).expect("serialize");
            let decoded: LogTag = serde_json::from_str(&json).expect("deserialize");
            assert_eq!(decoded, tag);
        }
    }

    #[test]
    fn modified_by_record_unaffected_by_new_fields() {
        // Sanity: ModificationRecord shape unchanged.
        let _rec = ModificationRecord {
            handler_id: "h".to_string(),
            before: move_event(),
            after: move_event(),
            note: None,
        };
    }
}
