Example #1
0
def test_fastround():
    assert fastround(1.1234543) == 1.0
    assert fastround(1.1234543, 0) == 1.0
    assert fastround(1.5, 0) == 2.0

    assert fastround(1.1234543, 2) == 1.12
    assert fastround(1.1234543, 3) == 1.123
    assert fastround(1.1234543, 4) == 1.1235
    assert fastround(1.1234543, 5) == 1.12345
Example #2
0
    def shots_to_kill_ranges(
        self,
        damage_target_type: DamageTargetType = DamageTargetType.
        INFANTRY_BASELINE,
        damage_location: DamageLocation = DamageLocation.TORSO,
        step: float = 0.1,
        precision_decimals: int = 2,
    ) -> Iterator[Tuple[float, int]]:

        if self.damage_range_delta > 0:

            previous_stk: Optional[int] = None

            for r in float_range(0.0, self.min_damage_range + step, step,
                                 precision_decimals):

                stk: int = self.shots_to_kill(
                    distance=r,
                    damage_target_type=damage_target_type,
                    damage_location=damage_location,
                )

                if previous_stk is None or stk != previous_stk:

                    if r >= step:

                        yield (fastround(r - step, precision_decimals), stk)

                    else:

                        yield (r, stk)

                previous_stk = stk

        else:

            yield (
                0.0,
                self.shots_to_kill(
                    distance=self.max_damage_range,
                    damage_target_type=damage_target_type,
                    damage_location=damage_location,
                ),
            )
