1use axioval_ir::{Evidence, ObjectId};
31
32use crate::{PlanArea, PlanAreaError};
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum EffectReach {
37 Grown,
39 Travel,
42 Visible,
45}
46
47impl EffectReach {
48 #[must_use]
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::Grown => "grown",
53 Self::Travel => "travel",
54 Self::Visible => "visible",
55 }
56 }
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub struct Participant {
62 object: ObjectId,
63 certain: bool,
64}
65
66impl Participant {
67 #[must_use]
69 pub fn new(object: ObjectId, certain: bool) -> Self {
70 Self { object, certain }
71 }
72
73 #[must_use]
75 pub fn object(&self) -> &ObjectId {
76 &self.object
77 }
78
79 #[must_use]
81 pub fn is_certain(&self) -> bool {
82 self.certain
83 }
84}
85
86#[derive(Clone, Debug, PartialEq)]
89pub struct CoverageRequest {
90 subject: ObjectId,
91 reach: EffectReach,
92 range: f64,
93 sources: Vec<Participant>,
94 blockers: Vec<Participant>,
95 connected: Vec<Participant>,
96 passages: Vec<Participant>,
97}
98
99impl CoverageRequest {
100 pub fn try_new(
111 subject: ObjectId,
112 reach: EffectReach,
113 range_metres: f64,
114 sources: Vec<Participant>,
115 blockers: Vec<Participant>,
116 ) -> Result<Self, PlanAreaError> {
117 if !range_metres.is_finite() || range_metres < 0.0 {
118 return Err(PlanAreaError::Unavailable(format!(
119 "a range of {range_metres} m is not a non-negative length"
120 )));
121 }
122 let sources = merged(sources);
123 let blockers = merged(blockers);
124 let named = |list: &[Participant], object: &ObjectId| {
125 list.iter()
126 .any(|participant| participant.object() == object)
127 };
128 if named(&sources, &subject) || named(&blockers, &subject) {
129 return Err(PlanAreaError::Unavailable(format!(
130 "{subject} cannot cover or block its own footprint"
131 )));
132 }
133 if let Some(both) = sources
134 .iter()
135 .find(|source| named(&blockers, source.object()))
136 {
137 return Err(PlanAreaError::Unavailable(format!(
138 "{} is both a source and a blocker",
139 both.object()
140 )));
141 }
142 Ok(Self {
143 subject,
144 reach,
145 range: range_metres,
146 sources,
147 blockers,
148 connected: Vec::new(),
149 passages: Vec::new(),
150 })
151 }
152
153 pub fn with_connections(
169 mut self,
170 connected: Vec<Participant>,
171 passages: Vec<Participant>,
172 ) -> Result<Self, PlanAreaError> {
173 if self.reach == EffectReach::Grown && !(connected.is_empty() && passages.is_empty()) {
174 return Err(PlanAreaError::Unavailable(
175 "a grown effect ignores walls, so it continues through no connection".into(),
176 ));
177 }
178 let connected = merged(connected);
179 let passages = merged(passages);
180 let taken = |object: &ObjectId| {
181 *object == self.subject
182 || self
183 .sources
184 .iter()
185 .chain(&self.blockers)
186 .any(|participant| participant.object() == object)
187 };
188 if let Some(clash) = connected
189 .iter()
190 .chain(&passages)
191 .find(|participant| taken(participant.object()))
192 {
193 return Err(PlanAreaError::Unavailable(format!(
194 "{} cannot be a connection and the subject, a source or a blocker",
195 clash.object()
196 )));
197 }
198 if let Some(both) = connected.iter().find(|space| {
199 passages
200 .iter()
201 .any(|passage| passage.object() == space.object())
202 }) {
203 return Err(PlanAreaError::Unavailable(format!(
204 "{} is both a connected space and a passage",
205 both.object()
206 )));
207 }
208 self.connected = connected;
209 self.passages = passages;
210 Ok(self)
211 }
212
213 #[must_use]
215 pub fn subject(&self) -> &ObjectId {
216 &self.subject
217 }
218
219 #[must_use]
221 pub fn reach(&self) -> EffectReach {
222 self.reach
223 }
224
225 #[must_use]
227 pub fn range_metres(&self) -> f64 {
228 self.range
229 }
230
231 #[must_use]
233 pub fn sources(&self) -> &[Participant] {
234 &self.sources
235 }
236
237 #[must_use]
239 pub fn blockers(&self) -> &[Participant] {
240 &self.blockers
241 }
242
243 #[must_use]
246 pub fn connected(&self) -> &[Participant] {
247 &self.connected
248 }
249
250 #[must_use]
253 pub fn passages(&self) -> &[Participant] {
254 &self.passages
255 }
256}
257
258fn merged(mut list: Vec<Participant>) -> Vec<Participant> {
260 list.sort_by(|a, b| a.object.cmp(&b.object).then(b.certain.cmp(&a.certain)));
261 list.dedup_by(|later, kept| later.object == kept.object);
262 list
263}
264
265#[derive(Clone, Debug, PartialEq, Eq)]
267pub enum EffectMeets {
268 Surely,
270 Possibly,
272 No,
274 Unmeasured(String),
276}
277
278#[derive(Clone, Debug, PartialEq)]
280pub struct CoverageEvidence {
281 subject: ObjectId,
282 footprint: PlanArea,
283 covered: PlanArea,
284 effects: Vec<(ObjectId, EffectMeets)>,
285}
286
287impl CoverageEvidence {
288 pub fn try_new(
296 subject: ObjectId,
297 footprint: PlanArea,
298 covered: PlanArea,
299 effects: Vec<(ObjectId, EffectMeets)>,
300 ) -> Result<Self, PlanAreaError> {
301 if covered.lower_square_metres() > footprint.upper_square_metres() {
302 return Err(PlanAreaError::InvalidMeasurement);
303 }
304 Ok(Self {
305 subject,
306 footprint,
307 covered,
308 effects,
309 })
310 }
311
312 #[must_use]
314 pub fn subject(&self) -> &ObjectId {
315 &self.subject
316 }
317
318 #[must_use]
320 pub fn footprint(&self) -> &PlanArea {
321 &self.footprint
322 }
323
324 #[must_use]
326 pub fn covered(&self) -> &PlanArea {
327 &self.covered
328 }
329
330 #[must_use]
332 pub fn effects(&self) -> &[(ObjectId, EffectMeets)] {
333 &self.effects
334 }
335
336 #[must_use]
338 pub fn evidence(&self) -> [&Evidence; 2] {
339 [self.footprint.evidence(), self.covered.evidence()]
340 }
341}
342
343pub(crate) fn check_answer(
347 request: &CoverageRequest,
348 answer: &CoverageEvidence,
349) -> Result<(), PlanAreaError> {
350 if answer.subject() != request.subject() {
351 return Err(PlanAreaError::Unavailable(format!(
352 "coverage of {} was returned for {}",
353 answer.subject(),
354 request.subject()
355 )));
356 }
357 let answered = answer.effects().iter().map(|(object, _)| object);
358 let asked = request.sources().iter().map(Participant::object);
359 if !answered.eq(asked) {
360 return Err(PlanAreaError::Unavailable(
361 "the coverage answer does not list the requested sources".into(),
362 ));
363 }
364 Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use axioval_ir::SourceId;
371
372 fn id(local: &str) -> ObjectId {
373 ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
374 }
375
376 fn area(value: f64) -> PlanArea {
377 PlanArea::try_new(
378 value,
379 value,
380 Evidence::exact(SourceId::new("cad", "m").unwrap(), "area"),
381 )
382 .unwrap()
383 }
384
385 #[test]
386 fn a_request_merges_listings_and_refuses_overlaps_and_bad_ranges() {
387 let source = |local, certain| Participant::new(id(local), certain);
388 let request = CoverageRequest::try_new(
389 id("s"),
390 EffectReach::Grown,
391 1.0,
392 vec![source("b", false), source("a", false), source("b", true)],
393 vec![],
394 )
395 .unwrap();
396 assert_eq!(request.sources(), [source("a", false), source("b", true)]);
397 for range in [-1.0, f64::NAN] {
398 assert!(
399 CoverageRequest::try_new(id("s"), EffectReach::Grown, range, vec![], vec![])
400 .is_err()
401 );
402 }
403 assert!(
404 CoverageRequest::try_new(
405 id("s"),
406 EffectReach::Travel,
407 1.0,
408 vec![source("s", true)],
409 vec![]
410 )
411 .is_err()
412 );
413 assert!(
414 CoverageRequest::try_new(
415 id("s"),
416 EffectReach::Travel,
417 1.0,
418 vec![source("a", true)],
419 vec![source("a", false)]
420 )
421 .is_err()
422 );
423 }
424
425 #[test]
426 fn connections_are_merged_and_refused_where_they_overlap() {
427 let one = |local, certain| Participant::new(id(local), certain);
428 let request = || {
429 CoverageRequest::try_new(
430 id("s"),
431 EffectReach::Travel,
432 5.0,
433 vec![one("a", true)],
434 vec![one("w", true)],
435 )
436 .unwrap()
437 };
438 let joined = request()
439 .with_connections(vec![one("n", false), one("n", true)], vec![one("d", false)])
440 .unwrap();
441 assert_eq!(joined.connected(), [one("n", true)]);
442 assert_eq!(joined.passages(), [one("d", false)]);
443 assert!(request().connected().is_empty() && request().passages().is_empty());
444 for (connected, passages) in [
445 (vec![one("s", true)], vec![]),
446 (vec![one("a", true)], vec![]),
447 (vec![], vec![one("w", false)]),
448 (vec![one("n", true)], vec![one("n", true)]),
449 ] {
450 assert!(request().with_connections(connected, passages).is_err());
451 }
452 let grown =
453 CoverageRequest::try_new(id("s"), EffectReach::Grown, 1.0, vec![], vec![]).unwrap();
454 assert!(
455 grown
456 .clone()
457 .with_connections(vec![one("n", true)], vec![])
458 .is_err()
459 );
460 assert!(grown.with_connections(vec![], vec![]).is_ok());
461 }
462
463 #[test]
464 fn an_answer_must_fit_its_request() {
465 let request = CoverageRequest::try_new(
466 id("s"),
467 EffectReach::Grown,
468 1.0,
469 vec![Participant::new(id("a"), true)],
470 vec![],
471 )
472 .unwrap();
473 assert!(CoverageEvidence::try_new(id("s"), area(1.0), area(2.0), vec![]).is_err());
474 let wrong = CoverageEvidence::try_new(id("s"), area(1.0), area(0.5), vec![]).unwrap();
475 assert!(check_answer(&request, &wrong).is_err());
476 let right = CoverageEvidence::try_new(
477 id("s"),
478 area(1.0),
479 area(0.5),
480 vec![(id("a"), EffectMeets::Surely)],
481 )
482 .unwrap();
483 assert!(check_answer(&request, &right).is_ok());
484 }
485}