team04_server/lobby/game/
fight.rs

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    /// Broadcasts a GAMESTATE, and does nothing beyond that,
24    /// since the fight phase requires no user/client interaction.
25    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    // Reset all unit's movement counters
45    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 target for all living units
83    pick_targets(fight, |_| true, pfx).await;
84
85    // Let all living units try to move
86    fight
87        .do_moves_if_target_not_in_range(movements, |_| true, pfx)
88        .await;
89
90    // Pick target for all units which can move twice
91    pick_targets(fight, |u| u.max_movements(&info) == 2, pfx).await;
92
93    // Let jedi which have their special ability active move again
94    fight
95        .do_moves_if_target_not_in_range(movements, |u| u.max_movements(&info) == 2, pfx)
96        .await;
97
98    // Pick target for all living units, again
99    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    // reset unit fields which should be set later in the attack phase
118    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    /// Access the unit at the given index
128    macro_rules! units {
129        [$i:expr] => {
130            fight.living_units[$i]
131        };
132    }
133
134    for unit_index in unit_indices {
135        // only living units
136        if units![unit_index].alive() {
137            /// Access the unit at `unit_index`
138            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            // try to attack target
156            if let Some((current_target, current_target_pos)) = unit!().current_target {
157                let unit_attack_range = unit!().attack_range(&info);
158
159                // not sure about the standard. if the target moves away, but another target moves in its place, can we attack that unit instead?
160                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                        // attack
179                        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            // special effects of rodian or rebel
189            {
190                match unit_type {
191                    UnitType::Rodian => {
192                        // only if the unit has attacked
193                        if !unit!().last_targets.is_empty() {
194                            // increase rodian's attack stat further,
195                            // taking the increment from the unit config's `special`.
196                            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                        // only if the unit has attacked
213                        if !unit!().last_targets.is_empty() {
214                            // special effect: heal yourself
215                            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                                // NOTE: This heals via self-attack, not via (late) movement.
224                                // Can be changed, but doesn't really make sense, probably.
225                                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            // add units which have not attacked another unit
249            // and have not exhausted their movement opportunities
250            // to the late movements queue.
251            {
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            // relevant for multi-attack units (sith)
261            {
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                // suppresses a warning, plz ignore (#[allow(_)] isn't good here)
266                debug_assert_eq!(min_dist, usize::MAX);
267                let mut next_closest_units = Vec::new();
268                // depending on standard, previous attack should maybe be assumed as `1`,
269                // which would give siths which have not attacked before one less attack opportunity.
270                for _ in prev_attacks..max_attacks {
271                    log::debug!("trying to perform an additional attack"; pfx);
272                    // try to perform an additional attack
273                    let range = unit!().attack_range(&info) as usize;
274                    // find the next closest units
275                    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                                // units which are alive and have not been attacked in this attack phase
282                                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                        // no more units we could attack, end loop early
305                        log::debug!("giving up on additional attack opportunities because there are no more units to attack"; pfx);
306                        break;
307                    } else {
308                        // choose a random unit from the available ones
309                        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                        // attack
318                        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    // handle late movements
328    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        // second late movement, for jedi
344        if unit.movements_this_round < unit.max_movements(&info) {
345            let prev_pos = unit.position;
346            // NOTE: spec does not specify reevaluating movement targets here,
347            // but it also doesn't explicitly state *not* to do that afaik :|
348            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    // hutts heal themselves
364    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                // this unit has a (nonzero) healing effect due to its special ability
373                // healing is an attack with negative damage
374                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    // activate medic center and lava tiles
382    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    // mark units with health <= 0 as dead
407    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        // NOTE: This is not represented in any message sent over the network
413    }
414
415    log::debug!("END attack phase"; pfx);
416}
417
418struct Fight {
419    lobby: SharedLobbyState,
420    configs: ConfigSet,
421    /// power mode for Player 1, then Player 2
422    power_mode: [f64; 2],
423    /// rgb for Player 1, then Player 2
424    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 for Player 1, then Player 2
438    power_mode: [f64; 2],
439    /// rgb for Player 1, then Player 2
440    lightsabers: [f64; 6],
441}
442
443struct FightingUnit {
444    unit_type: UnitType,
445    owner: PlayerInMatchup,
446    position: Coord,
447    force_bonus: bool,
448    /// if `None`, the unit is dead.
449    health: Option<i64>,
450    /// unit index and its position when
451    /// this target was decided.
452    current_target: Option<(usize, Coord)>,
453    /// The units which were attacked in the previous
454    /// attack phase, in order, if any.
455    last_targets: Vec<usize>,
456    movements_this_round: u64,
457    /// This is added to the attack damage the attack would
458    /// usually inflict. For rodians, it is incremented by a
459    /// config-defined value every time they attack an enemy.
460    extra_attack: u64,
461}
462
463/// Calculates the 1v1 fights
464/// and evaluates their results.
465pub 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    // generate an initial `Fight` state for each matchup,
474    // on which later movement/attack phases can operate.
475    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    // simulate fight rounds
576    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        // movement phase
582        {
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                // add all units which did not move, so that clients can know their starting health
591                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                            // no unit on this unit's position => add this unit and its health
616                            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        // attack and late movement phases
642        {
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                        // this movement did not move, is only a health change
662                        if movements.movements[i].current_position
663                            == movements.movements[i].current_position
664                        {
665                            // try to find a previous movement which moved onto this position and not away anymore
666                            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                                    // unit moved AWAY from the spot this healing thing points to, give up merging
673                                    // to avoid merging with a unit which is not the correct one.
674                                    break;
675                                }
676                                if movements.movements[j].target_position
677                                    == movements.movements[i].current_position
678                                {
679                                    // found a previous movement ONTO the position on which we now have a health change
680                                    // -> merge the health change into that movement
681                                    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        // evaluate round outcomes
697        {
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                            // only one player has living units left,
705                            // that player wins
706                            fight.winner = Some(Some(living_unit.owner));
707                        }
708                    } else {
709                        // all units are dead, fight ends in a draw
710                        fight.winner = Some(None);
711                    }
712                    if let Some(winner) = fight.winner {
713                        // a winner has been decided after this fightround
714                        if lock.is_none() {
715                            // only lock lobby state if at least one fight has ended
716                            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    // evaluate fight outcomes
732    {
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                // get losing player's ID and increment winning player's `fights_won`.
738                // `loser` is `None` if the losing player was a ghost.
739                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                // TODO: how many lives should players lose if the fight ends in a draw
779                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    /// Makes one movement attempt, see [gen_unit_movement](Self::gen_unit_movement).
819    /// If the movement attempt succeeds, increments `movements_this_round`
820    /// and adds the movement to the movements message.
821    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    /// Makes one movement attempt. May return None if movement is not possible because another
836    /// unit blocks the path, or if the unit has no target. Panics if this unit
837    /// is dead. Not recommended to be called directly, use [do_moves_if_target_not_in_range](Self::do_moves_if_target_not_in_range)
838    /// or [try_move_unit](Self::try_move_unit) instead.
839    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    /// Perform movements (and push them to `movements`) for all units for which `filter` returns
895    /// `true`, and which do not have their target in range.
896    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    /// an `enemy_armor` of `0` means the `base` damage will not be modified.
926    /// bonus damage completely ignores armor.
927    fn mod_attack(
928        &self,
929        player: PlayerInMatchup,
930        base: u64,
931        enemy_armor: u64,
932        bonus_damage: u64,
933    ) -> u64 {
934        // NOTE: order of power mode vs. lightsabers is unclear.
935        // Without brackets around addition: Power Mode first
936        // With brackets around addition: Lightsabers first
937        ((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        // apply enemy's armor.
946        // NOTE: this rounds the damage down
947        * 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    /// `true` iff `health` is `Some(_)`.
986    /// Units can have negative health, but they
987    /// can only die at certain points. Truly dead
988    /// units have a health value of `None`.
989    fn alive(&self) -> bool {
990        self.health.is_some()
991    }
992    /// If the unit is alive but has `health <= 0`,
993    /// make `unit.alive()` return `false` from now on.
994    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        // WARN: not sure if thats all that affects max health
1002        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                    // these units give a global bonus
1016                    + 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        // WARN: not sure if thats all that affects the attack range
1031        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        // WARN: not sure if thats all that affects armor
1046        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                    // these units give a global bonus
1060                    + 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        // WARN: not sure if thats all that affects the attack damage
1089        // normal damage, enemy's armor applies
1090        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                        // rodians use `self.extra_attack` instead,
1098                        // stormtroopers give a global bonus (0 here to avoid duplicating that bonus)
1099                        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                    // these units give a global bonus
1106                    + 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        // bonus damage, ignores armor
1113        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        // NOTE: the default for `special.targets`, as specified in the stdwiki, is `1`.
1143        // For sith, it should be set to a value `>= 2` if an effect is desired.
1144        // Setting it to zero may not work as expected.
1145        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    /// Move to the given position.
1154    /// Does **not** check if the position can actually be reached.
1155    fn movement(&mut self, position: Coord, movements: &mut Movement, info: &FightInfo) {
1156        self.movement_message(position, self.health, movements, info);
1157    }
1158    /// Heals this unit by the given amount.
1159    /// The unit cannot heal above its `max_health()` value.
1160    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    /// Inflicts the given amount of damage on the unit.
1170    /// The unit **can** fall to or below zero health without dying, see [dying](Self::die_if_dead).
1171    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    /// If `raw_damage.is_none()`, damage is applied based on the two units' stats.
1210    /// If `raw_damage.is_some()`, the provided damage is applied regardless of the two units' stats (used for healing).
1211    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        // define what happens to the attacked unit
1223        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    /// Picks the next target to move to / attack. May be None if there are no units left.
1261    async fn pick_target(&self, fight: &Fight) -> Option<(usize, Coord)> {
1262        let info = fight.info();
1263        // 1st, check previously attacked unit
1264        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        // 2nd, check units in range
1272        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        // 3rd, check all units
1306        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    /// Returns true if this unit could attack `other` based on its range.
1343    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            // set to 1 if no entry or add 1 to existing entry
1382            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        // at least one unit has taken damage
1483        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        // The wookie at [2, 5] did not move, because it can attack [2, 2]
1582        assert_eq!(
1583            fight.living_units[0].position,
1584            xy(2, 5),
1585            "Wookie at [2, 5] moved"
1586        );
1587        // The siths at [2, 2], [6, 1] did not move, because they can attack other units, because
1588        // they have infinite range
1589        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        // The wookie at [2, 1] tried to move, but was obstructed by [2, 2]
1600        assert_eq!(
1601            fight.living_units[3].position,
1602            xy(2, 1),
1603            "Wookie at [2, 1] moved"
1604        );
1605
1606        // Both Jedi ([7, 5],[5, 7]) moved twice, so they have a distance of 2 to their starting
1607        // positions
1608        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}