Example #3
0
def generate_magdump_simulation(
    fire_group: FireGroup,
    runs: int = 1,
    control_time: int = 0,
    auto_burst_length: Optional[int] = None,
    recoil_compensation: bool = False,
    player_state: PlayerState = PlayerState.STANDING,
    width: Optional[int] = None,
    height: Optional[int] = None,
) -> Tuple[Optional[altair.HConcatChart], Dict[int, altair.HConcatChart]]:

    assert (width or height) and not (width and height)

    datapoints: List[dict]
    fire_modes_datapoints: Dict[int, List[dict]] = {}

    for fire_mode in fire_group.fire_modes:

        if fire_mode.max_consecutive_shots > 0:

            fire_mode_id: int = fire_mode.fire_mode_id

            datapoints = []

            simulation: Iterator[Tuple[int, Tuple[float, float],
                                       List[Tuple[float, float]], float,
                                       Tuple[float, float], Tuple[float,
                                                                  float], ]]
            for simulation in (fire_mode.simulate_shots(
                    shots=fire_mode.max_consecutive_shots,
                    control_time=control_time,
                    auto_burst_length=auto_burst_length,
                    recoil_compensation=recoil_compensation,
                    player_state=player_state,
            ) for _ in range(runs)):

                t: int
                cursor_coor: Tuple[float, float]
                pellets_coors: List[Tuple[float, float]]
                _cof: float
                _vertical_recoil: Tuple[float, float]
                _horizontal_recoil: Tuple[float, float]
                for (
                        t,
                        cursor_coor,
                        pellets_coors,
                        _cof,
                        _vertical_recoil,
                        _horizontal_recoil,
                ) in simulation:

                    cursor_x, cursor_y = cursor_coor

                    cursor_x = fastround(cursor_x, PRECISION_DECIMALS)
                    cursor_y = fastround(cursor_y, PRECISION_DECIMALS)

                    datapoints.append({
                        "firemode":
                        f"{fire_mode_type_resolver[fire_mode.fire_mode_type]} {'ADS' if fire_mode.is_ads else 'Hipfire'} ({fire_mode.fire_mode_id})",
                        "time": t,
                        "type": CURSOR,
                        X: cursor_x,
                        Y: cursor_y,
                    })

                    for pellet_x, pellet_y in pellets_coors:

                        pellet_x = fastround(pellet_x, PRECISION_DECIMALS)
                        pellet_y = fastround(pellet_y, PRECISION_DECIMALS)

                        datapoints.append({
                            "firemode":
                            f"{fire_mode_type_resolver[fire_mode.fire_mode_type]} {'ADS' if fire_mode.is_ads else 'Hipfire'} ({fire_mode.fire_mode_id})",
                            "time": t,
                            "type": PELLET,
                            X: pellet_x,
                            Y: pellet_y,
                        })

            fire_modes_datapoints[fire_mode_id] = datapoints

    if not fire_modes_datapoints:

        return (None, {})

    # Generate charts for fire group and individual fire groups
    fire_modes_charts: Dict[int, altair.HConcatChart] = {}

    # Fire modes
    for fire_mode_id, datapoints in fire_modes_datapoints.items():
        chart_height: int
        chart_width: int

        min_x: float = min((d[X] for d in datapoints))
        max_x: float = max((d[X] for d in datapoints))
        min_y: float = min((d[Y] for d in datapoints))
        max_y: float = max((d[Y] for d in datapoints))

        if height:
            chart_height = height

            if max_y != min_y:
                chart_width = int(
                    math.ceil((max_x - min_x) * height / (max_y - min_y)))
            else:
                chart_width = height

        elif width:
            chart_width = width

            if max_x != min_x:
                chart_height = int(
                    math.ceil((max_y - min_y) * width / (max_x - min_x)))
            else:
                chart_height = width

        total_shots: int = len(
            list(filter(lambda x: x["type"] == CURSOR, datapoints)))
        total_pellets: int = len(
            list(filter(lambda x: x["type"] == PELLET, datapoints)))

        dataset: altair.Data = altair.Data(values=datapoints)

        chart: altair.Chart = (altair.Chart(dataset).mark_point().encode(
            x=altair.X(
                f"{X}:Q",
                axis=altair.Axis(title="horizontal angle (degrees)"),
                scale=altair.Scale(domain=(min_x, max_x)),
            ),
            y=altair.Y(
                f"{Y}:Q",
                axis=altair.Axis(title="vertical angle (degrees)"),
                scale=altair.Scale(domain=(min_y, max_y)),
            ),
            color=SIMULATION_POINT_TYPE_COLOR,
            opacity=SIMULATION_POINT_TYPE_OPACITY,
            tooltip=["time:Q", f"{X}:Q", f"{Y}:Q"],
        ).properties(
            width=chart_width,
            height=chart_height,
            title=
            f"{runs} magdumps, {total_shots} shots, {total_pellets} pellets",
        ).interactive())

        legend: altair.Chart = (altair.Chart(dataset).mark_point().encode(
            y=altair.Y("type:N", axis=altair.Axis(orient="right")),
            color=SIMULATION_POINT_TYPE_COLOR,
        ).add_selection(SIMULATION_POINT_TYPE_SELECTION))

        fire_modes_charts[fire_mode_id] = altair.hconcat(chart, legend)

    # Fire group
    all_datapoints: List[dict] = list(
        itertools.chain.from_iterable(fire_modes_datapoints.values()))

    fg_chart_height: int
    fg_chart_width: int

    fg_min_x: float = min((d[X] for d in all_datapoints))
    fg_max_x: float = max((d[X] for d in all_datapoints))
    fg_min_y: float = min((d[Y] for d in all_datapoints))
    fg_max_y: float = max((d[Y] for d in all_datapoints))

    if height:
        fg_chart_height = height

        if fg_max_y != fg_min_y:
            fg_chart_width = int(
                math.ceil(
                    (fg_max_x - fg_min_x) * height / (fg_max_y - fg_min_y)))
        else:
            fg_chart_width = 0

    elif width:
        fg_chart_width = width

        if fg_max_x != fg_min_x:
            fg_chart_height = int(
                math.ceil(
                    (fg_max_y - fg_min_y) * width / (fg_max_x - fg_min_x)))
        else:
            fg_chart_height = 0

    all_total_shots: int = len(
        list(filter(lambda x: x["type"] == CURSOR,
                    all_datapoints))) // len(fire_modes_datapoints)
    all_total_pellets: int = len(
        list(filter(lambda x: x["type"] == PELLET,
                    all_datapoints))) // len(fire_modes_datapoints)

    all_datapoints_pellets_only: List[dict] = list(
        filter(lambda x: x["type"] == PELLET, all_datapoints))

    fg_dataset: altair.Data = altair.Data(values=all_datapoints_pellets_only)

    fg_chart: altair.Chart = (altair.Chart(fg_dataset).mark_point().encode(
        x=altair.X(
            f"{X}:Q",
            axis=altair.Axis(title="horizontal angle (degrees)"),
            scale=altair.Scale(domain=(fg_min_x, fg_max_x)),
        ),
        y=altair.Y(
            f"{Y}:Q",
            axis=altair.Axis(title="vertical angle (degrees)"),
            scale=altair.Scale(domain=(fg_min_y, fg_max_y)),
        ),
        color=SIMULATION_FIRE_MODE_COLOR,
        opacity=SIMULATION_FIRE_MODE_OPACITY,
        tooltip=["time:Q", f"{X}:Q", f"{Y}:Q"],
    ).properties(
        width=fg_chart_width,
        height=fg_chart_height,
        title=
        f"{runs} magdumps, {all_total_shots} shots, {all_total_pellets} pellets",
    ).interactive())

    fg_legend: altair.Chart = (altair.Chart(fg_dataset).mark_point().encode(
        y=altair.Y("firemode:N", axis=altair.Axis(orient="right")),
        color=SIMULATION_FIRE_MODE_COLOR,
    ).add_selection(SIMULATION_FIRE_MODE_SELECTION))

    return (altair.hconcat(fg_chart, fg_legend), fire_modes_charts)
