Skip to main content

ixa/triggers/
property_value_count.rs

1use std::cell::Cell;
2use std::marker::PhantomData;
3use std::rc::Rc;
4
5use super::{Direction, TriggerCriterion, TriggerMode};
6use crate::entity::events::{EntityCreatedEvent, PropertyChangeEvent};
7use crate::entity::property::Property;
8use crate::entity::{ContextEntitiesExt, Entity, EntityId};
9use crate::{Context, EntityPropertyTuple};
10
11/// Trigger criterion for the count of entities with a particular property value.
12///
13/// [`PropertyValueCountTrigger`] observes
14/// [`EntityCreatedEvent`](crate::entity::events::EntityCreatedEvent) and
15/// [`PropertyChangeEvent`](crate::entity::events::PropertyChangeEvent) for a specific
16/// entity/property pair and emits when the count of entities with a configured property value
17/// crosses a configured threshold.
18///
19/// ## Construction
20///
21/// ```rust,ignore
22/// PropertyValueCountTrigger::<E, P>::increases_to(value, threshold)
23/// PropertyValueCountTrigger::<E, P>::decreases_to(value, threshold)
24/// PropertyValueCountTrigger::<E, P>::changes_to(value, threshold)
25/// PropertyValueCountTrigger::<E, P>::changes_to(value, threshold).once()
26/// PropertyValueCountTrigger::<E, P>::changes_to(value, threshold).repeating()
27/// ```
28///
29/// ## Observation
30///
31/// The observation data passed to
32/// [`TriggerCriterion::emit_with`](super::TriggerCriterion::emit_with) is
33/// [`PropertyValueCountTriggerEvent`]. It contains the entity ID whose creation or property write
34/// caused the crossing, the tracked property value, the new count, the observed
35/// [`Direction`](super::Direction), the configured direction filter as `Option<Direction>`, and the
36/// selected [`TriggerMode`](super::TriggerMode):
37///
38/// ```rust,ignore
39/// pub struct PropertyValueCountTriggerEvent<E, P>
40/// where
41///     E: Entity,
42///     P: Property<E>,
43/// {
44///     pub entity_id: EntityId<E>,
45///     pub value: P,
46///     pub count: usize,
47///     pub direction_filter: Option<Direction>,
48///     pub direction: Direction,
49///     pub mode: TriggerMode,
50/// }
51/// ```
52///
53/// ## Semantics
54///
55/// The initial count is measured when the trigger is registered. The criterion emits only on a
56/// later threshold crossing. Since counts change one entity at a time, a crossing occurs when the
57/// new count equals the threshold and differs from the previous count. [`Direction::Increasing`]
58/// means the count increased to the threshold, while [`Direction::Decreasing`] means the count
59/// decreased to the threshold. `changes_to` leaves the direction filter unset and emits for either
60/// observed direction. `increases_to` and `decreases_to` set the direction filter to the
61/// corresponding observed direction.
62///
63/// By default, the criterion uses [`TriggerMode::Repeating`](super::TriggerMode::Repeating) and
64/// emits every time the count crosses the threshold and passes the configured direction filter. Call
65/// [`PropertyValueCountTrigger::once`] to emit only for the first crossing, or
66/// [`PropertyValueCountTrigger::repeating`] to return to the default repeating behavior.
67///
68/// Entity creation can cause a crossing if the new entity has the tracked value. Property writes
69/// can cause a crossing when they move an entity into or out of the tracked value. A no-op write
70/// where `previous == current` still emits a property-change event at the entity layer, but it does
71/// not change this trigger's tracked count and therefore cannot by itself cross the threshold.
72///
73/// ## Example
74///
75/// ```rust
76/// use ixa::{Context, ContextEntitiesExt, define_entity, define_property, IxaEvent};
77/// use ixa::entity::EntityId;
78/// use ixa::triggers::{
79///     ContextTriggersExt, Direction, PropertyValueCountTrigger, TriggerCriterion, TriggerMode,
80/// };
81///
82/// define_entity!(Person);
83/// define_property!(
84///     enum InfectionStatus {
85///         Susceptible,
86///         Infectious,
87///     },
88///     Person,
89///     default_const = InfectionStatus::Susceptible
90/// );
91///
92/// // The event records which person caused us to reach the threshold and
93/// // the value of the threshold itself (as `count`).
94/// #[derive(IxaEvent)]
95/// struct InfectiousThresholdReached {
96///     person: EntityId<Person>,
97///     count: usize
98/// }
99///
100/// let mut context = Context::new();
101///
102/// context.register_trigger(
103///     PropertyValueCountTrigger::increases_to(
104///         InfectionStatus::Infectious,
105///         2,
106///     ).emit_with(|observation| InfectiousThresholdReached {
107///         person: observation.entity_id,
108///         count: observation.count
109///     }),
110/// );
111///
112/// context.subscribe_to_event(|_context, _event: InfectiousThresholdReached| {
113///     // respond when the infectious count crosses from below 2 to at least 2
114/// });
115/// ```
116pub struct PropertyValueCountTrigger<E, P>
117where
118    E: Entity,
119    P: Property<E>,
120{
121    value: P,
122    threshold: usize,
123    direction_filter: Option<Direction>,
124    mode: TriggerMode,
125    _entity: PhantomData<fn() -> E>,
126}
127
128#[derive(Clone, Copy, Debug)]
129pub struct PropertyValueCountTriggerEvent<E, P>
130where
131    E: Entity,
132    P: Property<E>,
133{
134    pub entity_id: EntityId<E>,
135    pub value: P,
136    pub count: usize,
137    pub direction_filter: Option<Direction>,
138    pub direction: Direction,
139    pub mode: TriggerMode,
140}
141
142impl<E, P> PropertyValueCountTrigger<E, P>
143where
144    E: Entity,
145    P: Property<E>,
146{
147    #[must_use]
148    pub fn increases_to(value: P, threshold: usize) -> Self {
149        Self {
150            value,
151            threshold,
152            direction_filter: Some(Direction::Increasing),
153            mode: TriggerMode::Repeating,
154            _entity: PhantomData,
155        }
156    }
157
158    #[must_use]
159    pub fn decreases_to(value: P, threshold: usize) -> Self {
160        Self {
161            value,
162            threshold,
163            direction_filter: Some(Direction::Decreasing),
164            mode: TriggerMode::Repeating,
165            _entity: PhantomData,
166        }
167    }
168
169    #[must_use]
170    pub fn changes_to(value: P, threshold: usize) -> Self {
171        Self {
172            value,
173            threshold,
174            direction_filter: None,
175            mode: TriggerMode::Repeating,
176            _entity: PhantomData,
177        }
178    }
179
180    #[must_use]
181    pub fn once(mut self) -> Self {
182        self.mode = TriggerMode::Once;
183        self
184    }
185
186    #[must_use]
187    pub fn repeating(mut self) -> Self {
188        self.mode = TriggerMode::Repeating;
189        self
190    }
191}
192
193impl<E, P> TriggerCriterion for PropertyValueCountTrigger<E, P>
194where
195    E: Entity,
196    P: Property<E>,
197{
198    type Observation = PropertyValueCountTriggerEvent<E, P>;
199
200    fn install<F>(self, context: &mut Context, on_match: F)
201    where
202        F: Fn(&mut Context, Self::Observation) + 'static,
203    {
204        match self.mode {
205            TriggerMode::Once => {
206                let state = Rc::new(Cell::new(CountTriggerState {
207                    active: true,
208                    count: context
209                        .query_entity_count(EntityPropertyTuple::<E, _>::new((self.value,))),
210                }));
211                let on_match = Rc::new(on_match);
212
213                context.subscribe_to_event({
214                    let state = Rc::clone(&state);
215                    let on_match = Rc::clone(&on_match);
216                    move |context, event: EntityCreatedEvent<E>| {
217                        let current = context.get_property::<E, P>(event.entity_id);
218                        if current == self.value {
219                            let mut state_value = state.get();
220                            if !state_value.active {
221                                return;
222                            }
223                            let previous_count = state_value.count;
224                            state_value.count += 1;
225                            let direction = Direction::Increasing;
226                            if self.direction_filter != Some(Direction::Decreasing) {
227                                let threshold_crossed = state_value.count == self.threshold
228                                    && previous_count != state_value.count
229                                    && self
230                                        .direction_filter
231                                        .is_none_or(|filter| filter == direction);
232                                if threshold_crossed {
233                                    on_match(
234                                        context,
235                                        PropertyValueCountTriggerEvent {
236                                            entity_id: event.entity_id,
237                                            value: self.value,
238                                            count: state_value.count,
239                                            direction_filter: self.direction_filter,
240                                            direction,
241                                            mode: self.mode,
242                                        },
243                                    );
244                                    state_value.active = false;
245                                }
246                            }
247                            state.set(state_value);
248                        }
249                    }
250                });
251
252                context.subscribe_to_event({
253                    let state = Rc::clone(&state);
254                    let on_match = Rc::clone(&on_match);
255                    move |context, event: PropertyChangeEvent<E, P>| {
256                        let mut state_value = state.get();
257                        if !state_value.active {
258                            return;
259                        }
260                        let previous_count = state_value.count;
261                        state_value.count =
262                            match (event.previous == self.value, event.current == self.value) {
263                                (false, true) => state_value.count + 1,
264                                (true, false) => state_value.count - 1,
265                                _ => state_value.count,
266                            };
267                        let direction = if state_value.count > previous_count {
268                            Some(Direction::Increasing)
269                        } else if state_value.count < previous_count {
270                            Some(Direction::Decreasing)
271                        } else {
272                            None
273                        };
274                        if let Some(direction) = direction {
275                            let threshold_crossed = state_value.count == self.threshold
276                                && self
277                                    .direction_filter
278                                    .is_none_or(|filter| filter == direction);
279                            if threshold_crossed {
280                                on_match(
281                                    context,
282                                    PropertyValueCountTriggerEvent {
283                                        entity_id: event.entity_id,
284                                        value: self.value,
285                                        count: state_value.count,
286                                        direction_filter: self.direction_filter,
287                                        direction,
288                                        mode: self.mode,
289                                    },
290                                );
291                                state_value.active = false;
292                            }
293                        }
294                        state.set(state_value);
295                    }
296                });
297            }
298            TriggerMode::Repeating => {
299                let count = Rc::new(Cell::new(
300                    context.query_entity_count(EntityPropertyTuple::<E, _>::new((self.value,))),
301                ));
302                let on_match = Rc::new(on_match);
303
304                context.subscribe_to_event({
305                    let count = Rc::clone(&count);
306                    let on_match = Rc::clone(&on_match);
307                    move |context, event: EntityCreatedEvent<E>| {
308                        let current = context.get_property::<E, P>(event.entity_id);
309                        if current == self.value {
310                            let previous_count = count.get();
311                            let current_count = previous_count + 1;
312                            count.set(current_count);
313                            let direction = Direction::Increasing;
314                            if self.direction_filter != Some(Direction::Decreasing) {
315                                let threshold_crossed = current_count == self.threshold
316                                    && previous_count != current_count
317                                    && self
318                                        .direction_filter
319                                        .is_none_or(|filter| filter == direction);
320                                if threshold_crossed {
321                                    on_match(
322                                        context,
323                                        PropertyValueCountTriggerEvent {
324                                            entity_id: event.entity_id,
325                                            value: self.value,
326                                            count: current_count,
327                                            direction_filter: self.direction_filter,
328                                            direction,
329                                            mode: self.mode,
330                                        },
331                                    );
332                                }
333                            }
334                        }
335                    }
336                });
337
338                context.subscribe_to_event({
339                    let count = Rc::clone(&count);
340                    let on_match = Rc::clone(&on_match);
341                    move |context, event: PropertyChangeEvent<E, P>| {
342                        let previous_count = count.get();
343                        let current_count =
344                            match (event.previous == self.value, event.current == self.value) {
345                                (false, true) => previous_count + 1,
346                                (true, false) => previous_count - 1,
347                                _ => previous_count,
348                            };
349                        count.set(current_count);
350                        let direction = if current_count > previous_count {
351                            Some(Direction::Increasing)
352                        } else if current_count < previous_count {
353                            Some(Direction::Decreasing)
354                        } else {
355                            None
356                        };
357                        if let Some(direction) = direction {
358                            let threshold_crossed = current_count == self.threshold
359                                && self
360                                    .direction_filter
361                                    .is_none_or(|filter| filter == direction);
362                            if threshold_crossed {
363                                on_match(
364                                    context,
365                                    PropertyValueCountTriggerEvent {
366                                        entity_id: event.entity_id,
367                                        value: self.value,
368                                        count: current_count,
369                                        direction_filter: self.direction_filter,
370                                        direction,
371                                        mode: self.mode,
372                                    },
373                                );
374                            }
375                        }
376                    }
377                });
378            }
379        }
380    }
381}
382
383#[derive(Clone, Copy)]
384struct CountTriggerState {
385    active: bool,
386    count: usize,
387}