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}