Example #4
0
def parse_fire_group_data(
    fg: dict,
    ammo_clip_size: int,
    ammo_total_capacity: int,
    heat_overheat_penalty_time: int,
    heat_bleed_off_rate: int,
) -> FireGroup:

    fg_id: int = int(fg["fire_group_id"])

    if fg_id in FIRE_GROUP_DATA_FIXERS:
        FIRE_GROUP_DATA_FIXERS[fg_id](fg)

    try:
        # Fire modes
        fire_modes: List[FireMode] = []

        _fm: dict
        for _fm in sorted(
            fg["fire_group_to_fire_modes"], key=lambda x: get(x, "fire_mode_id", int),
        ):
            fm: dict = _fm["fire_mode"]

            fm_to_pr: Optional[dict] = fm.get("fire_mode_to_projectile")
            pr: Optional[dict] = None

            if fm_to_pr is not None:
                pr = fm["fire_mode_to_projectile"]["projectile"]

            # Player states
            player_state_cone_of_fire: Dict[PlayerState, ConeOfFire] = {}
            player_state_can_ads: Dict[PlayerState, bool] = {}

            ps: dict
            for ps in sorted(
                fm["player_state_groups"], key=lambda x: get(x, "player_state_id", int),
            ):
                player_state_cone_of_fire[
                    PlayerState(get(ps, "player_state_id", int))
                ] = ConeOfFire(
                    max_angle=optget(ps, "cof_max", float, 0.0),
                    min_angle=optget(ps, "cof_min", float, 0.0),
                    bloom=optget(fm, "cof_recoil", float, 0.0),
                    recovery_rate=optget(ps, "cof_recovery_rate", float, 0.0),
                    recovery_delay=optget(ps, "cof_recovery_delay_ms", int, 0),
                    multiplier=get(fm, "cof_scalar", float),
                    moving_multiplier=get(fm, "cof_scalar_moving", float),
                    pellet_spread=optget(fm, "cof_pellet_spread", float, 0.0),
                    grow_rate=optget(ps, "cof_grow_rate", float),
                )

                player_state_can_ads[PlayerState(get(ps, "player_state_id", int))] = (
                    get(ps, "can_iron_sight", int) == 1
                )

            # Direct damage
            direct_damage_profile: Optional[DamageProfile] = None

            if "max_damage" in fm:
                # Effect
                damage_direct_effect: Dict[str, str] = {}

                effect = fm["damage_direct_effect"]

                damage_direct_effect = {}

                damage_direct_effect["resist_type"] = ResistType(
                    get(effect, "resist_type_id", int)
                )
                damage_direct_effect["target_type"] = (
                    TargetType(get(effect, "target_type_id", int))
                    if "target_type_id" in effect
                    else None
                )

                direct_effect_type: dict = effect["effect_type"]

                damage_direct_effect["action"] = direct_effect_type["description"]

                for k, v in direct_effect_type.items():
                    if k.startswith("string") or k.startswith("param"):
                        if k in effect:
                            damage_direct_effect[v] = effect[k]

                # Profile
                direct_damage_profile = DamageProfile(
                    max_damage=get(fm, "max_damage", int),
                    max_damage_range=optget(fm, "max_damage_range", float, 0.0),
                    min_damage=optget(
                        fm, "min_damage", int, get(fm, "max_damage", int)
                    ),
                    min_damage_range=optget(fm, "min_damage_range", float, 0.0),
                    pellets_count=optget(fm, "fire_pellets_per_shot", int, 1),
                    resist_type=damage_direct_effect["resist_type"],
                    location_multiplier={
                        DamageLocation.HEAD: fastround(
                            1.0 + optget(fm, "damage_head_multiplier", float, 0.0), 4
                        ),
                        DamageLocation.LEGS: fastround(
                            1.0 + optget(fm, "damage_legs_multiplier", float, 0.0), 4
                        ),
                    },
                    effect=damage_direct_effect or {},
                )

                if (
                    direct_damage_profile.min_damage == 0
                    and direct_damage_profile.min_damage_range == 0.0
                ):
                    direct_damage_profile.min_damage = direct_damage_profile.max_damage
                    direct_damage_profile.min_damage_range = (
                        direct_damage_profile.max_damage_range
                    )

            # Indirect damage
            indirect_damage_profile: Optional[DamageProfile] = None

            if "max_damage_ind" in fm:

                # Effect
                damage_indirect_effect: Dict[str, str] = {}

                effect = fm["damage_indirect_effect"]

                damage_indirect_effect = {}

                damage_indirect_effect["resist_type"] = ResistType(
                    get(effect, "resist_type_id", int)
                )
                damage_indirect_effect["target_type"] = (
                    TargetType(get(effect, "target_type_id", int))
                    if "target_type_id" in effect
                    else None
                )

                indirect_effect_type: dict = effect["effect_type"]

                damage_indirect_effect["action"] = indirect_effect_type["description"]

                for k, v in indirect_effect_type.items():
                    if k.startswith("string") or k.startswith("param"):
                        if k in effect:
                            damage_indirect_effect[v] = effect[k]

                # Profile
                indirect_damage_profile = DamageProfile(
                    max_damage=get(fm, "max_damage_ind", int),
                    max_damage_range=get(fm, "max_damage_ind_radius", float),
                    min_damage=get(fm, "min_damage_ind", int),
                    min_damage_range=get(fm, "min_damage_ind_radius", float),
                    resist_type=damage_indirect_effect["resist_type"],
                    pellets_count=optget(fm, "fire_pellets_per_shot", int, 1),
                    effect=damage_indirect_effect or {},
                )

            ammo: Optional[Ammo] = (
                Ammo(
                    ammo_per_shot=get(fm, "fire_ammo_per_shot", int),
                    block_auto=optget(fm, "reload_block_auto", lambda x: int(x) == 1),
                    continuous=optget(fm, "reload_continuous", lambda x: int(x) == 1),
                    clip_size=ammo_clip_size,
                    total_capacity=ammo_total_capacity,
                    short_reload_time=get(fm, "reload_time_ms", int),
                    reload_chamber_time=optget(fm, "reload_chamber_ms", int, 0),
                    loop_start_time=optget(fm, "reload_loop_start_ms", int),
                    loop_end_time=optget(fm, "reload_loop_end_ms", int),
                )
                if optget(fm, "fire_ammo_per_shot", int, 0) > 0
                else None
            )

            heat: Optional[Heat] = (
                Heat(
                    total_capacity=get(fm, "heat_threshold", int),
                    heat_per_shot=get(fm, "heat_per_shot", int),
                    overheat_penalty_time=heat_overheat_penalty_time,
                    recovery_delay=optget(fm, "heat_recovery_delay_ms", int, 0),
                    recovery_rate=heat_bleed_off_rate,
                )
                if optget(fm, "heat_per_shot", int, 0) > 0
                else None
            )

            fire_timing: FireTiming = FireTiming(
                is_automatic=optget(fm, "automatic", lambda x: int(x) == 1, False),
                refire_time=optget(fm, "fire_refire_ms", int, 0),
                fire_duration=optget(fm, "fire_duration_ms", int, 0),
                burst_length=optget(fm, "fire_burst_count", int),
                burst_refire_time=optget(fm, "fire_auto_fire_ms", int),
                delay=optget(fm, "fire_delay_ms", int, 0),
                charge_up_time=optget(fm, "fire_charge_up_ms", int, 0),
                spool_up_time=optget(fg, "spool_up_ms", int),
                spool_up_initial_refire_time=optget(
                    fg, "spool_up_initial_refire_ms", int
                ),
                chamber_time=optget(fg, "chamber_duration_ms", int),
            )

            recoil: Recoil = Recoil(
                max_angle=optget(fm, "recoil_angle_max", float, 0.0),
                min_angle=optget(fm, "recoil_angle_min", float, 0.0),
                max_vertical=optget(fm, "recoil_magnitude_max", float, 0.0),
                min_vertical=optget(fm, "recoil_magnitude_min", float, 0.0),
                vertical_increase=optget(fm, "recoil_increase", float, 0.0),
                vertical_crouched_increase=optget(
                    fm, "recoil_increase_crouched", float, 0.0
                ),
                max_horizontal=optget(fm, "recoil_horizontal_max", float, 0.0),
                min_horizontal=optget(fm, "recoil_horizontal_min", float, 0.0),
                horizontal_tolerance=optget(fm, "recoil_horizontal_tolerance", float),
                max_horizontal_increase=optget(
                    fm, "recoil_horizontal_max_increase", float, 0.0
                ),
                min_horizontal_increase=optget(
                    fm, "recoil_horizontal_min_increase", float, 0.0
                ),
                recovery_acceleration=optget(
                    fm, "recoil_recovery_acceleration", float, 0.0
                ),
                recovery_delay=optget(fm, "recoil_recovery_delay_ms", int, 0),
                recovery_rate=optget(fm, "recoil_recovery_rate", float, 0.0),
                first_shot_multiplier=optget(
                    fm, "recoil_first_shot_modifier", float, 1.0
                ),
                shots_at_min_magnitude=optget(fm, "recoil_shots_at_min_magnitude", int),
                max_total_magnitude=optget(fm, "recoil_max_total_magnitude", float),
            )

            projectile: Optional[Projectile] = (
                Projectile(
                    turn_rate=optget(pr, "turn_rate", float),
                    speed=optget(fm, "projectile_speed_override", float)
                    or get(pr, "speed", float),
                    max_speed=optget(pr, "speed_max", float),
                    acceleration=optget(pr, "acceleration", float, 0.0),
                    flight_type=ProjectileFlightType(
                        get(pr, "projectile_flight_type_id", int)
                    ),
                    gravity=optget(pr, "gravity", float, 0.0),
                    life_time=get(pr, "lifespan", lambda x: int(1_000 * float(x))),
                    drag=optget(pr, "drag", float, 0.0),
                )
                if pr is not None
                else None
            )

            lock_on: Optional[LockOn] = (
                LockOn(
                    turn_rate=optget(pr, "turn_rate", float),
                    life_time=get(
                        pr, "lockon_lifespan", lambda x: int(1_000 * float(x))
                    ),
                    seek_in_flight=optget(
                        pr, "lockon_seek_in_flight", lambda x: int(x) == 1, False
                    ),
                    maintain=optget(
                        fm, "lockon_maintain", lambda x: int(x) == 1, False
                    ),
                    required=optget(
                        fm, "lockon_required", lambda x: int(x) == 1, False
                    ),
                )
                if pr is not None
                and (
                    optget(fm, "lockon_angle", float, None)
                    or optget(pr, "lockon_lose_angle", float, None)
                    or optget(fm, "lockon_acquire_ms", int, None)
                    or optget(fm, "lockon_acquire_close_ms", int, None)
                    or optget(fm, "lockon_acquire_far_ms", int, None)
                    or optget(fm, "lockon_range", float, None)
                    or optget(fm, "lockon_range_close", float, None)
                    or optget(fm, "lockon_range_far", float, None)
                    or optget(fm, "lockon_maintain", lambda x: int(x) == 1, None)
                    or optget(fm, "lockon_required", lambda x: int(x) == 1, None)
                    or optget(pr, "lockon_seek_in_flight", lambda x: int(x) == 1, None)
                )
                else None
            )

            # Fire Mode
            fire_mode: FireMode = FireMode(
                fire_mode_id=get(fm, "fire_mode_id", int),
                fire_mode_type=FireModeType(get(fm, "fire_mode_type_id", int)),
                description=fm["description"]["en"] if "description" in fm else "",
                is_ads=optget(fm, "iron_sights", lambda x: int(x) == 1, False),
                detect_range=optget(fm, "fire_detect_range", float, 0.0),
                turn_multiplier=optget(fm, "turn_modifier", float, 1.0),
                move_multiplier=optget(fm, "move_modifier", float, 1.0),
                direct_damage_profile=direct_damage_profile,
                indirect_damage_profile=indirect_damage_profile,
                zoom=optget(fm, "zoom_default", float, 1.0),
                sway_can_steady=optget(fm, "sway_can_steady", lambda x: int(x) == 1),
                sway_amplitude_x=optget(fm, "sway_amplitude_x", float),
                sway_amplitude_y=optget(fm, "sway_amplitude_y", float),
                sway_period_x=optget(fm, "sway_period_x", float),
                sway_period_y=optget(fm, "sway_period_y", float),
                ammo=ammo,
                heat=heat,
                fire_timing=fire_timing,
                recoil=recoil,
                projectile=projectile,
                lock_on=lock_on,
                player_state_cone_of_fire=player_state_cone_of_fire,
                player_state_can_ads=player_state_can_ads,
            )

            fire_modes.append(fire_mode)

        fire_modes_descriptions: Set[str] = set(  # type: ignore
            [
                f.description if f.description not in {"Placeholder", ""} else None
                for f in fire_modes
            ]
        ) - {None}

        fg_description: str = ""
        if fire_modes_descriptions:
            fg_description = " / ".join(list(fire_modes_descriptions))

        fire_group: FireGroup = FireGroup(
            fire_group_id=fg_id,
            description=fg_description,
            transition_time=optget(fg, "transition_duration_ms", int, 0),
            fire_modes=fire_modes,
        )
