ferravex_play/
rounds.rs

1//! Authoritative tactical round/economy rules. The host supplies validated movement
2//! and hit results; this module validates teams, phase, money, bomb possession,
3//! interaction distance, alive state, and uninterrupted plant/defuse durations.
4use std::collections::VecDeque;
5
6pub type PlayerId = u16;
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum Team {
9    Attack,
10    Defend,
11}
12impl Team {
13    fn index(self) -> usize {
14        match self {
15            Self::Attack => 0,
16            Self::Defend => 1,
17        }
18    }
19    fn other(self) -> Self {
20        match self {
21            Self::Attack => Self::Defend,
22            Self::Defend => Self::Attack,
23        }
24    }
25}
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Weapon {
28    Pistol,
29    Rifle,
30    Sniper,
31}
32impl Weapon {
33    pub fn price(self) -> u32 {
34        match self {
35            Self::Pistol => 200,
36            Self::Rifle => 2700,
37            Self::Sniper => 4750,
38        }
39    }
40}
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum Phase {
43    Buy,
44    Live,
45    Planted,
46    PostRound,
47    Finished,
48}
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum WinReason {
51    Elimination,
52    TimeExpired,
53    Explosion,
54    Defused,
55}
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum Bomb {
58    Carried(PlayerId),
59    Dropped([f32; 3]),
60    Planted { site: usize, position: [f32; 3] },
61}
62#[derive(Clone, Copy, Debug)]
63pub struct Site {
64    pub center: [f32; 3],
65    pub radius: f32,
66}
67#[derive(Clone, Copy, Debug)]
68pub struct PlayerSetup {
69    pub id: PlayerId,
70    pub team: Team,
71    pub spawn: [f32; 3],
72}
73#[derive(Clone, Debug)]
74pub struct Player {
75    pub id: PlayerId,
76    pub team: Team,
77    pub position: [f32; 3],
78    pub health: u16,
79    pub money: u32,
80    pub weapon: Weapon,
81    pub armor: u16,
82    pub defuse_kit: bool,
83    spawn: [f32; 3],
84}
85impl Player {
86    pub fn alive(&self) -> bool {
87        self.health > 0
88    }
89}
90#[derive(Clone, Debug)]
91pub struct Config {
92    pub buy_seconds: f32,
93    pub round_seconds: f32,
94    pub bomb_seconds: f32,
95    pub plant_seconds: f32,
96    pub defuse_seconds: f32,
97    pub kit_defuse_seconds: f32,
98    pub intermission_seconds: f32,
99    pub rounds_to_win: u16,
100    pub starting_money: u32,
101    pub sites: Vec<Site>,
102}
103impl Default for Config {
104    fn default() -> Self {
105        Self {
106            buy_seconds: 10.0,
107            round_seconds: 115.0,
108            bomb_seconds: 40.0,
109            plant_seconds: 3.0,
110            defuse_seconds: 10.0,
111            kit_defuse_seconds: 5.0,
112            intermission_seconds: 3.0,
113            rounds_to_win: 13,
114            starting_money: 800,
115            sites: vec![
116                Site {
117                    center: [10.0, 0.0, 10.0],
118                    radius: 4.0,
119                },
120                Site {
121                    center: [-10.0, 0.0, 10.0],
122                    radius: 4.0,
123                },
124            ],
125        }
126    }
127}
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum Action {
130    BuyWeapon(Weapon),
131    BuyArmor,
132    BuyDefuseKit,
133    Plant(usize),
134    Defuse,
135    Cancel,
136    DropBomb,
137    PickUpBomb,
138}
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum RuleError {
141    InvalidConfig,
142    InvalidRoster,
143    UnknownPlayer,
144    WrongPhase,
145    Dead,
146    WrongTeam,
147    InsufficientFunds,
148    AlreadyOwned,
149    BombUnavailable,
150    OutOfRange,
151    Busy,
152    InvalidInput,
153}
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum Event {
156    RoundStarted(u32),
157    Killed {
158        attacker: PlayerId,
159        victim: PlayerId,
160    },
161    Planted {
162        player: PlayerId,
163        site: usize,
164    },
165    RoundWon {
166        team: Team,
167        reason: WinReason,
168    },
169    MatchWon(Team),
170}
171#[derive(Clone, Copy)]
172enum Interaction {
173    Plant(usize),
174    Defuse,
175}
176#[derive(Clone, Copy)]
177struct Channel {
178    player: PlayerId,
179    action: Interaction,
180    elapsed: f32,
181    required: f32,
182    origin: [f32; 3],
183}
184
185pub struct TacticalMatch {
186    config: Config,
187    players: Vec<Player>,
188    phase: Phase,
189    remaining: f32,
190    bomb: Bomb,
191    channel: Option<Channel>,
192    score: [u16; 2],
193    loss_streak: [u8; 2],
194    round: u32,
195    last_winner: Option<Team>,
196    events: VecDeque<Event>,
197}
198impl TacticalMatch {
199    pub fn new(config: Config, roster: Vec<PlayerSetup>) -> Result<Self, RuleError> {
200        if ![
201            config.buy_seconds,
202            config.round_seconds,
203            config.bomb_seconds,
204            config.plant_seconds,
205            config.defuse_seconds,
206            config.kit_defuse_seconds,
207            config.intermission_seconds,
208        ]
209        .into_iter()
210        .all(|n| n.is_finite() && n > 0.0 && n <= 3600.0)
211            || config.rounds_to_win == 0
212            || config.starting_money > 16000
213            || config.sites.is_empty()
214            || config.sites.len() > 32
215            || config
216                .sites
217                .iter()
218                .any(|site| !finite(site.center) || !site.radius.is_finite() || site.radius <= 0.0)
219        {
220            return Err(RuleError::InvalidConfig);
221        }
222        if roster.len() < 2
223            || roster.len() > 64
224            || !roster.iter().any(|p| p.team == Team::Attack)
225            || !roster.iter().any(|p| p.team == Team::Defend)
226            || roster
227                .iter()
228                .enumerate()
229                .any(|(i, p)| !finite(p.spawn) || roster[..i].iter().any(|old| old.id == p.id))
230        {
231            return Err(RuleError::InvalidRoster);
232        }
233        let carrier = roster
234            .iter()
235            .find(|p| p.team == Team::Attack)
236            .expect("validated attackers")
237            .id;
238        let players = roster
239            .into_iter()
240            .map(|p| Player {
241                id: p.id,
242                team: p.team,
243                position: p.spawn,
244                spawn: p.spawn,
245                health: 100,
246                money: config.starting_money,
247                weapon: Weapon::Pistol,
248                armor: 0,
249                defuse_kit: false,
250            })
251            .collect();
252        let remaining = config.buy_seconds;
253        let mut game = Self {
254            config,
255            players,
256            phase: Phase::Buy,
257            remaining,
258            bomb: Bomb::Carried(carrier),
259            channel: None,
260            score: [0; 2],
261            loss_streak: [0; 2],
262            round: 1,
263            last_winner: None,
264            events: VecDeque::with_capacity(128),
265        };
266        game.emit(Event::RoundStarted(1));
267        Ok(game)
268    }
269    pub fn phase(&self) -> Phase {
270        self.phase
271    }
272    pub fn remaining(&self) -> f32 {
273        self.remaining
274    }
275    pub fn round(&self) -> u32 {
276        self.round
277    }
278    pub fn score(&self) -> [u16; 2] {
279        self.score
280    }
281    pub fn bomb(&self) -> Bomb {
282        self.bomb
283    }
284    pub fn players(&self) -> &[Player] {
285        &self.players
286    }
287    pub fn player(&self, id: PlayerId) -> Option<&Player> {
288        self.players.iter().find(|p| p.id == id)
289    }
290    pub fn interaction_progress(&self) -> Option<(PlayerId, f32)> {
291        self.channel
292            .map(|c| (c.player, (c.elapsed / c.required).clamp(0.0, 1.0)))
293    }
294    pub fn pop_event(&mut self) -> Option<Event> {
295        self.events.pop_front()
296    }
297    /// Integrate movement on the authoritative host, then provide its accepted position.
298    /// Moving >15 cm from interaction origin interrupts plant/defuse.
299    pub fn set_position(&mut self, id: PlayerId, position: [f32; 3]) -> Result<(), RuleError> {
300        if !finite(position) {
301            return Err(RuleError::InvalidInput);
302        }
303        let index = self.index(id)?;
304        if !self.players[index].alive() {
305            return Err(RuleError::Dead);
306        }
307        self.players[index].position = position;
308        if self
309            .channel
310            .is_some_and(|c| c.player == id && distance(c.origin, position) > 0.15)
311        {
312            self.channel = None;
313        }
314        Ok(())
315    }
316    pub fn act(&mut self, id: PlayerId, action: Action) -> Result<(), RuleError> {
317        let index = self.index(id)?;
318        let player = &self.players[index];
319        if !player.alive() {
320            return Err(RuleError::Dead);
321        }
322        match action {
323            Action::BuyWeapon(weapon) => {
324                if self.phase != Phase::Buy {
325                    return Err(RuleError::WrongPhase);
326                }
327                if player.weapon == weapon {
328                    return Err(RuleError::AlreadyOwned);
329                }
330                self.spend(index, weapon.price())?;
331                self.players[index].weapon = weapon;
332            }
333            Action::BuyArmor => {
334                if self.phase != Phase::Buy {
335                    return Err(RuleError::WrongPhase);
336                }
337                if player.armor == 100 {
338                    return Err(RuleError::AlreadyOwned);
339                }
340                self.spend(index, 650)?;
341                self.players[index].armor = 100;
342            }
343            Action::BuyDefuseKit => {
344                if self.phase != Phase::Buy {
345                    return Err(RuleError::WrongPhase);
346                }
347                if player.team != Team::Defend {
348                    return Err(RuleError::WrongTeam);
349                }
350                if player.defuse_kit {
351                    return Err(RuleError::AlreadyOwned);
352                }
353                self.spend(index, 400)?;
354                self.players[index].defuse_kit = true;
355            }
356            Action::Cancel => {
357                if self.channel.is_some_and(|c| c.player == id) {
358                    self.channel = None;
359                }
360            }
361            Action::Plant(site_index) => {
362                if self.phase != Phase::Live {
363                    return Err(RuleError::WrongPhase);
364                }
365                if player.team != Team::Attack {
366                    return Err(RuleError::WrongTeam);
367                }
368                if self.bomb != Bomb::Carried(id) {
369                    return Err(RuleError::BombUnavailable);
370                }
371                let site = self
372                    .config
373                    .sites
374                    .get(site_index)
375                    .ok_or(RuleError::OutOfRange)?;
376                if distance(site.center, player.position) > site.radius {
377                    return Err(RuleError::OutOfRange);
378                }
379                if self.channel.is_some() {
380                    return Err(RuleError::Busy);
381                }
382                self.channel = Some(Channel {
383                    player: id,
384                    action: Interaction::Plant(site_index),
385                    elapsed: 0.0,
386                    required: self.config.plant_seconds,
387                    origin: player.position,
388                });
389            }
390            Action::Defuse => {
391                if self.phase != Phase::Planted {
392                    return Err(RuleError::WrongPhase);
393                }
394                if player.team != Team::Defend {
395                    return Err(RuleError::WrongTeam);
396                }
397                let Bomb::Planted { position, .. } = self.bomb else {
398                    return Err(RuleError::BombUnavailable);
399                };
400                if distance(position, player.position) > 2.5 {
401                    return Err(RuleError::OutOfRange);
402                }
403                if self.channel.is_some() {
404                    return Err(RuleError::Busy);
405                }
406                self.channel = Some(Channel {
407                    player: id,
408                    action: Interaction::Defuse,
409                    elapsed: 0.0,
410                    required: if player.defuse_kit {
411                        self.config.kit_defuse_seconds
412                    } else {
413                        self.config.defuse_seconds
414                    },
415                    origin: player.position,
416                });
417            }
418            Action::DropBomb => {
419                if self.phase != Phase::Live {
420                    return Err(RuleError::WrongPhase);
421                }
422                if self.bomb != Bomb::Carried(id) {
423                    return Err(RuleError::BombUnavailable);
424                }
425                self.bomb = Bomb::Dropped(player.position);
426                self.channel = None;
427            }
428            Action::PickUpBomb => {
429                if self.phase != Phase::Live {
430                    return Err(RuleError::WrongPhase);
431                }
432                if player.team != Team::Attack {
433                    return Err(RuleError::WrongTeam);
434                }
435                let Bomb::Dropped(position) = self.bomb else {
436                    return Err(RuleError::BombUnavailable);
437                };
438                if distance(player.position, position) > 1.5 {
439                    return Err(RuleError::OutOfRange);
440                }
441                self.bomb = Bomb::Carried(id);
442            }
443        }
444        Ok(())
445    }
446    /// Apply a server-confirmed hit. Clients must never supply unvalidated hit results.
447    pub fn damage(
448        &mut self,
449        attacker: PlayerId,
450        victim: PlayerId,
451        amount: u16,
452    ) -> Result<(), RuleError> {
453        if !matches!(self.phase, Phase::Live | Phase::Planted) {
454            return Err(RuleError::WrongPhase);
455        }
456        let a = self.index(attacker)?;
457        let v = self.index(victim)?;
458        if !self.players[a].alive() || !self.players[v].alive() {
459            return Err(RuleError::Dead);
460        }
461        if self.players[a].team == self.players[v].team {
462            return Err(RuleError::WrongTeam);
463        }
464        let absorbed = self.players[v].armor.min(amount / 2);
465        self.players[v].armor -= absorbed;
466        self.players[v].health = self.players[v].health.saturating_sub(amount - absorbed);
467        if !self.players[v].alive() {
468            self.players[a].money = (self.players[a].money + 300).min(16000);
469            if self.bomb == Bomb::Carried(victim) {
470                self.bomb = Bomb::Dropped(self.players[v].position);
471            }
472            if self.channel.is_some_and(|c| c.player == victim) {
473                self.channel = None;
474            }
475            self.emit(Event::Killed { attacker, victim });
476            self.check_elimination();
477        }
478        Ok(())
479    }
480    /// Splits elapsed time at every phase/interaction deadline. Large steps produce
481    /// the same rule transitions as small steps. Bomb expiry wins exact defuse ties.
482    pub fn tick(&mut self, mut dt: f32) -> Result<(), RuleError> {
483        if !dt.is_finite() || !(0.0..=3600.0).contains(&dt) {
484            return Err(RuleError::InvalidInput);
485        }
486        while dt > 0.0 && self.phase != Phase::Finished {
487            let channel_left = self
488                .channel
489                .map_or(f32::INFINITY, |c| (c.required - c.elapsed).max(0.0));
490            let step = dt.min(self.remaining).min(channel_left);
491            self.remaining = (self.remaining - step).max(0.0);
492            if let Some(channel) = &mut self.channel {
493                channel.elapsed += step;
494            }
495            dt = (dt - step).max(0.0);
496            if self.phase == Phase::Planted && self.remaining <= 1e-6 {
497                self.finish_round(Team::Attack, WinReason::Explosion);
498                continue;
499            }
500            if self.channel.is_some_and(|c| c.elapsed + 1e-6 >= c.required) {
501                self.finish_channel();
502                continue;
503            }
504            if self.remaining <= 1e-6 {
505                match self.phase {
506                    Phase::Buy => {
507                        self.phase = Phase::Live;
508                        self.remaining = self.config.round_seconds;
509                    }
510                    Phase::Live => self.finish_round(Team::Defend, WinReason::TimeExpired),
511                    Phase::PostRound => self.next_round(),
512                    Phase::Planted | Phase::Finished => {
513                        unreachable!("handled before phase transitions")
514                    }
515                }
516            }
517        }
518        Ok(())
519    }
520    fn index(&self, id: PlayerId) -> Result<usize, RuleError> {
521        self.players
522            .iter()
523            .position(|p| p.id == id)
524            .ok_or(RuleError::UnknownPlayer)
525    }
526    fn spend(&mut self, index: usize, price: u32) -> Result<(), RuleError> {
527        let player = &mut self.players[index];
528        if player.money < price {
529            return Err(RuleError::InsufficientFunds);
530        }
531        player.money -= price;
532        Ok(())
533    }
534    fn finish_channel(&mut self) {
535        let channel = self.channel.take().expect("completed channel");
536        match channel.action {
537            Interaction::Plant(site) => {
538                let index = self.index(channel.player).expect("roster stable");
539                self.bomb = Bomb::Planted {
540                    site,
541                    position: channel.origin,
542                };
543                self.phase = Phase::Planted;
544                self.remaining = self.config.bomb_seconds;
545                self.players[index].money = (self.players[index].money + 300).min(16000);
546                self.emit(Event::Planted {
547                    player: channel.player,
548                    site,
549                });
550            }
551            Interaction::Defuse => self.finish_round(Team::Defend, WinReason::Defused),
552        }
553    }
554    fn check_elimination(&mut self) {
555        let attacks = self
556            .players
557            .iter()
558            .any(|p| p.team == Team::Attack && p.alive());
559        let defends = self
560            .players
561            .iter()
562            .any(|p| p.team == Team::Defend && p.alive());
563        if !defends {
564            self.finish_round(Team::Attack, WinReason::Elimination);
565        } else if !attacks && self.phase != Phase::Planted {
566            self.finish_round(Team::Defend, WinReason::Elimination);
567        }
568    }
569    fn finish_round(&mut self, winner: Team, reason: WinReason) {
570        if !matches!(self.phase, Phase::Live | Phase::Planted) {
571            return;
572        }
573        self.phase = Phase::PostRound;
574        self.remaining = self.config.intermission_seconds;
575        self.channel = None;
576        self.last_winner = Some(winner);
577        self.score[winner.index()] += 1;
578        self.loss_streak[winner.index()] = 0;
579        let loser = winner.other();
580        self.loss_streak[loser.index()] = (self.loss_streak[loser.index()] + 1).min(5);
581        let loss_reward = 1400 + u32::from(self.loss_streak[loser.index()] - 1) * 500;
582        for player in &mut self.players {
583            player.money = (player.money
584                + if player.team == winner {
585                    3250
586                } else {
587                    loss_reward
588                })
589            .min(16000);
590        }
591        self.emit(Event::RoundWon {
592            team: winner,
593            reason,
594        });
595    }
596    fn next_round(&mut self) {
597        let winner = self.last_winner.expect("post round has winner");
598        if self.score[winner.index()] >= self.config.rounds_to_win {
599            self.phase = Phase::Finished;
600            self.remaining = 0.0;
601            self.emit(Event::MatchWon(winner));
602            return;
603        }
604        self.round += 1;
605        self.phase = Phase::Buy;
606        self.remaining = self.config.buy_seconds;
607        for player in &mut self.players {
608            if !player.alive() {
609                player.weapon = Weapon::Pistol;
610                player.armor = 0;
611                player.defuse_kit = false;
612            }
613            player.health = 100;
614            player.position = player.spawn;
615        }
616        self.bomb = Bomb::Carried(
617            self.players
618                .iter()
619                .find(|p| p.team == Team::Attack)
620                .expect("roster has attackers")
621                .id,
622        );
623        self.emit(Event::RoundStarted(self.round));
624    }
625    fn emit(&mut self, event: Event) {
626        if self.events.len() == 128 {
627            self.events.pop_front();
628        }
629        self.events.push_back(event);
630    }
631}
632fn finite(v: [f32; 3]) -> bool {
633    v.into_iter().all(f32::is_finite)
634}
635fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
636    ((f64::from(a[0]) - f64::from(b[0])).powi(2)
637        + (f64::from(a[1]) - f64::from(b[1])).powi(2)
638        + (f64::from(a[2]) - f64::from(b[2])).powi(2))
639    .sqrt() as f32
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    fn game() -> TacticalMatch {
646        TacticalMatch::new(
647            Config {
648                buy_seconds: 1.0,
649                round_seconds: 10.0,
650                bomb_seconds: 8.0,
651                plant_seconds: 2.0,
652                defuse_seconds: 4.0,
653                kit_defuse_seconds: 2.0,
654                intermission_seconds: 1.0,
655                rounds_to_win: 2,
656                starting_money: 4000,
657                sites: vec![Site {
658                    center: [0.0; 3],
659                    radius: 3.0,
660                }],
661            },
662            vec![
663                PlayerSetup {
664                    id: 1,
665                    team: Team::Attack,
666                    spawn: [0.0; 3],
667                },
668                PlayerSetup {
669                    id: 2,
670                    team: Team::Defend,
671                    spawn: [1.0, 0.0, 0.0],
672                },
673            ],
674        )
675        .unwrap()
676    }
677    #[test]
678    fn purchases_validate_team_phase_money_and_ownership() {
679        let mut g = game();
680        g.act(1, Action::BuyWeapon(Weapon::Rifle)).unwrap();
681        assert_eq!(g.player(1).unwrap().money, 1300);
682        assert_eq!(
683            g.act(1, Action::BuyWeapon(Weapon::Sniper)),
684            Err(RuleError::InsufficientFunds)
685        );
686        assert_eq!(g.act(1, Action::BuyDefuseKit), Err(RuleError::WrongTeam));
687        g.act(2, Action::BuyDefuseKit).unwrap();
688        assert_eq!(g.act(2, Action::BuyDefuseKit), Err(RuleError::AlreadyOwned));
689        g.tick(1.0).unwrap();
690        assert_eq!(g.act(1, Action::BuyArmor), Err(RuleError::WrongPhase));
691    }
692    #[test]
693    fn plant_defuse_requires_uninterrupted_distance_checked_channels() {
694        let mut g = game();
695        g.act(2, Action::BuyDefuseKit).unwrap();
696        g.tick(1.0).unwrap();
697        g.act(1, Action::Plant(0)).unwrap();
698        g.tick(1.0).unwrap();
699        g.set_position(1, [0.2, 0.0, 0.0]).unwrap();
700        assert!(g.interaction_progress().is_none());
701        g.act(1, Action::Plant(0)).unwrap();
702        g.tick(2.0).unwrap();
703        assert_eq!(g.phase(), Phase::Planted);
704        g.act(2, Action::Defuse).unwrap();
705        g.tick(2.0).unwrap();
706        assert_eq!(g.phase(), Phase::PostRound);
707        assert_eq!(g.score(), [0, 1]);
708        assert_eq!(g.player(1).unwrap().money, 5700); // plant bonus + first loss
709    }
710    #[test]
711    fn planted_bomb_survives_attacker_elimination_and_expires() {
712        let mut g = game();
713        g.tick(1.0).unwrap();
714        g.act(1, Action::Plant(0)).unwrap();
715        g.tick(2.0).unwrap();
716        g.damage(2, 1, 100).unwrap();
717        assert_eq!(g.phase(), Phase::Planted);
718        g.tick(8.0).unwrap();
719        assert_eq!(g.score(), [1, 0]);
720    }
721    #[test]
722    fn elimination_economy_respawn_and_match_end() {
723        let mut g = game();
724        g.tick(1.0).unwrap();
725        g.damage(1, 2, 100).unwrap();
726        assert_eq!(g.player(1).unwrap().money, 7550);
727        assert_eq!(g.player(2).unwrap().money, 5400);
728        g.tick(1.0).unwrap();
729        assert_eq!(g.round(), 2);
730        assert!(g.player(2).unwrap().alive());
731        g.tick(1.0).unwrap();
732        g.damage(1, 2, 100).unwrap();
733        g.tick(1.0).unwrap();
734        assert_eq!(g.phase(), Phase::Finished);
735        assert_eq!(g.score(), [2, 0]);
736        assert_eq!(g.player(2).unwrap().money, 7300); // escalating loss bonus
737    }
738    #[test]
739    fn time_partitioning_and_exact_defuse_deadline_are_defined() {
740        let mut a = game();
741        let mut b = game();
742        a.tick(12.0).unwrap();
743        for _ in 0..120 {
744            b.tick(0.1).unwrap();
745        }
746        assert_eq!(a.phase(), b.phase());
747        assert_eq!(a.score(), b.score());
748        assert_eq!(a.round(), b.round());
749        let mut g = game();
750        g.tick(1.0).unwrap();
751        g.act(1, Action::Plant(0)).unwrap();
752        g.tick(2.0).unwrap();
753        g.tick(4.0).unwrap();
754        g.act(2, Action::Defuse).unwrap();
755        g.tick(4.0).unwrap();
756        assert_eq!(g.score(), [1, 0]); // explosion wins exact tie
757    }
758    #[test]
759    fn dropped_bomb_rejects_defender_and_remote_pickup() {
760        let mut g = game();
761        g.tick(1.0).unwrap();
762        g.act(1, Action::DropBomb).unwrap();
763        assert_eq!(g.act(2, Action::PickUpBomb), Err(RuleError::WrongTeam));
764        g.set_position(1, [5.0, 0.0, 0.0]).unwrap();
765        assert_eq!(g.act(1, Action::PickUpBomb), Err(RuleError::OutOfRange));
766        g.set_position(1, [0.0; 3]).unwrap();
767        g.act(1, Action::PickUpBomb).unwrap();
768        assert_eq!(g.bomb(), Bomb::Carried(1));
769    }
770}