//! Rollback-safe input history and command buffering. use bevy::prelude::*; use bevy_ggrs::{PlayerInputs, Rollback}; use strum::IntoEnumIterator; use strum_macros::EnumIter; use crate::fighter::FighterFacingDirection; use crate::fighter::attack::AttackKind; use crate::game_settings::GameSettings; use crate::input::{DigitalAction, FighterInputFrame}; use crate::math::int::FGi32; use crate::math::vec::FGVec2; use crate::netcode::GGRSCfg; use crate::player::Player; #[derive(Clone, Copy, EnumIter, Debug, PartialEq, Eq, Hash)] pub enum FighterCommands { Jump, Shield, GuardHold, Attack, SmashMoveUp, SmashMoveDown, SmashMoveLeft, SmashMoveRight, TapJump, } pub(crate) fn second_binding_pressed( current: crate::input::ActionBindingMask, previous: crate::input::ActionBindingMask, ) -> bool { current.count() >= 2 && current.pressed_since(previous).0 != 0 } impl FighterCommands { pub const COUNT: usize = 9; pub const fn index(self) -> usize { match self { Self::Jump => 0, Self::Shield => 1, Self::GuardHold => 2, Self::Attack => 3, Self::SmashMoveUp => 4, Self::SmashMoveDown => 5, Self::SmashMoveLeft => 6, Self::SmashMoveRight => 7, Self::TapJump => 8, } } } #[derive(Default, Clone, Hash, Copy, Debug)] pub enum FighterAttackCommandType { #[default] Neutral, SmashAttackUp, SmashAttackDown, SmashAttackLeft, SmashAttackRight, TiltAttackUp, TiltAttackDown, TiltAttackLeft, TiltAttackRight, } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum FlickDirection { Up, Down, Left, Right, } impl FlickDirection { const fn smash_move_command(self) -> FighterCommands { match self { Self::Up => FighterCommands::SmashMoveUp, Self::Down => FighterCommands::SmashMoveDown, Self::Left => FighterCommands::SmashMoveLeft, Self::Right => FighterCommands::SmashMoveRight, } } const fn smash_attack_command(self) -> FighterAttackCommandType { match self { Self::Up => FighterAttackCommandType::SmashAttackUp, Self::Down => FighterAttackCommandType::SmashAttackDown, Self::Left => FighterAttackCommandType::SmashAttackLeft, Self::Right => FighterAttackCommandType::SmashAttackRight, } } } impl FighterAttackCommandType { pub fn to_attack_kind_grounded(&self) -> AttackKind { match self { Self::Neutral => AttackKind::Jab, Self::SmashAttackUp => AttackKind::UpSmash, Self::SmashAttackDown => AttackKind::DownSmash, Self::SmashAttackLeft | Self::SmashAttackRight => AttackKind::ForwardSmash, Self::TiltAttackUp => AttackKind::UpTilt, Self::TiltAttackDown => AttackKind::DownTilt, Self::TiltAttackLeft | Self::TiltAttackRight => AttackKind::ForwardTilt, } } pub fn to_attack_kind_airborne(&self, facing_direction: &FighterFacingDirection) -> AttackKind { match self { Self::Neutral => AttackKind::NeutralAir, Self::SmashAttackUp | Self::TiltAttackUp => AttackKind::UpAir, Self::SmashAttackDown | Self::TiltAttackDown => AttackKind::DownAir, Self::SmashAttackLeft | Self::TiltAttackLeft => match facing_direction { FighterFacingDirection::Left => AttackKind::ForwardAir, FighterFacingDirection::Right => AttackKind::BackAir, }, Self::SmashAttackRight | Self::TiltAttackRight => match facing_direction { FighterFacingDirection::Left => AttackKind::BackAir, FighterFacingDirection::Right => AttackKind::ForwardAir, }, } } } #[derive(Clone, Copy, Debug, Hash)] struct CommandSlot { lifetime: u8, age: u16, hold_remaining: HoldLimit, consumed: bool, } impl Default for CommandSlot { fn default() -> Self { Self { lifetime: 0, age: 0, hold_remaining: HoldLimit::Disabled, consumed: false, } } } #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] enum HoldLimit { #[default] Disabled, Frames(u16), Infinite, } #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] enum HoldOverride { #[default] Inherit, Disabled, Frames(u16), Infinite, } impl HoldOverride { fn from_max_frames(max_frames: u16) -> Self { if max_frames == 0 { Self::Infinite } else { Self::Frames(max_frames) } } } impl HoldLimit { fn from_max_frames(max_frames: u16) -> Self { if max_frames == 0 { Self::Infinite } else { Self::Frames(max_frames) } } } #[derive(Clone, Copy, Debug, Hash)] struct ButtonHistory { trigger_age: u16, release_age: u16, held_frames: u16, secondary_trigger_age: u16, } impl Default for ButtonHistory { fn default() -> Self { Self { trigger_age: u16::MAX, release_age: u16::MAX, held_frames: 0, secondary_trigger_age: u16::MAX, } } } impl ButtonHistory { fn update(&mut self, current: u16, previous: u16) { let pressed = current & !previous; let released = previous & !current; self.trigger_age = self.trigger_age.saturating_add(1); self.release_age = self.release_age.saturating_add(1); self.secondary_trigger_age = self.secondary_trigger_age.saturating_add(1); if pressed != 0 { self.trigger_age = 0; } if released != 0 { self.release_age = 0; } if current != 0 { self.held_frames = self.held_frames.saturating_add(1); } else { self.held_frames = 0; } if current.count_ones() >= 2 && (pressed != 0 || previous.count_ones() < 2) { self.secondary_trigger_age = 0; } } } #[derive(Clone, Copy, Debug, Hash)] struct FlickAxisState { age: u8, eligible: bool, consumed: bool, command_emitted: bool, } impl Default for FlickAxisState { fn default() -> Self { Self { age: u8::MAX, eligible: false, consumed: false, command_emitted: false, } } } impl FlickAxisState { fn update( &mut self, current: FGi32, previous: FGi32, reset_threshold: FGi32, axis_threshold: FGi32, frame_threshold: u8, ) { if current.abs() < reset_threshold { *self = Self::default(); return; } if previous.abs() < reset_threshold || previous.signum() != current.signum() { self.age = 1; self.consumed = false; self.command_emitted = false; } else { self.age = self.age.saturating_add(1); } self.eligible = !self.consumed && current.abs() >= axis_threshold && self.age <= frame_threshold; } } #[derive(Clone, Copy, Debug, Default, Hash)] struct StickFlickState { x: FlickAxisState, y: FlickAxisState, } impl StickFlickState { fn update( &mut self, current: FGVec2, previous: FGVec2, reset_threshold: FGi32, axis_threshold: FGi32, frame_threshold: u8, ) { self.x.update( current.x, previous.x, reset_threshold, axis_threshold, frame_threshold, ); self.y.update( current.y, previous.y, reset_threshold, axis_threshold, frame_threshold, ); } fn peek(&self, value: FGVec2) -> Option { self.resolve(value, self.x.eligible, self.y.eligible) } /// Returns each axis that has not produced a passive command during this /// gesture. This does not consume the flick, so an attack can still use /// the remainder of its smash-input window. fn next_commands(&mut self, value: FGVec2) -> [Option; 2] { let use_x = self.x.eligible && !self.x.command_emitted; let use_y = self.y.eligible && !self.y.command_emitted; if use_x { self.x.command_emitted = true; } if use_y { self.y.command_emitted = true; } [ use_x.then_some(if value.x.is_positive() { FlickDirection::Right } else { FlickDirection::Left }), use_y.then_some(if value.y.is_positive() { FlickDirection::Up } else { FlickDirection::Down }), ] } fn consume(&mut self, value: FGVec2) -> Option { let direction = self.peek(value)?; // Simultaneous eligible axes are one diagonal gesture. Consuming only // the selected axis would expose the other axis on the next frame. if self.x.eligible { self.x.consumed = true; self.x.eligible = false; } if self.y.eligible { self.y.consumed = true; self.y.eligible = false; } Some(direction) } fn resolve(&self, value: FGVec2, use_x: bool, use_y: bool) -> Option { if use_x { Some(if value.x.is_positive() { FlickDirection::Right } else { FlickDirection::Left }) } else if use_y { Some(if value.y.is_positive() { FlickDirection::Up } else { FlickDirection::Down }) } else { None } } } /// Per-fighter input state. Every field is deterministic rollback state. #[derive(Component, Clone, Copy, Hash)] #[require(Rollback)] pub struct FighterInput { command_slots: [CommandSlot; FighterCommands::COUNT], hold_limits: [HoldOverride; FighterCommands::COUNT], hold_all: HoldLimit, button_history: [ButtonHistory; 3], prev_frame: FighterInputFrame, current_frame: FighterInputFrame, movement_flick: StickFlickState, directional_attack_flick: StickFlickState, attack_command: FighterAttackCommandType, } impl Default for FighterInput { fn default() -> Self { Self { command_slots: [CommandSlot::default(); FighterCommands::COUNT], hold_limits: [HoldOverride::default(); FighterCommands::COUNT], hold_all: HoldLimit::default(), button_history: [ButtonHistory::default(); 3], prev_frame: FighterInputFrame::default(), current_frame: FighterInputFrame::default(), movement_flick: StickFlickState::default(), directional_attack_flick: StickFlickState::default(), attack_command: FighterAttackCommandType::Neutral, } } } impl FighterInput { pub fn get_last_frame(&self) -> FighterInputFrame { self.current_frame } pub fn push_input_frame(&mut self, frame: FighterInputFrame) { self.prev_frame = self.current_frame; self.current_frame = frame; } pub fn clear_buffer(&mut self) { self.command_slots = [CommandSlot::default(); FighterCommands::COUNT]; } pub fn clear_command(&mut self, command: FighterCommands) { let slot = &mut self.command_slots[command.index()]; slot.lifetime = 0; slot.hold_remaining = HoldLimit::Disabled; slot.consumed = true; } pub fn consume_command(&mut self, command: FighterCommands) -> bool { if self.has_command(command) { self.clear_command(command); true } else { false } } pub fn set_lifetime(&mut self, command: FighterCommands, lifetime: u8) { let slot = &mut self.command_slots[command.index()]; slot.lifetime = lifetime; slot.age = 0; slot.hold_remaining = HoldLimit::Disabled; slot.consumed = false; self.latch_if_hold_enabled(command); } pub fn has_command(&self, command: FighterCommands) -> bool { self.command_slots[command.index()].lifetime > 0 } pub fn reduce_command_lifetime(&mut self, command: FighterCommands) { self.command_slots[command.index()].lifetime = self.command_slots[command.index()] .lifetime .saturating_sub(1); } /// Holds all inputs for n frames pub fn enable_hold_all(&mut self, max_frames: u16) { self.hold_all = HoldLimit::from_max_frames(max_frames); self.latch_active_commands(); } pub fn disable_hold_all(&mut self) { self.hold_all = HoldLimit::Disabled; } /// Stops global enrollment and immediately cancels every latched command. pub fn cancel_hold_all(&mut self) { self.disable_hold_all(); for slot in &mut self.command_slots { slot.hold_remaining = HoldLimit::Disabled; } } pub fn enable_hold(&mut self, command: FighterCommands, max_frames: u16) { self.hold_limits[command.index()] = HoldOverride::from_max_frames(max_frames); if self.has_command(command) { self.latch_if_hold_enabled(command); } } /// Explicitly prevents this command from using either command-specific or /// global hold buffering, while preserving an existing latch. pub fn disable_hold(&mut self, command: FighterCommands) { self.hold_limits[command.index()] = HoldOverride::Disabled; } /// Removes the command-specific policy so this command inherits the /// global policy again. pub fn reset_hold_override(&mut self, command: FighterCommands) { self.hold_limits[command.index()] = HoldOverride::Inherit; } /// Stops enrollment and immediately cancels this command's hold lifetime. pub fn cancel_hold(&mut self, command: FighterCommands) { self.disable_hold(command); self.command_slots[command.index()].hold_remaining = HoldLimit::Disabled; } fn latch_if_hold_enabled(&mut self, command: FighterCommands) { let limit = self.hold_limit(command); let slot = &mut self.command_slots[command.index()]; slot.hold_remaining = match limit { HoldLimit::Disabled => return, HoldLimit::Infinite => HoldLimit::Infinite, // The limit includes tap-buffer frames already elapsed. HoldLimit::Frames(max_frames) => { let remaining = max_frames.saturating_sub(slot.age); if remaining == 0 { return; } HoldLimit::Frames(remaining) } }; } fn latch_active_commands(&mut self) { for command in FighterCommands::iter() { if self.has_command(command) { self.latch_if_hold_enabled(command); } } } pub fn is_pressed(&self, action: DigitalAction) -> bool { self.current_frame .bindings_for(action) .pressed_since(self.prev_frame.bindings_for(action)) .0 != 0 } pub fn is_released(&self, action: DigitalAction) -> bool { self.current_frame .bindings_for(action) .released_since(self.prev_frame.bindings_for(action)) .0 != 0 } pub fn pressed_bindings(&self, action: DigitalAction) -> u16 { self.current_frame .bindings_for(action) .pressed_since(self.prev_frame.bindings_for(action)) .0 } pub fn binding_count(&self, action: DigitalAction) -> u32 { self.current_frame.bindings_for(action).count() } pub fn peek_movement_flick(&self) -> Option { self.movement_flick.peek(self.current_frame.movement) } pub fn peek_movement_flick_dbg(&self) -> u8 { self.movement_flick.y.age } pub fn consume_movement_flick(&mut self) -> Option { self.movement_flick.consume(self.current_frame.movement) } pub fn peek_directional_attack_flick(&self) -> Option { self.directional_attack_flick .peek(self.current_frame.directional_attack) } pub fn consume_directional_attack_flick(&mut self) -> Option { self.directional_attack_flick .consume(self.current_frame.directional_attack) } pub fn set_attack_command_type(&mut self, command: FighterAttackCommandType) { self.attack_command = command; } pub fn get_attack_command_type(&self) -> FighterAttackCommandType { self.attack_command } fn hold_limit(&self, command: FighterCommands) -> HoldLimit { let specific = self.hold_limits[command.index()]; match specific { HoldOverride::Inherit => self.hold_all, HoldOverride::Disabled => HoldLimit::Disabled, HoldOverride::Frames(frames) => HoldLimit::Frames(frames), HoldOverride::Infinite => HoldLimit::Infinite, } } fn update_hold(&mut self, command: FighterCommands) { let index = command.index(); let slot = &mut self.command_slots[index]; slot.age = slot.age.saturating_add(1); match slot.hold_remaining { HoldLimit::Disabled => {} HoldLimit::Infinite => { if slot.lifetime == 0 { slot.lifetime = 1; } } HoldLimit::Frames(remaining) => { // This tick consumes one of the total persistent frames, but // the command remains active for the tick that consumes the // final frame. let remaining = remaining.saturating_sub(1); slot.hold_remaining = if remaining == 0 { HoldLimit::Disabled } else { HoldLimit::Frames(remaining) }; if slot.lifetime == 0 { slot.lifetime = 1; } } } } } pub fn postprocess_input( query: Query<(&mut FighterInput, &Player)>, inputs: Res>, game_settings: Res, ) { for (mut input, player) in query { for command in FighterCommands::iter() { input.reduce_command_lifetime(command); } input.push_input_frame(inputs[player.handle].0); let current_bindings = input.current_frame.bindings; let previous_bindings = input.prev_frame.bindings; for (history, action) in input.button_history.iter_mut().zip([ DigitalAction::Jump, DigitalAction::Shield, DigitalAction::Attack, ]) { history.update( current_bindings.get(action).0, previous_bindings.get(action).0, ); } let movement = input.current_frame.movement; let previous_movement = input.prev_frame.movement; let directional_attack = input.current_frame.directional_attack; let previous_directional_attack = input.prev_frame.directional_attack; let x_abs = movement.x.abs(); let y_abs = movement.y.abs(); let reset_deadzone = game_settings.input_common.smash_input_reset_axis_threshold; let axis_threshold = game_settings.input_common.smash_input_axis_threshold; let frame_threshold = game_settings.input_common.smash_input_frame_threshold; input.movement_flick.update( movement, previous_movement, reset_deadzone, axis_threshold, frame_threshold, ); input.directional_attack_flick.update( directional_attack, previous_directional_attack, reset_deadzone, axis_threshold, frame_threshold, ); let tap = game_settings.input_common.input_buffer_size; let jump_pressed = input.is_pressed(DigitalAction::Jump); let shield_pressed = input.is_pressed(DigitalAction::Shield); let attack_pressed = input.is_pressed(DigitalAction::Attack); if jump_pressed { input.set_lifetime(FighterCommands::Jump, tap); } if shield_pressed { input.set_lifetime(FighterCommands::Shield, tap); } let shield_now = input.current_frame.bindings_for(DigitalAction::Shield); let shield_previous = input.prev_frame.bindings_for(DigitalAction::Shield); let second_shield_pressed = second_binding_pressed(shield_now, shield_previous); if second_shield_pressed { input.set_lifetime(FighterCommands::GuardHold, tap); } for flick in input .movement_flick .next_commands(movement) .into_iter() .flatten() { let command = flick.smash_move_command(); match flick { FlickDirection::Up | FlickDirection::Down => { input.clear_command(FighterCommands::SmashMoveUp); input.clear_command(FighterCommands::SmashMoveDown); } FlickDirection::Left | FlickDirection::Right => { input.clear_command(FighterCommands::SmashMoveLeft); input.clear_command(FighterCommands::SmashMoveRight); } } input.set_lifetime(command, tap); if flick == FlickDirection::Up { input.set_lifetime(FighterCommands::TapJump, tap); } } if attack_pressed { let attack_type = if let Some(flick) = input.consume_movement_flick() { flick.smash_attack_command() } else if x_abs > y_abs { if movement.x.is_positive() { FighterAttackCommandType::TiltAttackRight } else { FighterAttackCommandType::TiltAttackLeft } } else if y_abs > x_abs { if movement.y.is_positive() { FighterAttackCommandType::TiltAttackUp } else { FighterAttackCommandType::TiltAttackDown } } else { FighterAttackCommandType::Neutral }; dbg!(input.movement_flick.y, attack_type); input.set_lifetime(FighterCommands::Attack, tap); input.set_attack_command_type(attack_type); } if let Some(flick) = input.consume_directional_attack_flick() { input.set_lifetime(FighterCommands::Attack, tap); input.set_attack_command_type(flick.smash_attack_command()); } for command in FighterCommands::iter() { input.update_hold(command); } } } #[cfg(test)] mod tests { use super::*; use crate::input::ActionBindingMask; const RESET_THRESHOLD: FGi32 = FGi32::lit("0.2"); const AXIS_THRESHOLD: FGi32 = FGi32::lit("0.8"); const FRAME_THRESHOLD: u8 = 3; fn update_flick(state: &mut StickFlickState, current: FGVec2, previous: FGVec2) { state.update( current, previous, RESET_THRESHOLD, AXIS_THRESHOLD, FRAME_THRESHOLD, ); } fn advance_command(input: &mut FighterInput, command: FighterCommands) { input.reduce_command_lifetime(command); input.update_hold(command); } #[test] fn second_binding_is_an_edge_not_a_level() { let first = ActionBindingMask(0b0001); let both = ActionBindingMask(0b0011); assert!(second_binding_pressed(both, first)); assert!(!second_binding_pressed(both, both)); } #[test] fn replacing_a_binding_still_triggers_the_combination() { let previous = ActionBindingMask(0b0011); let current = ActionBindingMask(0b0101); assert!(second_binding_pressed(current, previous)); } #[test] fn flick_remains_available_for_the_configured_window() { let mut flick = StickFlickState::default(); let right = FGVec2::lit("1", "0"); update_flick(&mut flick, right, FGVec2::ZERO); assert_eq!(flick.peek(right), Some(FlickDirection::Right)); for _ in 2..=FRAME_THRESHOLD { update_flick(&mut flick, right, right); assert_eq!(flick.peek(right), Some(FlickDirection::Right)); } update_flick(&mut flick, right, right); assert_eq!(flick.peek(right), None); } #[test] fn passive_command_does_not_consume_flick() { let mut flick = StickFlickState::default(); let right = FGVec2::lit("1", "0"); update_flick(&mut flick, right, FGVec2::ZERO); assert_eq!( flick.next_commands(right), [Some(FlickDirection::Right), None] ); assert_eq!(flick.next_commands(right), [None, None]); assert_eq!(flick.peek(right), Some(FlickDirection::Right)); update_flick(&mut flick, right, right); assert_eq!(flick.consume(right), Some(FlickDirection::Right)); assert_eq!(flick.peek(right), None); } #[test] fn consumed_flick_rearms_after_returning_below_reset_threshold() { let mut flick = StickFlickState::default(); let right = FGVec2::lit("1", "0"); update_flick(&mut flick, right, FGVec2::ZERO); assert_eq!(flick.consume(right), Some(FlickDirection::Right)); update_flick(&mut flick, right, right); assert_eq!(flick.peek(right), None); update_flick(&mut flick, FGVec2::ZERO, right); update_flick(&mut flick, right, FGVec2::ZERO); assert_eq!(flick.peek(right), Some(FlickDirection::Right)); } #[test] fn flick_can_reach_activation_threshold_after_entering_reset_deadzone() { let mut flick = StickFlickState::default(); let partial = FGVec2::lit("0.4", "0"); let right = FGVec2::lit("1", "0"); update_flick(&mut flick, partial, FGVec2::ZERO); assert_eq!(flick.peek(partial), None); update_flick(&mut flick, right, partial); assert_eq!(flick.peek(right), Some(FlickDirection::Right)); } #[test] fn diagonal_flick_prioritizes_horizontal_and_consumes_the_gesture() { let mut flick = StickFlickState::default(); let diagonal = FGVec2::lit("0.9", "1"); update_flick(&mut flick, diagonal, FGVec2::ZERO); assert_eq!( flick.next_commands(diagonal), [Some(FlickDirection::Right), Some(FlickDirection::Up)] ); assert_eq!(flick.consume(diagonal), Some(FlickDirection::Right)); assert_eq!(flick.peek(diagonal), None); } #[test] fn direction_reversal_starts_a_new_flick() { let mut flick = StickFlickState::default(); let right = FGVec2::lit("1", "0"); let left = FGVec2::lit("-1", "0"); update_flick(&mut flick, right, FGVec2::ZERO); assert_eq!(flick.consume(right), Some(FlickDirection::Right)); update_flick(&mut flick, left, right); assert_eq!(flick.peek(left), Some(FlickDirection::Left)); } #[test] fn command_can_be_consumed_without_erasing_raw_input() { let mut input = FighterInput::default(); input.set_lifetime(FighterCommands::GuardHold, 3); assert!(input.consume_command(FighterCommands::GuardHold)); assert!(!input.has_command(FighterCommands::GuardHold)); assert_eq!(input.get_last_frame(), FighterInputFrame::default()); } #[test] fn hold_extension_does_not_resurrect_consumed_commands() { let mut input = FighterInput::default(); input.enable_hold(FighterCommands::Shield, 10); input.set_lifetime(FighterCommands::Shield, 1); input.update_hold(FighterCommands::Shield); input.reduce_command_lifetime(FighterCommands::Shield); input.update_hold(FighterCommands::Shield); assert!(input.has_command(FighterCommands::Shield)); input.consume_command(FighterCommands::Shield); input.update_hold(FighterCommands::Shield); assert!(!input.has_command(FighterCommands::Shield)); } #[test] fn hold_buffer_keeps_released_attack_through_jump_squat() { let mut input = FighterInput::default(); input.set_lifetime(FighterCommands::Attack, 3); // Recognition happened before jump squat enabled persistence. input.update_hold(FighterCommands::Attack); input.enable_hold_all(10); // Five simulation ticks cover the test fighter's jump squat. The // physical attack binding is released, but persistence belongs to the // recognized command rather than the binding's held state. for _ in 0..5 { advance_command(&mut input, FighterCommands::Attack); } assert!(input.has_command(FighterCommands::Attack)); // Jump-squat exit stops enrollment. The latch crosses into Jump and // remains visible on its first airborne interrupt frame. input.disable_hold_all(); advance_command(&mut input, FighterCommands::Attack); assert!(input.has_command(FighterCommands::Attack)); assert!(input.consume_command(FighterCommands::Attack)); assert!(!input.has_command(FighterCommands::Attack)); } #[test] fn finite_hold_limit_includes_elapsed_tap_frames() { let mut input = FighterInput::default(); input.set_lifetime(FighterCommands::Attack, 3); // The recognition frame has already elapsed when jump squat starts. input.update_hold(FighterCommands::Attack); input.enable_hold_all(10); for _ in 0..9 { advance_command(&mut input, FighterCommands::Attack); assert!(input.has_command(FighterCommands::Attack)); } advance_command(&mut input, FighterCommands::Attack); assert!(!input.has_command(FighterCommands::Attack)); } #[test] fn stopping_enrollment_and_cancelling_latches_are_distinct() { let mut input = FighterInput::default(); input.enable_hold_all(10); input.set_lifetime(FighterCommands::Shield, 1); input.disable_hold_all(); advance_command(&mut input, FighterCommands::Shield); assert!(input.has_command(FighterCommands::Shield)); input.cancel_hold_all(); advance_command(&mut input, FighterCommands::Shield); assert!(!input.has_command(FighterCommands::Shield)); } #[test] fn command_override_can_disable_global_hold() { let mut input = FighterInput::default(); input.enable_hold_all(10); input.disable_hold(FighterCommands::Attack); input.set_lifetime(FighterCommands::Attack, 1); advance_command(&mut input, FighterCommands::Attack); assert!(!input.has_command(FighterCommands::Attack)); input.reset_hold_override(FighterCommands::Attack); input.set_lifetime(FighterCommands::Attack, 1); advance_command(&mut input, FighterCommands::Attack); assert!(input.has_command(FighterCommands::Attack)); } #[test] fn infinite_hold_uses_an_explicit_state() { let mut input = FighterInput::default(); input.enable_hold(FighterCommands::Shield, 0); input.set_lifetime(FighterCommands::Shield, 1); for _ in 0..u16::MAX as usize + 1 { advance_command(&mut input, FighterCommands::Shield); } assert!(input.has_command(FighterCommands::Shield)); } }