Example #5
0
    def simulate_shots(
        self,
        shots: int = -1,
        control_time: int = 0,
        auto_burst_length: Optional[int] = None,
        player_state: PlayerState = PlayerState.STANDING,
        recoil_compensation: bool = False,
        recoil_compensation_accuracy: float = 0.0,
        precision_decimals: int = 6,
    ) -> Iterator[Tuple[
            int,  # time
            Tuple[float, float],  # cursor position
            List[Tuple[float, float]],  # pellets positions
            float,  # current CoF
            Tuple[float, float],  # current min and max vertical recoil
            Tuple[float, float],  # current min and max horizontal recoil
    ], ]:

        if shots == 0:

            yield (0, (0, 0), [], 0, (0, 0), (0, 0))

            return

        # Cone of fire at player state
        cof: ConeOfFire = self.player_state_cone_of_fire[player_state]

        # Current state
        # Position; start at origin
        curr_x = 0.0
        curr_y = 0.0

        # Recoil parameters
        curr_max_vertical_recoil: float = self.recoil.max_vertical
        curr_min_vertical_recoil: float = self.recoil.min_vertical
        curr_max_horizontal_recoil: float = self.recoil.max_horizontal
        curr_min_horizontal_recoil: float = self.recoil.min_horizontal

        # CoF parameters
        curr_cof_angle: float = cof.min_cof_angle()

        # Loop
        previous_t: int = 0

        t: int
        b: bool
        for t, b in self.generate_real_shot_timings(
                shots=shots,
                control_time=control_time,
                auto_burst_length=auto_burst_length):

            delta: int = t - previous_t

            # Scaling and recoveries
            ############################################################################

            # After first shot, apply scaling and recoveries
            if t > 0:

                # CoF
                ########################################################################

                cof_recovery_delay: int = self.fire_timing.refire_time + cof.recovery_delay

                # Under recovery delay -- bloom CoF
                if delta <= cof_recovery_delay:

                    curr_cof_angle = cof.apply_bloom(current=curr_cof_angle)

                # Above recovery delay -- recover CoF
                else:

                    curr_cof_angle = cof.recover(
                        current=curr_cof_angle,
                        time=delta - cof_recovery_delay,
                    )

                # Recoil
                ########################################################################

                recoil_recovery_delay: int = self.fire_timing.refire_time + self.recoil.recovery_delay

                # Under recovery delay -- scale recoil
                if delta <= recoil_recovery_delay:

                    # Vertical
                    (
                        curr_min_vertical_recoil,
                        curr_max_vertical_recoil,
                    ) = self.recoil.scale_vertical(
                        current_min=curr_min_vertical_recoil,
                        current_max=curr_max_vertical_recoil,
                    )

                    # Horizontal
                    (
                        curr_min_horizontal_recoil,
                        curr_max_horizontal_recoil,
                    ) = self.recoil.scale_horizontal(
                        current_min=curr_min_horizontal_recoil,
                        current_max=curr_max_horizontal_recoil,
                    )

                # Above recovery delay and have a recovery rate -- recover recoil
                else:

                    curr_x, curr_y = self.recoil.recover(
                        current_x=curr_x,
                        current_y=curr_y,
                        time=delta - recoil_recovery_delay,
                    )

            # Recoil compensation
            ############################################################################
            if recoil_compensation is True:

                if curr_y != 0.0:

                    recenter_a: float

                    if self.recoil.min_angle == self.recoil.max_angle:
                        recenter_a = self.recoil.min_angle
                    else:
                        recenter_a = (self.recoil.min_angle +
                                      self.recoil.max_angle) / 2

                    if recenter_a != 0.0:
                        curr_x -= curr_y / (cached_tan(
                            math.radians(90 - recenter_a)))

                        if recoil_compensation_accuracy > 0.0:
                            curr_x += random.uniform(
                                -recoil_compensation_accuracy,
                                recoil_compensation_accuracy,
                            )

                    curr_y = 0.0

                    if recoil_compensation_accuracy > 0.0:
                        curr_y += random.uniform(-recoil_compensation_accuracy,
                                                 recoil_compensation_accuracy)

            # Current result
            ############################################################################

            # Result as a tuple of time, cursor position tuple and pellets positions tuples
            curr_result: Tuple[int, Tuple[float, float],
                               List[Tuple[float, float]], float,
                               Tuple[float, float], Tuple[float, float], ] = (
                                   t,  # time
                                   (curr_x, curr_y),  # cursor
                                   [],  # pellets
                                   curr_cof_angle,  # current cof angle
                                   (
                                       curr_min_vertical_recoil,
                                       curr_max_vertical_recoil,
                                   ),  # current vertical recoil
                                   (
                                       curr_min_horizontal_recoil,
                                       curr_max_horizontal_recoil,
                                   ),  # current horizontal recoil
                               )

            # CoF simulation
            ############################################################################

            cof_h: float
            cof_v: float

            if curr_cof_angle == 0.0:

                cof_h = 0.0
                cof_v = 0.0

            else:

                cof_h, cof_v = random_point_in_disk(radius=curr_cof_angle)

            # Individual pellets position
            for _ in range(self.direct_damage_profile.pellets_count if self.
                           direct_damage_profile is not None else self.
                           indirect_damage_profile.pellets_count if self.
                           indirect_damage_profile is not None else 1):

                pellet_h: float
                pellet_v: float

                if cof.pellet_spread:

                    pellet_h, pellet_v = random_point_in_disk(
                        radius=cof.pellet_spread)

                    curr_result[2].append((
                        fastround(curr_x + cof_h + pellet_h,
                                  precision_decimals),
                        fastround(curr_y + cof_v + pellet_v,
                                  precision_decimals),
                    ))

                else:

                    curr_result[2].append((
                        fastround(curr_x + cof_h, precision_decimals),
                        fastround(curr_y + cof_v, precision_decimals),
                    ))

            # Recoil simulation
            ############################################################################

            # Un-angled vertical recoil amplitude
            recoil_v: float

            if curr_max_vertical_recoil == curr_min_vertical_recoil:

                recoil_v = curr_max_vertical_recoil

            else:

                recoil_v = random.uniform(curr_min_vertical_recoil,
                                          curr_max_vertical_recoil)

            # FSM scaling of un-angled vertical recoil
            if b is True:

                recoil_v = recoil_v * self.recoil.first_shot_multiplier

            # Un-angled horizontal recoil amplitude
            recoil_h: float

            if curr_max_horizontal_recoil == curr_min_horizontal_recoil:

                recoil_h = curr_max_horizontal_recoil

            else:

                recoil_h = random.uniform(curr_min_horizontal_recoil,
                                          curr_max_horizontal_recoil)

            # Recoil angle
            recoil_a: float

            if (self.recoil.max_angle, self.recoil.min_angle) == (0.0, 0.0):

                recoil_a = 0.0

            elif self.recoil.max_angle == self.recoil.min_angle:

                recoil_a = self.recoil.max_angle

            else:

                recoil_a = random.uniform(self.recoil.min_angle,
                                          self.recoil.max_angle)

            # Horizontal recoil direction
            recoil_h_direction: Literal[-1, 1]
            recoil_h_choices: Tuple[Literal[-1, 1], Literal[-1, 1]] = (-1, 1)

            if self.recoil.half_horizontal_tolerance:

                rta: float = cached_tan(math.radians(90 - recoil_a))

                left_bound: float = (
                    (curr_y - self.recoil.half_horizontal_tolerance) / rta
                ) if recoil_a != 0.0 else -self.recoil.half_horizontal_tolerance

                right_bound: float = (
                    (curr_y + self.recoil.half_horizontal_tolerance) / rta
                ) if recoil_a != 0.0 else self.recoil.half_horizontal_tolerance

                if left_bound <= curr_x <= right_bound:

                    recoil_h_direction = random.choice(recoil_h_choices)

                else:

                    if curr_x > right_bound:

                        recoil_h_direction = -1

                    else:

                        recoil_h_direction = 1

            else:

                recoil_h_direction = random.choice(recoil_h_choices)

            recoil_h *= recoil_h_direction

            # Angle horizontal and vertical recoil
            recoil_h_angled: float
            recoil_v_angled: float

            if recoil_a == 0.0:

                recoil_h_angled = recoil_h
                recoil_v_angled = recoil_v

            else:

                recoil_a_radians: float = math.radians(recoil_a)
                sin_recoil_a_radians: float = math.sin(recoil_a_radians)
                cos_recoil_a_radians: float = math.cos(recoil_a_radians)

                recoil_h_angled = (recoil_h * cos_recoil_a_radians +
                                   recoil_v * sin_recoil_a_radians)

                recoil_v_angled = (-recoil_h * sin_recoil_a_radians +
                                   recoil_v * cos_recoil_a_radians)

            # Yield
            yield curr_result

            # Update current position
            curr_x = fastround(curr_x + recoil_h_angled, precision_decimals)
            curr_y = fastround(curr_y + recoil_v_angled, precision_decimals)

            previous_t = t
print(f"Generated {len(infantry_weapons)} infantry weapons")

wp: InfantryWeapon = next(x for x in infantry_weapons if x.item_id == 43)

fm: FireMode = wp.fire_groups[0].fire_modes[1]

# Simulation without recoil compensation
sim = fm.simulate_shots(shots=fm.max_consecutive_shots)

datapoints: List[dict] = []

for t, cursor_coor, pellets_coors, _cof, _vertical_recoil, _horizontal_recoil in sim:
    cursor_x, cursor_y = cursor_coor

    cursor_x = fastround(cursor_x, PRECISION_DECIMALS)
    cursor_y = fastround(cursor_y, PRECISION_DECIMALS)

    datapoints.append({"time": t, "type": "cursor", "x": cursor_x, "y": cursor_y})

    for pellet_x, pellet_y in pellets_coors:

        pellet_x = fastround(pellet_x, PRECISION_DECIMALS)
        pellet_y = fastround(pellet_y, PRECISION_DECIMALS)

        datapoints.append({"time": t, "type": "pellet", "x": pellet_x, "y": pellet_y})

dataset = altair.Data(values=datapoints)

chart = (
    altair.Chart(dataset)