1use std::{collections::BTreeMap, sync::Arc};
2
3use rand::seq::{IndexedRandom, SliceRandom};
4use tokio::time::{Duration, Instant, sleep_until};
5
6use crate::{
7 board::{Coord, Direction, TileType, xy},
8 config::ConfigSet,
9 lobby::state::{
10 LobbyPhase, LockedLobbyState, SharedLobbyState, clients::PlayerId, players::PlayerInMatchup,
11 },
12 log,
13 messages::{
14 MessageTx,
15 attack::{Attack, SingleAttack},
16 end_fight::EndFight,
17 movement::{Movement, SingleMovement},
18 },
19 unit::UnitType,
20};
21
22impl SharedLobbyState {
23 pub async fn fight(&self) -> LockedLobbyState {
26 let mut pfx = log::pfx();
27 pfx.lobby(self.id());
28
29 let mut lock = self.lock().await;
30 assert!(lock.game_started());
31
32 lock.phase = LobbyPhase::FightPhase;
33 lock.broadcast_gamestate().await;
34
35 log::debug!("Fight phase starting"; &pfx);
36
37 lock
38 }
39}
40
41async fn movement(fight: &mut Fight, movements: &mut Movement, pfx: &log::Prefix) {
42 let info = fight.info();
43 log::debug!("START movement phase"; pfx);
44 for unit in fight.living_units.iter_mut() {
46 unit.movements_this_round = 0;
47 }
48
49 async fn pick_targets(
50 fight: &mut Fight,
51 filter: impl Fn(&FightingUnit) -> bool,
52 pfx: &log::Prefix,
53 ) {
54 let info = fight.info();
55 let mut new_targets = Vec::with_capacity(fight.living_units.len());
56 for unit in fight.living_units.iter().filter(|u| u.alive() && filter(u)) {
57 new_targets.push(unit.pick_target(fight).await);
58 }
59 for (unit, target) in fight
60 .living_units
61 .iter_mut()
62 .filter(|u| u.alive() && filter(u))
63 .zip(new_targets)
64 {
65 if let Some(t) = &target {
66 log::debug!("{:?} at {:?} picked a target at {:?}", unit.unit_type, unit.position, t.1;
67 &pfx.clone().player(match unit.owner {
68 PlayerInMatchup::Player1 => info.matchup.0,
69 PlayerInMatchup::Player2 => info.matchup.1,
70 }));
71 } else {
72 log::debug!("{:?} at {:?} could not pick a target", unit.unit_type, unit.position;
73 &pfx.clone().player(match unit.owner {
74 PlayerInMatchup::Player1 => info.matchup.0,
75 PlayerInMatchup::Player2 => info.matchup.1,
76 }));
77 }
78 unit.current_target = target;
79 }
80 }
81
82 pick_targets(fight, |_| true, pfx).await;
84
85 fight
87 .do_moves_if_target_not_in_range(movements, |_| true, pfx)
88 .await;
89
90 pick_targets(fight, |u| u.max_movements(&info) == 2, pfx).await;
92
93 fight
95 .do_moves_if_target_not_in_range(movements, |u| u.max_movements(&info) == 2, pfx)
96 .await;
97
98 pick_targets(fight, |_| true, pfx).await;
100
101 log::debug!("END movement phase"; pfx);
102}
103
104async fn attack(
105 fight: &mut Fight,
106 attacks: &mut Attack,
107 late_movements: &mut Movement,
108 pfx: &log::Prefix,
109) {
110 log::debug!("START attack phase"; pfx);
111
112 let game_config = Arc::clone(&fight.configs.game_config);
113 let unit_config = Arc::clone(&fight.configs.unit_config);
114 let board_config = Arc::clone(&fight.configs.board_config);
115 let info = fight.info();
116
117 for unit in &mut fight.living_units {
119 unit.last_targets.clear();
120 }
121
122 let mut late_movements_queue = Vec::<usize>::new();
123
124 let mut unit_indices = (0..fight.living_units.len()).collect::<Vec<_>>();
125 unit_indices.shuffle(&mut rand::rng());
126
127 macro_rules! units {
129 [$i:expr] => {
130 fight.living_units[$i]
131 };
132 }
133
134 for unit_index in unit_indices {
135 if units![unit_index].alive() {
137 macro_rules! unit {
139 () => {
140 units![unit_index]
141 };
142 }
143 let unit_type = units![unit_index].unit_type;
144 let unit_owner = units![unit_index].owner;
145 let unit_pos = unit!().position;
146 log::debug!("now processing unit of type {unit_type:?} at {unit_pos}"; pfx);
147 let unobstructed_sight = unit_config.get_unit(unit_type).is_some_and(|v| {
148 info.unit_counts.get(unit_type, unit_owner)
149 >= v.unobstructed_sight_threshold as usize
150 }) && unit_type == UnitType::Sith;
151 let infinite_range = unit_config.get_unit(unit_type).is_some_and(|v| {
152 info.unit_counts.get(unit_type, unit_owner) >= v.infinite_range_threshold as usize
153 }) && unit_type == UnitType::Sith;
154
155 if let Some((current_target, current_target_pos)) = unit!().current_target {
157 let unit_attack_range = unit!().attack_range(&info);
158
159 let target_has_moved = units![current_target].position != current_target_pos;
161
162 if target_has_moved {
163 log::debug!("the selected target has moved away, will not attack"; pfx);
164 } else {
165 if !infinite_range
166 && unit_pos.x.abs_diff(current_target_pos.x)
167 + unit_pos.y.abs_diff(current_target_pos.y)
168 > unit_attack_range as usize
169 {
170 log::debug!("cannot attack the selected target: i don't have the infinite_range modifier and the enemy unit is too far away (to {current_target_pos})"; pfx);
171 } else if !unobstructed_sight
172 && board_config
173 .board
174 .is_line_of_sight_blocked(unit_pos, current_target_pos)
175 {
176 log::debug!("cannot attack the selected target: i don't have the unobstructed_sight modifier and the line of sight is blocked (to {current_target_pos})"; pfx);
177 } else {
178 log::debug!("attacking the selected target (at {current_target_pos})"; pfx);
180 let attack =
181 fight.living_units[unit_index].attack(None, &info, pfx.clone());
182 attack(&mut units![current_target], attacks, &info);
183 unit!().last_targets.push(current_target);
184 }
185 }
186 }
187
188 {
190 match unit_type {
191 UnitType::Rodian => {
192 if !unit!().last_targets.is_empty() {
194 if let Some(extra_attack) = unit_config
197 .get_unit(unit_type)
198 .and_then(|v| {
199 v.special(info.unit_counts.get(unit_type, unit_owner) as u64)
200 })
201 .map(|special| special.attack)
202 .filter(|v| *v > 0)
203 {
204 unit!().extra_attack += extra_attack;
205 log::debug!("this rodian has attacked, so its extra_attack has been increased by {extra_attack} and is now at {} for any future attacks", unit!().extra_attack; pfx);
206 } else {
207 log::debug!("this rodian's extra attack has not been increased because the config does not specify any extra_attack value (or specifies `0`) for this amount of deployed rodians"; pfx);
208 }
209 }
210 }
211 UnitType::Rebel => {
212 if !unit!().last_targets.is_empty() {
214 if let Some(healing) = unit_config
216 .get_unit(unit_type)
217 .and_then(|v| {
218 v.special(info.unit_counts.get(unit_type, unit_owner) as u64)
219 })
220 .map(|special| special.healing)
221 .filter(|v| *v > 0)
222 {
223 log::debug!(
226 "this rebel will now heal itself by up to {healing} health"; pfx
227 );
228 let unit = &mut unit!();
229 let attack =
230 unit.attack(Some(-(healing as i64)), &info, pfx.clone());
231 attack(unit, attacks, &info);
232 } else {
233 log::debug!("this rebel's health has not been increased because the config does not specify any healing value (or specifies `0`) for this amount of deployed rebels"; pfx);
234 }
235 }
236 }
237 UnitType::Wookie
238 | UnitType::Droid
239 | UnitType::Gamorrean
240 | UnitType::Stormtrooper
241 | UnitType::Ewok
242 | UnitType::Hutt
243 | UnitType::Jedi
244 | UnitType::Sith => {}
245 }
246 }
247
248 {
252 if unit!().last_targets.is_empty()
253 && unit!().movements_this_round < unit!().max_movements(&info)
254 {
255 log::debug!("adding this unit to the late movements queue"; pfx);
256 late_movements_queue.push(unit_index);
257 }
258 }
259
260 {
262 let prev_attacks = unit!().last_targets.len() as u64;
263 let max_attacks = unit!().max_attacks(&info);
264 let mut min_dist = usize::MAX;
265 debug_assert_eq!(min_dist, usize::MAX);
267 let mut next_closest_units = Vec::new();
268 for _ in prev_attacks..max_attacks {
271 log::debug!("trying to perform an additional attack"; pfx);
272 let range = unit!().attack_range(&info) as usize;
274 if next_closest_units.is_empty() {
276 log::debug!("finding next closest eligible units to attack"; pfx);
277 min_dist = usize::MAX;
278 next_closest_units.clear();
279 for (target_index, target_unit) in
280 fight.living_units.iter().enumerate().filter(|(i, u)| {
281 u.alive() && !unit!().last_targets.contains(i)
283 })
284 {
285 let dist = unit_pos.x.abs_diff(target_unit.position.x)
286 + unit_pos.y.abs_diff(target_unit.position.y);
287 if (infinite_range || dist <= range)
288 && (unobstructed_sight
289 || !board_config
290 .board
291 .is_line_of_sight_blocked(unit_pos, target_unit.position))
292 {
293 if dist < min_dist {
294 min_dist = dist;
295 next_closest_units.clear();
296 }
297 if dist <= min_dist {
298 next_closest_units.push(target_index);
299 }
300 }
301 }
302 }
303 if next_closest_units.is_empty() {
304 log::debug!("giving up on additional attack opportunities because there are no more units to attack"; pfx);
306 break;
307 } else {
308 let random_index = if next_closest_units.len() == 1 {
310 log::debug!("there is 1 next-closest unit we could attack, choosing that one"; pfx);
311 0
312 } else {
313 log::debug!("there are {} next-closest units we could attack, choosing a random one", next_closest_units.len(); pfx);
314 rand::random_range(0..next_closest_units.len())
315 };
316 let target_index = next_closest_units.swap_remove(random_index);
317 let attack = units![unit_index].attack(None, &info, pfx.clone());
319 attack(&mut units![target_index], attacks, &info);
320 unit!().last_targets.push(target_index);
321 }
322 }
323 }
324 }
325 }
326
327 late_movements_queue.shuffle(&mut rand::rng());
329 for unit_index in late_movements_queue {
330 let prev_pos = units![unit_index].position;
331 if units![unit_index]
332 .current_target
333 .is_some_and(|(i, _)| !units![unit_index].can_attack(&fight.living_units[i], &info))
334 {
335 fight.try_move_unit(unit_index, late_movements, pfx).await;
336 }
337 let unit = &mut units![unit_index];
338 if prev_pos == unit.position {
339 log::debug!("the {:?} at {} could not use its late movement opportunity", unit.unit_type, prev_pos; pfx);
340 } else {
341 log::debug!("the {:?} at {} uses its late movement opportunity to move to {}", unit.unit_type, prev_pos, unit.position; pfx);
342 }
343 if unit.movements_this_round < unit.max_movements(&info) {
345 let prev_pos = unit.position;
346 if units![unit_index]
349 .current_target
350 .is_some_and(|(i, _)| !units![unit_index].can_attack(&fight.living_units[i], &info))
351 {
352 fight.try_move_unit(unit_index, late_movements, pfx).await;
353 }
354 let unit = &mut units![unit_index];
355 if prev_pos == unit.position {
356 log::debug!("the {:?} at {} could not use its second late movement opportunity", unit.unit_type, prev_pos; pfx);
357 } else {
358 log::debug!("the {:?} at {} uses its second late movement opportunity to move to {}", unit.unit_type, prev_pos, unit.position; pfx);
359 }
360 }
361 }
362
363 for unit in fight.living_units.iter_mut().filter(|u| u.alive()) {
365 if let UnitType::Hutt = unit.unit_type {
366 if let Some(healing) = unit_config
367 .get_unit(unit.unit_type)
368 .and_then(|v| v.special(info.unit_counts.get(unit.unit_type, unit.owner) as u64))
369 .map(|special| special.healing)
370 .filter(|healing| *healing > 0)
371 {
372 log::debug!("the {:?} at {} will now heal itself", unit.unit_type, unit.position; pfx);
375 let attack = unit.attack(Some(-(healing as i64)), &info, pfx.clone());
376 attack(unit, attacks, &info);
377 }
378 }
379 }
380
381 for unit in fight.living_units.iter_mut().filter(|u| u.alive()) {
383 let tile = board_config
384 .board
385 .get(unit.position)
386 .expect("unit is standing... somewhere... but not on the board? D:");
387 match tile {
388 TileType::MedicCenter => {
389 let health = game_config.tile_modifier_medic_center.health;
390 if health > 0 {
391 unit.movement_heal(health as u64, late_movements, &info);
392 log::debug!("the {:?} at {} will be healed because it is standing on a medic center field, it is {}", unit.unit_type, unit.position, unit.health.map(|v| format!("now at {v} health")).unwrap_or_else(|| "dead".to_owned()); pfx);
393 }
394 }
395 TileType::Lava => {
396 let health = game_config.tile_modifier_lava.health;
397 if health < 0 {
398 unit.movement_damage((-health) as u64, late_movements, &info);
399 log::debug!("the {:?} at {} will take damage because it is standing on a lava field, it is {}", unit.unit_type, unit.position, unit.health.map(|v| format!("now at {v} health")).unwrap_or_else(|| "dead".to_owned()); pfx);
400 }
401 }
402 TileType::Rock | TileType::Grass | TileType::Force => {}
403 }
404 }
405
406 for unit in fight.living_units.iter_mut().filter(|u| u.alive()) {
408 unit.die_if_dead();
409 if !unit.alive() {
410 log::debug!("the {:?} at {} is now dead", unit.unit_type, unit.position; pfx);
411 }
412 }
414
415 log::debug!("END attack phase"; pfx);
416}
417
418struct Fight {
419 lobby: SharedLobbyState,
420 configs: ConfigSet,
421 power_mode: [f64; 2],
423 lightsabers: [f64; 6],
425
426 matchup: (PlayerId, PlayerId, bool),
427 winner: Option<Option<PlayerInMatchup>>,
428
429 round: u64,
430 living_units: Vec<FightingUnit>,
431}
432struct FightInfo {
433 configs: ConfigSet,
434 matchup: (PlayerId, PlayerId),
435 round: u64,
436 unit_counts: UnitCounts,
437 power_mode: [f64; 2],
439 lightsabers: [f64; 6],
441}
442
443struct FightingUnit {
444 unit_type: UnitType,
445 owner: PlayerInMatchup,
446 position: Coord,
447 force_bonus: bool,
448 health: Option<i64>,
450 current_target: Option<(usize, Coord)>,
453 last_targets: Vec<usize>,
456 movements_this_round: u64,
457 extra_attack: u64,
461}
462
463pub async fn fight(lobby: &SharedLobbyState) {
466 let mut pfx = log::pfx();
467 pfx.lobby(lobby.id());
468 let lock = lobby.fight().await;
469
470 let deadline =
471 Instant::now() + Duration::from_millis(lock.configs.game_config.timeout_fight_phase);
472
473 let mut fights = lock
476 .matchups
477 .iter()
478 .map(|(p1id, p2id, ghost)| {
479 let p1 = lock
480 .clients
481 .players
482 .get(p1id)
483 .expect("player id in matchups should also be in the lobby state");
484 let p2 = lock
485 .clients
486 .players
487 .get(p2id)
488 .expect("player id in matchups should also be in the lobby state");
489 let living_units = {
490 let p1_units = p1
491 .unit_placement
492 .iter()
493 .map(|p| (p, PlayerInMatchup::Player1));
494 let p2_units = p2
495 .unit_placement
496 .iter()
497 .map(|p| (p, PlayerInMatchup::Player2));
498 p1_units
499 .chain(p2_units)
500 .map(|(placement, owner)| {
501 let stats = lock
502 .configs
503 .unit_config
504 .get_unit(placement.unit_type)
505 .expect("unit in placement must exist in lobby");
506 FightingUnit {
507 unit_type: placement.unit_type,
508 owner,
509 position: placement.position,
510 force_bonus: matches!(
511 lock.configs.board_config.board.get(placement.position),
512 Some(TileType::Force)
513 ),
514 health: Some(stats.health as i64),
515 current_target: None,
516 last_targets: vec![],
517 movements_this_round: 0,
518 extra_attack: 0,
519 }
520 })
521 .collect()
522 };
523 let mut f = Fight {
524 lobby: lobby.clone(),
525 configs: lock.configs.clone(),
526
527 matchup: (*p1id, *p2id, *ghost),
528 winner: None,
529
530 round: 0,
531 living_units,
532
533 power_mode: [
534 if p1.unit_placement.first().is_some_and(|f| {
535 p1.unit_placement.iter().all(|u| u.unit_type == f.unit_type)
536 }) && p1.unit_placement.len()
537 > lock.configs.game_config.power_mode_threshold as usize
538 {
539 lock.configs.game_config.power_mode_modifier
540 } else {
541 1.0
542 },
543 if p2.unit_placement.first().is_some_and(|f| {
544 p2.unit_placement.iter().all(|u| u.unit_type == f.unit_type)
545 }) && p2.unit_placement.len()
546 > lock.configs.game_config.power_mode_threshold as usize
547 {
548 lock.configs.game_config.power_mode_modifier
549 } else {
550 1.0
551 },
552 ],
553
554 lightsabers: [
555 p1.red_lightsabers.iter().sum(),
556 p1.green_lightsabers.iter().sum(),
557 p1.blue_lightsabers.iter().sum(),
558 p2.red_lightsabers.iter().sum(),
559 p2.green_lightsabers.iter().sum(),
560 p2.blue_lightsabers.iter().sum(),
561 ],
562 };
563 let info = f.info();
564 for unit in &mut f.living_units {
565 unit.health = Some(unit.max_health(&info) as i64);
566 }
567 f
568 })
569 .collect::<Vec<_>>();
570
571 let max_fight_rounds = lock.configs.game_config.max_fight_rounds;
572
573 drop(lock);
574
575 for r in 1..=max_fight_rounds {
577 log::debug!("evaluating fights (round {r})"; &pfx);
578 if fights.iter().all(|f| f.winner.is_some()) {
579 break;
580 }
581 {
583 let mut movements = Movement::new_empty();
584 for fight in &mut fights {
585 fight.round = r;
586 movement(fight, &mut movements, &pfx).await;
587 }
588 #[cfg(feature = "send-initial-health-in-first-movement-message")]
589 if r == 1 {
590 for fight in &mut fights {
592 let per_match_index = if let Some(i) =
593 movements.movements_per_match.iter().position(|pm| {
594 pm.matchup[0] == fight.matchup.0 && pm.matchup[1] == fight.matchup.1
595 }) {
596 i
597 } else {
598 let i = movements.movements_per_match.len();
599 movements
600 .movements_per_match
601 .push(crate::messages::movement::PerMatch {
602 matchup: [fight.matchup.0, fight.matchup.1],
603 current_fight_round: r,
604 movements: vec![],
605 });
606 i
607 };
608 for unit in &fight.living_units {
609 let position = Movement::coord_to_arr(unit.position);
610 if !movements.movements_per_match[per_match_index]
611 .movements
612 .iter()
613 .any(|m| m.target_position == position)
614 {
615 movements.push_movement(
617 (fight.matchup.0, fight.matchup.1),
618 r,
619 SingleMovement {
620 unit: unit.unit_type,
621 player_id: match unit.owner {
622 PlayerInMatchup::Player1 => fight.matchup.0,
623 PlayerInMatchup::Player2 => fight.matchup.1,
624 },
625 current_position: position,
626 target_position: position,
627 health: unit.health.filter(|v| *v > 0).unwrap_or(0) as u64,
628 },
629 );
630 }
631 }
632 }
633 }
634 lobby
635 .lock()
636 .await
637 .clients
638 .broadcast_message(&movements.serialize())
639 .await;
640 }
641 {
643 let mut attacks = Attack::new_empty();
644 let mut late_movements = Movement::new_empty();
645 for fight in &mut fights {
646 attack(
647 fight,
648 &mut attacks,
649 &mut late_movements,
650 &pfx.clone().player(fight.matchup.0),
651 )
652 .await;
653 }
654 let mut lock = lobby.lock().await;
655 lock.clients.broadcast_message(&attacks.serialize()).await;
656 #[cfg(feature = "combine-late-movements-and-health-changes")]
657 {
658 for movements in &mut late_movements.movements_per_match {
659 let mut remove_parts = vec![];
660 for i in 0..movements.movements.len() {
661 if movements.movements[i].current_position
663 == movements.movements[i].current_position
664 {
665 for j in (0..i).rev() {
667 if movements.movements[j].current_position
668 == movements.movements[i].current_position
669 && movements.movements[j].target_position
670 != movements.movements[i].current_position
671 {
672 break;
675 }
676 if movements.movements[j].target_position
677 == movements.movements[i].current_position
678 {
679 movements.movements[j].health = movements.movements[i].health;
682 remove_parts.push(i);
683 }
684 }
685 }
686 }
687 for i in remove_parts.into_iter().rev() {
688 movements.movements.remove(i);
689 }
690 }
691 }
692 lock.clients
693 .broadcast_message(&late_movements.serialize())
694 .await;
695 }
696 {
698 let mut lock = None;
699 for fight in &mut fights {
700 if fight.winner.is_none() {
701 let mut living_units = fight.living_units.iter().filter(|u| u.alive());
702 if let Some(living_unit) = living_units.next() {
703 if living_units.all(|u| u.owner == living_unit.owner) {
704 fight.winner = Some(Some(living_unit.owner));
707 }
708 } else {
709 fight.winner = Some(None);
711 }
712 if let Some(winner) = fight.winner {
713 if lock.is_none() {
715 lock = Some(lobby.lock().await);
717 }
718 lock.as_mut()
719 .unwrap()
720 .clients
721 .broadcast_message(
722 &EndFight::new(fight.matchup.0, fight.matchup.1, winner)
723 .serialize(),
724 )
725 .await;
726 }
727 }
728 }
729 }
730 }
731 {
733 let mut lock = lobby.lock().await;
734 for fight in &fights {
735 let ghost = fight.matchup.2;
736 if let Some(Some(winner)) = fight.winner {
737 let loser = match winner {
740 PlayerInMatchup::Player1 => {
741 if let Some(winner) = lock.clients.players.get_mut(&fight.matchup.0) {
742 winner.fights_won += 1;
743 } else {
744 log::warning!(
745 "messed up internal state; no player with id {} found in lobby", fight.matchup.0; &pfx
746 );
747 }
748 if ghost { None } else { Some(fight.matchup.1) }
749 }
750 PlayerInMatchup::Player2 => {
751 if !ghost {
752 if let Some(winner) = lock.clients.players.get_mut(&fight.matchup.1) {
753 winner.fights_won += 1;
754 } else {
755 log::warning!(
756 "messed up internal state; no player with id {} found in lobby", fight.matchup.1; &pfx
757 );
758 }
759 };
760 Some(fight.matchup.0)
761 }
762 };
763 if let Some(loser) = loser {
764 let loss = lock
765 .configs
766 .game_config
767 .live_loss_on_defeat
768 .get_loss(lock.round);
769 if let Some(loser) = lock.clients.players.get_mut(&loser) {
770 loser.lives -= loss as i64;
771 } else {
772 log::warning!(
773 "messed up internal state; no player with id {loser} found in lobby"; &pfx
774 );
775 }
776 }
777 } else {
778 let loss = lock
780 .configs
781 .game_config
782 .live_loss_on_defeat
783 .get_loss(lock.round);
784 for loser in [
785 Some(fight.matchup.0),
786 if ghost { None } else { Some(fight.matchup.1) },
787 ]
788 .into_iter()
789 .flatten()
790 {
791 if let Some(loser) = lock.clients.players.get_mut(&loser) {
792 loser.lives -= loss as i64;
793 } else {
794 log::warning!(
795 "messed up internal state; no player with id {loser} found in lobby"; &pfx
796 );
797 }
798 }
799 }
800 }
801 }
802
803 sleep_until(deadline).await;
804}
805
806impl Fight {
807 fn info(&self) -> FightInfo {
808 FightInfo {
809 configs: self.configs.clone(),
810 matchup: (self.matchup.0, self.matchup.1),
811 round: self.round,
812 unit_counts: UnitCounts::from_fight(self),
813 power_mode: self.power_mode,
814 lightsabers: self.lightsabers,
815 }
816 }
817
818 async fn try_move_unit(
822 &mut self,
823 uid: usize,
824 movements: &mut Movement,
825 pfx: &log::Prefix,
826 ) -> bool {
827 if let Some(movement) = self.gen_unit_movement(uid, pfx).await {
828 self.living_units[uid].movements_this_round += 1;
829 movements.push_movement((self.matchup.0, self.matchup.1), self.round, movement);
830 true
831 } else {
832 false
833 }
834 }
835 async fn gen_unit_movement(&mut self, uid: usize, pfx: &log::Prefix) -> Option<SingleMovement> {
840 let this_unit = &self.living_units[uid];
841 let info = self.info();
842 let owner = match this_unit.owner {
843 PlayerInMatchup::Player1 => info.matchup.0,
844 PlayerInMatchup::Player2 => info.matchup.1,
845 };
846 let mut pfx = pfx.clone();
847 pfx.player(owner);
848 let dirs = if let Some(target) = &this_unit.current_target {
849 self.configs
850 .board_config
851 .board
852 .get_dirs(this_unit.position, target.1)
853 .await
854 .unwrap()
855 } else {
856 return None;
857 };
858
859 let dir = dirs.random();
860 let pos = this_unit.position;
861 let new_pos = match dir {
862 Direction::North => xy(pos.x, pos.y.checked_sub(1).unwrap()),
863 Direction::East => xy(pos.x.checked_add(1).unwrap(), pos.y),
864 Direction::South => xy(pos.x, pos.y.checked_add(1).unwrap()),
865 Direction::West => xy(pos.x.checked_sub(1).unwrap(), pos.y),
866 };
867 for other_pos in self
868 .living_units
869 .iter()
870 .enumerate()
871 .filter_map(|(n, u)| if n != uid { Some(u) } else { None })
872 .filter(|u| u.alive())
873 .map(|u| u.position)
874 {
875 if new_pos == other_pos {
876 log::debug!("{:?} at {:?} was obstucted by unit at {:?}", this_unit.unit_type, pos, other_pos; &pfx);
877 return None;
878 }
879 }
880
881 log::debug!("{:?} at {:?} moved to {:?}", this_unit.unit_type, pos, new_pos; &pfx);
882 let unit_type = this_unit.unit_type;
883 let health = this_unit.health;
884 self.living_units[uid].position = new_pos;
885 Some(SingleMovement::new(
886 unit_type,
887 owner,
888 pos,
889 new_pos,
890 health.unwrap().max(0) as u64,
891 ))
892 }
893
894 async fn do_moves_if_target_not_in_range(
897 &mut self,
898 movements: &mut Movement,
899 filter: impl Fn(&FightingUnit) -> bool,
900 pfx: &log::Prefix,
901 ) {
902 let info = self.info();
903 let mut to_move = self
904 .living_units
905 .iter()
906 .enumerate()
907 .filter(|(_, u)| u.alive() && filter(u))
908 .filter_map(|(n, unit)| unit.current_target.map(|t| (n, unit, t)))
909 .filter_map(|(n, unit, t)| {
910 if !unit.can_attack(&self.living_units[t.0], &info) {
911 Some(n)
912 } else {
913 None
914 }
915 })
916 .collect::<Vec<_>>();
917 to_move.shuffle(&mut rand::rng());
918 for uid in to_move {
919 self.try_move_unit(uid, movements, pfx).await;
920 }
921 }
922}
923
924impl FightInfo {
925 fn mod_attack(
928 &self,
929 player: PlayerInMatchup,
930 base: u64,
931 enemy_armor: u64,
932 bonus_damage: u64,
933 ) -> u64 {
934 ((self.lightsabers[match player {
938 PlayerInMatchup::Player1 => 0,
939 PlayerInMatchup::Player2 => 3,
940 }] + base as f64
941 * match player {
942 PlayerInMatchup::Player1 => self.power_mode[0],
943 PlayerInMatchup::Player2 => self.power_mode[1],
944 })
945 * 100.0
948 / (100.0 + enemy_armor as f64))
949 .ceil() as u64
950 + bonus_damage
951 }
952 fn mod_max_health(&self, player: PlayerInMatchup, base: u64) -> u64 {
953 (self.lightsabers[match player {
954 PlayerInMatchup::Player1 => 1,
955 PlayerInMatchup::Player2 => 4,
956 }] + base as f64
957 * match player {
958 PlayerInMatchup::Player1 => self.power_mode[0],
959 PlayerInMatchup::Player2 => self.power_mode[1],
960 })
961 .ceil() as u64
962 }
963 fn mod_armor(&self, player: PlayerInMatchup, base: u64) -> u64 {
964 (self.lightsabers[match player {
965 PlayerInMatchup::Player1 => 2,
966 PlayerInMatchup::Player2 => 5,
967 }] + base as f64
968 * match player {
969 PlayerInMatchup::Player1 => self.power_mode[0],
970 PlayerInMatchup::Player2 => self.power_mode[1],
971 })
972 .ceil() as u64
973 }
974 fn mod_range(&self, player: PlayerInMatchup, base: u64) -> u64 {
975 (base as f64
976 * match player {
977 PlayerInMatchup::Player1 => self.power_mode[0],
978 PlayerInMatchup::Player2 => self.power_mode[1],
979 })
980 .ceil() as u64
981 }
982}
983
984impl FightingUnit {
985 fn alive(&self) -> bool {
990 self.health.is_some()
991 }
992 fn die_if_dead(&mut self) {
995 if self.health.is_some_and(|h| h <= 0) {
996 self.health = None;
997 }
998 }
999
1000 fn max_health(&self, info: &FightInfo) -> u64 {
1001 let mut base = info
1003 .configs
1004 .unit_config
1005 .get_unit(self.unit_type)
1006 .map(|v| {
1007 v.health
1008 + if !matches!(self.unit_type, UnitType::Stormtrooper | UnitType::Hutt) {
1009 v.special(info.unit_counts.get(self.unit_type, self.owner) as u64)
1010 .map(|special| special.health)
1011 .unwrap_or(0)
1012 } else {
1013 0
1014 }
1015 + v.special(info.unit_counts.get(UnitType::Stormtrooper, self.owner) as u64)
1017 .map(|special| special.health)
1018 .unwrap_or(0)
1019 + v.special(info.unit_counts.get(UnitType::Hutt, self.owner) as u64)
1020 .map(|special| special.health)
1021 .unwrap_or(0)
1022 })
1023 .unwrap_or(0);
1024 if self.force_bonus {
1025 base = (base as i64 + info.configs.game_config.tile_modifier_force.health) as u64;
1026 }
1027 info.mod_max_health(self.owner, base)
1028 }
1029 fn attack_range(&self, info: &FightInfo) -> u64 {
1030 let base = info
1032 .configs
1033 .unit_config
1034 .get_unit(self.unit_type)
1035 .map(|v| {
1036 v.attack_range
1037 + v.special(info.unit_counts.get(self.unit_type, self.owner) as u64)
1038 .map(|special| special.attack_range)
1039 .unwrap_or(0)
1040 })
1041 .unwrap_or(0);
1042 info.mod_range(self.owner, base)
1043 }
1044 fn armor(&self, info: &FightInfo) -> u64 {
1045 let mut base = info
1047 .configs
1048 .unit_config
1049 .get_unit(self.unit_type)
1050 .map(|v| {
1051 v.armor
1052 + if !matches!(self.unit_type, UnitType::Stormtrooper) {
1053 v.special(info.unit_counts.get(self.unit_type, self.owner) as u64)
1054 .map(|special| special.armor)
1055 .unwrap_or(0)
1056 } else {
1057 0
1058 }
1059 + v.special(info.unit_counts.get(UnitType::Stormtrooper, self.owner) as u64)
1061 .map(|special| special.armor)
1062 .unwrap_or(0)
1063 })
1064 .unwrap_or(0);
1065 if self.force_bonus {
1066 base = (base as i64 + info.configs.game_config.tile_modifier_force.armor) as u64;
1067 }
1068 info.mod_armor(self.owner, base)
1069 }
1070 fn damage_per_attack(&self, enemy_armor: u64, info: &FightInfo) -> u64 {
1071 Self::damage_per_attack_int(
1072 self.unit_type,
1073 self.extra_attack,
1074 self.force_bonus,
1075 self.owner,
1076 enemy_armor,
1077 info,
1078 )
1079 }
1080 fn damage_per_attack_int(
1081 self_unit_type: UnitType,
1082 self_extra_attack: u64,
1083 self_force_bonus: bool,
1084 self_owner: PlayerInMatchup,
1085 enemy_armor: u64,
1086 info: &FightInfo,
1087 ) -> u64 {
1088 let mut base = info
1091 .configs
1092 .unit_config
1093 .get_unit(self_unit_type)
1094 .map(|v| {
1095 v.attack
1096 + if matches!(self_unit_type, UnitType::Rodian | UnitType::Stormtrooper) {
1097 0
1100 } else {
1101 v.special(info.unit_counts.get(self_unit_type, self_owner) as u64)
1102 .map(|special| special.attack)
1103 .unwrap_or(0)
1104 }
1105 + v.special(info.unit_counts.get(UnitType::Stormtrooper, self_owner) as u64)
1107 .map(|special| special.armor)
1108 .unwrap_or(0)
1109 })
1110 .unwrap_or(0)
1111 + self_extra_attack;
1112 let bonus_damage = info
1114 .configs
1115 .unit_config
1116 .get_unit(self_unit_type)
1117 .and_then(|v| v.special(info.unit_counts.get(self_unit_type, self_owner) as u64))
1118 .map(|special| special.bonus_damage)
1119 .unwrap_or(0);
1120 if self_force_bonus {
1121 base = (base as i64 + info.configs.game_config.tile_modifier_force.attack) as u64;
1122 }
1123 info.mod_attack(self_owner, base, enemy_armor, bonus_damage)
1124 }
1125 fn max_movements(&self, info: &FightInfo) -> u64 {
1126 if matches!(self.unit_type, UnitType::Jedi)
1127 && info
1128 .configs
1129 .unit_config
1130 .get_unit(self.unit_type)
1131 .is_some_and(|v| {
1132 info.unit_counts.get(self.unit_type, self.owner)
1133 >= v.multiple_moves_threshold as usize
1134 })
1135 {
1136 2
1137 } else {
1138 1
1139 }
1140 }
1141 fn max_attacks(&self, info: &FightInfo) -> u64 {
1142 info.configs
1146 .unit_config
1147 .get_unit(self.unit_type)
1148 .and_then(|v| v.special(info.unit_counts.get(self.unit_type, self.owner) as u64))
1149 .map(|special| special.targets)
1150 .unwrap_or(1)
1151 }
1152
1153 fn movement(&mut self, position: Coord, movements: &mut Movement, info: &FightInfo) {
1156 self.movement_message(position, self.health, movements, info);
1157 }
1158 fn movement_heal(&mut self, healing_amount: u64, movements: &mut Movement, info: &FightInfo) {
1161 self.movement_message(
1162 self.position,
1163 self.health
1164 .map(|h| (h + healing_amount as i64).min(self.max_health(info) as i64)),
1165 movements,
1166 info,
1167 );
1168 }
1169 fn movement_damage(&mut self, damage_amount: u64, movements: &mut Movement, info: &FightInfo) {
1172 self.movement_message(
1173 self.position,
1174 self.health.map(|h| h - damage_amount as i64),
1175 movements,
1176 info,
1177 );
1178 }
1179
1180 fn movement_message(
1181 &mut self,
1182 position: Coord,
1183 health: Option<i64>,
1184 movements: &mut Movement,
1185 info: &FightInfo,
1186 ) {
1187 let health_filtered = health.filter(|h| *h > 0).unwrap_or(0);
1188 if position != self.position
1189 || health_filtered != self.health.filter(|h| *h > 0).unwrap_or(0)
1190 {
1191 movements.push_movement(
1192 info.matchup,
1193 info.round,
1194 SingleMovement::new(
1195 self.unit_type,
1196 match self.owner {
1197 PlayerInMatchup::Player1 => info.matchup.0,
1198 PlayerInMatchup::Player2 => info.matchup.1,
1199 },
1200 self.position,
1201 position,
1202 health_filtered as u64,
1203 ),
1204 );
1205 }
1206 self.position = position;
1207 self.health = health;
1208 }
1209 fn attack<'a, 'b>(
1212 &'a mut self,
1213 raw_damage: Option<i64>,
1214 _info: &'a FightInfo,
1215 pfx: log::Prefix,
1216 ) -> impl FnOnce(&'b mut FightingUnit, &'b mut Attack, &'b FightInfo) + 'static {
1217 let self_position = self.position;
1218 let self_unit_type = self.unit_type;
1219 let self_extra_attack = self.extra_attack;
1220 let self_force_bonus = self.force_bonus;
1221 let self_owner = self.owner;
1222 move |attacked_unit: &mut FightingUnit, attacks: &mut Attack, info: &FightInfo| {
1224 let damage = raw_damage.unwrap_or_else(|| {
1225 FightingUnit::damage_per_attack_int(
1226 self_unit_type,
1227 self_extra_attack,
1228 self_force_bonus,
1229 self_owner,
1230 attacked_unit.armor(info),
1231 info,
1232 ) as i64
1233 });
1234 if let Some(hp) = attacked_unit.health {
1235 let new_health = (hp - damage).min(attacked_unit.max_health(info) as i64);
1236 attacked_unit.health = Some(new_health);
1237 if hp != new_health {
1238 attacks.push_attack(
1239 info.matchup,
1240 info.round,
1241 SingleAttack::new(
1242 self_unit_type,
1243 match self_owner {
1244 PlayerInMatchup::Player1 => info.matchup.0,
1245 PlayerInMatchup::Player2 => info.matchup.1,
1246 },
1247 self_position,
1248 attacked_unit.position,
1249 hp.max(0) - new_health.max(0),
1250 ),
1251 );
1252 }
1253 log::debug!("this attack changes the target unit's health from {hp} to {new_health}"; &pfx);
1254 } else {
1255 log::debug!("this attack does not affect the target unit, because it is already dead"; &pfx);
1256 }
1257 }
1258 }
1259
1260 async fn pick_target(&self, fight: &Fight) -> Option<(usize, Coord)> {
1262 let info = fight.info();
1263 if let Some(uid) = self.last_targets.last() {
1265 let unit = &fight.living_units[*uid];
1266 if unit.alive() && self.can_attack(unit, &info) {
1267 return Some((*uid, unit.position));
1268 }
1269 }
1270
1271 let mut units_in_range = fight
1273 .living_units
1274 .iter()
1275 .enumerate()
1276 .filter(|&(_, unit)| {
1277 unit.alive() && unit.owner != self.owner && self.can_attack(unit, &info)
1278 })
1279 .map(|(n, unit)| (n, unit, 0))
1280 .collect::<Vec<_>>();
1281 if !units_in_range.is_empty() {
1282 for (_, unit, dist) in units_in_range.iter_mut() {
1283 *dist = fight
1284 .configs
1285 .board_config
1286 .board
1287 .get_dirs(self.position, unit.position)
1288 .await
1289 .unwrap()
1290 .dist();
1291 }
1292 units_in_range.sort_unstable_by_key(|&(_, _, d)| d);
1293 let lowest = units_in_range.first().unwrap().2;
1294 units_in_range.truncate(
1295 units_in_range
1296 .iter()
1297 .enumerate()
1298 .find_map(|(n, &(_, _, dist))| if dist > lowest { Some(n) } else { None })
1299 .unwrap_or(units_in_range.len()),
1300 );
1301 let choice = units_in_range.choose(&mut rand::rng()).unwrap();
1302 return Some((choice.0, choice.1.position));
1303 }
1304
1305 let mut units_with_dist = fight
1307 .living_units
1308 .iter()
1309 .enumerate()
1310 .filter(|&(_, unit)| {
1311 unit.alive() && unit.position != self.position && unit.owner != self.owner
1312 })
1313 .map(|(n, unit)| (n, unit, 0))
1314 .collect::<Vec<_>>();
1315 if !units_with_dist.is_empty() {
1316 for (_, unit, dist) in units_with_dist.iter_mut() {
1317 *dist = fight
1318 .configs
1319 .board_config
1320 .board
1321 .get_dirs(self.position, unit.position)
1322 .await
1323 .unwrap()
1324 .dist();
1325 }
1326 units_with_dist.sort_unstable_by_key(|&(_, _, d)| d);
1327 let lowest = units_with_dist.first().unwrap().2;
1328 units_with_dist.truncate(
1329 units_with_dist
1330 .iter()
1331 .enumerate()
1332 .find_map(|(n, &(_, _, dist))| if dist > lowest { Some(n) } else { None })
1333 .unwrap_or(units_with_dist.len()),
1334 );
1335 let choice = units_with_dist.choose(&mut rand::rng()).unwrap();
1336 return Some((choice.0, choice.1.position));
1337 }
1338
1339 None
1340 }
1341
1342 fn can_attack(&self, other: &FightingUnit, info: &FightInfo) -> bool {
1344 let dist = self.position.cab_dist(other.position);
1345 let unobstructed_sight = info
1346 .configs
1347 .unit_config
1348 .get_unit(self.unit_type)
1349 .is_some_and(|v| {
1350 info.unit_counts.get(self.unit_type, self.owner)
1351 >= v.unobstructed_sight_threshold as usize
1352 })
1353 && self.unit_type == UnitType::Sith;
1354 let infinite_range = info
1355 .configs
1356 .unit_config
1357 .get_unit(self.unit_type)
1358 .is_some_and(|v| {
1359 info.unit_counts.get(self.unit_type, self.owner)
1360 >= v.infinite_range_threshold as usize
1361 })
1362 && self.unit_type == UnitType::Sith;
1363 dist > 0
1364 && (infinite_range || dist <= self.attack_range(info) as usize)
1365 && (unobstructed_sight
1366 || !info
1367 .configs
1368 .board_config
1369 .board
1370 .is_line_of_sight_blocked(self.position, other.position))
1371 }
1372}
1373
1374struct UnitCounts {
1375 map: [BTreeMap<UnitType, usize>; 2],
1376}
1377impl UnitCounts {
1378 fn from_fight(fight: &Fight) -> Self {
1379 let mut map = [BTreeMap::default(), BTreeMap::default()];
1380 for unit in &fight.living_units {
1381 let i = match unit.owner {
1383 PlayerInMatchup::Player1 => 0,
1384 PlayerInMatchup::Player2 => 1,
1385 };
1386 *map[i].entry(unit.unit_type).or_insert(0) += 1;
1387 }
1388 Self { map }
1389 }
1390 fn get(&self, unit_type: UnitType, owner: PlayerInMatchup) -> usize {
1391 let i = match owner {
1392 PlayerInMatchup::Player1 => 0,
1393 PlayerInMatchup::Player2 => 1,
1394 };
1395 self.map[i].get(&unit_type).copied().unwrap_or(0)
1396 }
1397}
1398
1399#[cfg(test)]
1400mod test {
1401 use crate::{
1402 board::xy,
1403 lobby::{
1404 game::fight::FightingUnit,
1405 state::players::PlayerInMatchup,
1406 test::{FakeCon, get_server_and_lobby, player_join},
1407 },
1408 log,
1409 messages::{attack::Attack, movement::Movement},
1410 unit::UnitType,
1411 };
1412
1413 use super::Fight;
1414
1415 #[tokio::test]
1416 async fn attack_phase() {
1417 let (server, lobby) = get_server_and_lobby();
1418 let (p1id, _, mut p1con) = player_join(&server, &lobby).await;
1419 let (p2id, _, mut p2con) = player_join(&server, &lobby).await;
1420 p1con.clear();
1421 p2con.clear();
1422 let lock = lobby.lock().await;
1423 let h = lock.configs.board_config.board.get_size().height;
1424 let mut fight = Fight {
1425 lobby: lobby.clone(),
1426 configs: lock.configs.clone(),
1427 power_mode: [1.0, 1.0],
1428 lightsabers: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1429 matchup: (p1id, p2id, false),
1430 winner: None,
1431 round: 1,
1432 living_units: vec![
1433 FightingUnit {
1434 unit_type: UnitType::Sith,
1435 owner: PlayerInMatchup::Player1,
1436 position: xy(0, h / 2 - 1),
1437 force_bonus: false,
1438 health: Some(100),
1439 current_target: Some((1, xy(0, h / 2))),
1440 last_targets: vec![],
1441 movements_this_round: 1,
1442 extra_attack: 0,
1443 },
1444 FightingUnit {
1445 unit_type: UnitType::Rodian,
1446 owner: PlayerInMatchup::Player1,
1447 position: xy(0, h / 2 - 1),
1448 force_bonus: false,
1449 health: Some(100),
1450 current_target: Some((1, xy(0, h / 2))),
1451 last_targets: vec![],
1452 movements_this_round: 1,
1453 extra_attack: 0,
1454 },
1455 FightingUnit {
1456 unit_type: UnitType::Rebel,
1457 owner: PlayerInMatchup::Player2,
1458 position: xy(0, h / 2),
1459 force_bonus: false,
1460 health: Some(100),
1461 current_target: Some((0, xy(0, h / 2 - 1))),
1462 last_targets: vec![],
1463 movements_this_round: 1,
1464 extra_attack: 0,
1465 },
1466 FightingUnit {
1467 unit_type: UnitType::Hutt,
1468 owner: PlayerInMatchup::Player2,
1469 position: xy(1, h / 2),
1470 force_bonus: false,
1471 health: Some(100),
1472 current_target: Some((0, xy(0, h / 2 - 1))),
1473 last_targets: vec![],
1474 movements_this_round: 1,
1475 extra_attack: 0,
1476 },
1477 ],
1478 };
1479 let mut attacks = Attack::new_empty();
1480 let mut late_movements = Movement::new_empty();
1481 super::attack(&mut fight, &mut attacks, &mut late_movements, &log::pfx()).await;
1482 assert!(
1484 fight
1485 .living_units
1486 .iter()
1487 .any(|unit| unit.health.is_none_or(|h| h < 100))
1488 );
1489 }
1490
1491 #[tokio::test]
1492 async fn movement_phase() {
1493 let (server, lobby) = get_server_and_lobby();
1494 let (p1id, _, mut p1con) = player_join(&server, &lobby).await;
1495 let (p2id, _, mut p2con) = player_join(&server, &lobby).await;
1496 p1con.clear();
1497 p2con.clear();
1498 let lock = lobby.lock().await;
1499 crate::board::routing::calculate_routing(lock.configs.board_config.clone());
1500 let mut fight = Fight {
1501 lobby: lobby.clone(),
1502 configs: lock.configs.clone(),
1503 power_mode: [1.0, 1.0],
1504 lightsabers: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1505 matchup: (p1id, p2id, false),
1506 winner: None,
1507 round: 1,
1508 living_units: vec![
1509 FightingUnit {
1510 unit_type: UnitType::Wookie,
1511 owner: PlayerInMatchup::Player1,
1512 position: xy(2, 5),
1513 force_bonus: false,
1514 health: Some(1),
1515 current_target: None,
1516 last_targets: vec![],
1517 movements_this_round: 86243,
1518 extra_attack: 0,
1519 },
1520 FightingUnit {
1521 unit_type: UnitType::Jedi,
1522 owner: PlayerInMatchup::Player1,
1523 position: xy(7, 5),
1524 force_bonus: false,
1525 health: Some(1),
1526 current_target: None,
1527 last_targets: vec![],
1528 movements_this_round: 1,
1529 extra_attack: 0,
1530 },
1531 FightingUnit {
1532 unit_type: UnitType::Jedi,
1533 owner: PlayerInMatchup::Player1,
1534 position: xy(5, 7),
1535 force_bonus: false,
1536 health: Some(1),
1537 current_target: None,
1538 last_targets: vec![],
1539 movements_this_round: 1,
1540 extra_attack: 0,
1541 },
1542 FightingUnit {
1543 unit_type: UnitType::Wookie,
1544 owner: PlayerInMatchup::Player2,
1545 position: xy(2, 1),
1546 force_bonus: false,
1547 health: Some(1),
1548 current_target: None,
1549 last_targets: vec![],
1550 movements_this_round: 1,
1551 extra_attack: 0,
1552 },
1553 FightingUnit {
1554 unit_type: UnitType::Sith,
1555 owner: PlayerInMatchup::Player2,
1556 position: xy(2, 2),
1557 force_bonus: false,
1558 health: Some(1),
1559 current_target: None,
1560 last_targets: vec![],
1561 movements_this_round: 1,
1562 extra_attack: 0,
1563 },
1564 FightingUnit {
1565 unit_type: UnitType::Sith,
1566 owner: PlayerInMatchup::Player2,
1567 position: xy(6, 1),
1568 force_bonus: false,
1569 health: Some(1),
1570 current_target: None,
1571 last_targets: vec![2],
1572 movements_this_round: 1,
1573 extra_attack: 0,
1574 },
1575 ],
1576 };
1577
1578 let mut movements = Movement::new_empty();
1579 super::movement(&mut fight, &mut movements, &log::pfx()).await;
1580
1581 assert_eq!(
1583 fight.living_units[0].position,
1584 xy(2, 5),
1585 "Wookie at [2, 5] moved"
1586 );
1587 assert_eq!(
1590 fight.living_units[4].position,
1591 xy(2, 2),
1592 "Sith at [2, 2] moved"
1593 );
1594 assert_eq!(
1595 fight.living_units[5].position,
1596 xy(6, 1),
1597 "Sith at [6, 1] moved"
1598 );
1599 assert_eq!(
1601 fight.living_units[3].position,
1602 xy(2, 1),
1603 "Wookie at [2, 1] moved"
1604 );
1605
1606 assert_eq!(
1609 fight.living_units[1].position.cab_dist(xy(7, 5)),
1610 2,
1611 "Jedi at [7, 5] moved too often/too few times"
1612 );
1613 assert_eq!(
1614 fight.living_units[2].position.cab_dist(xy(5, 7)),
1615 2,
1616 "Jedi at [5, 7] moved too often/too few times"
1617 );
1618 }
1619}