# Released under the MIT License. See LICENSE for details.
#
"""Provides GameActivity class."""

# pylint: disable=too-many-lines

import random
import logging
from typing import TYPE_CHECKING, overload, override

import babase

import _bascenev1
from bascenev1._activity import Activity
from bascenev1._player import PlayerInfo
from bascenev1._messages import PlayerDiedMessage, StandMessage
from bascenev1._score import ScoreConfig
from bascenev1 import _map
from bascenev1 import _music
from bascenev1 import _team

if TYPE_CHECKING:
    from typing import Any, Callable, Literal, Sequence

    from bascenev1lib.actor.playerspaz import PlayerSpaz
    from bascenev1lib.actor.bomb import TNTSpawner

    import bascenev1


def get_game_display_name(name: str) -> babase.LangStr:
    """Return a displayable name for a minigame.

    ``name`` is the game type's own
    :meth:`~bascenev1.GameActivity.getname()` value. First-party games
    resolve to their authored entry; a mod's game name is shown
    exactly as the mod provides it.

    :meta private:
    """
    # Safe up-call: bascenev1 is fully imported by the time this runs;
    # the cycle pylint sees is structural only.
    # pylint: disable-next=cyclic-import
    from bascenev1 import classicassets

    s = classicassets.strings.game_names
    entry = {
        'Assault': s.assault,
        'Capture the Flag': s.capture_the_flag,
        'Chosen One': s.chosen_one,
        'Conquest': s.conquest,
        'Death Match': s.death_match,
        'Easter Egg Hunt': s.easter_egg_hunt,
        'Elimination': s.elimination,
        'Football': s.football,
        'Hockey': s.hockey,
        'Keep Away': s.keep_away,
        'King of the Hill': s.king_of_the_hill,
        'Meteor Shower': s.meteor_shower,
        'Ninja Fight': s.ninja_fight,
        'Onslaught': s.onslaught,
        'Race': s.race,
        'Runaround': s.runaround,
        'Target Practice': s.target_practice,
        'The Last Stand': s.the_last_stand,
    }.get(name)
    return entry if entry is not None else babase.LangStr.from_text(name)


def get_game_description(
    description: str, *, arg: str | None = None
) -> babase.LangStr:
    """Return a displayable minigame objective description.

    ``description`` is a game's English description text (see
    :meth:`~bascenev1.GameActivity.get_description` and the instance
    variants); ``arg`` is the single ``${ARG1}`` value when the
    description is a parameterized template, else None. First-party
    descriptions resolve to their authored (translated) entry; a mod's
    description is shown as its own text (with ``${ARG1}`` filled in).

    :meta private:
    """
    # Safe up-call: bascenev1 is fully imported by the time this runs;
    # the cycle pylint sees is structural only.
    # pylint: disable-next=cyclic-import
    from bascenev1 import classicassets

    gd = classicassets.strings.game_descriptions
    if arg is None:
        plain: dict[str, babase.LangStr] = {
            (
                'Be the chosen one for a length of time to win.\n'
                'Kill the chosen one to become it.'
            ): (gd.be_the_chosen_one_for_a),
            'Bomb as many targets as you can.': (
                gd.bomb_as_many_targets_as_you
            ),
            'Carry the flag for a set length of time.': (
                gd.carry_the_flag_for_a_set
            ),
            'Defeat all enemies.': (gd.defeat_all_enemies),
            'Dodge the falling bombs.': (gd.dodge_the_falling_bombs),
            'Final glorious epic slow motion battle to the death.': (
                gd.final_glorious_epic_slow_motion_battle
            ),
            'Gather eggs!': (gd.gather_eggs),
            'Get the flag to the enemy end zone.': (
                gd.get_the_flag_to_the_enemy
            ),
            'How fast can you defeat the ninjas?': (
                gd.how_fast_can_you_defeat_the
            ),
            'Kill a set number of enemies to win.': (
                gd.kill_a_set_number_of_enemies
            ),
            'Last one standing wins.': (gd.last_one_standing_wins),
            'Last remaining alive wins.': (gd.last_remaining_alive_wins),
            'Last team standing wins.': (gd.last_team_standing_wins),
            'Prevent enemies from reaching the exit.': (
                gd.prevent_enemies_from_reaching_the_exit
            ),
            'Reach the enemy flag to score.': (
                gd.reach_the_enemy_flag_to_score
            ),
            'Return the enemy flag to score.': (
                gd.return_the_enemy_flag_to_score
            ),
            'Run 1 lap.': (gd.run_1_lap),
            'Run 1 lap. Your entire team has to finish.': (
                gd.run_1_lap_your_entire_team
            ),
            'Run real fast!': (gd.run_real_fast),
            'Score a goal.': (gd.score_a_goal),
            'Score a touchdown.': (gd.score_a_touchdown),
            'Score some goals.': (gd.score_some_goals),
            'Secure all flags on the map to win.': (
                gd.secure_all_flags_on_the_map
            ),
            'Secure the flag for a set length of time.': (
                gd.secure_the_flag_for_a_set
            ),
            'Steal the enemy flag.': (gd.steal_the_enemy_flag),
            'There can be only one.': (gd.there_can_be_only_one),
            'Touch the enemy flag.': (gd.touch_the_enemy_flag),
            'last one standing wins': (gd.last_one_standing_wins_2),
            'last team standing wins': (gd.last_team_standing_wins_2),
            'return 1 flag': (gd.return_1_flag),
            'run 1 lap': (gd.run_1_lap_2),
            'score a goal': (gd.score_a_goal_2),
            'score a touchdown': (gd.score_a_touchdown_2),
            'touch 1 flag': (gd.touch_1_flag),
        }
        entry = plain.get(description)
        return (
            entry
            if entry is not None
            else babase.LangStr.from_text(description)
        )
    argt = {
        'Carry the flag for ${ARG1} seconds.': (gd.carry_the_flag_for_seconds),
        'Crush ${ARG1} of your enemies.': (gd.crush_of_your_enemies),
        'Run ${ARG1} laps.': (gd.run_laps),
        'Run ${ARG1} laps. Your entire team has to finish.': (
            gd.run_laps_your_entire_team_has
        ),
        'Score ${ARG1} goals.': (gd.score_goals),
        'Score ${ARG1} touchdowns.': (gd.score_touchdowns),
        'Secure all ${ARG1} flags.': (gd.secure_all_flags),
        'Secure the flag for ${ARG1} seconds.': (
            gd.secure_the_flag_for_seconds
        ),
        'Steal the enemy flag ${ARG1} times.': (gd.steal_the_enemy_flag_times),
        'Touch the enemy flag ${ARG1} times.': (gd.touch_the_enemy_flag_times),
        'carry the flag for ${ARG1} seconds': (gd.carry_the_flag_for_seconds_2),
        'kill ${ARG1} enemies': (gd.kill_enemies),
        'return ${ARG1} flags': (gd.return_flags),
        'run ${ARG1} laps': (gd.run_laps_2),
        'score ${ARG1} goals': (gd.score_goals_2),
        'score ${ARG1} touchdowns': (gd.score_touchdowns_2),
        'secure all ${ARG1} flags': (gd.secure_all_flags_2),
        'secure the flag for ${ARG1} seconds': (
            gd.secure_the_flag_for_seconds_2
        ),
        'touch ${ARG1} flags': (gd.touch_flags),
    }
    acc = argt.get(description)
    if acc is not None:
        return acc(arg1=arg)
    return babase.LangStr.from_text(description.replace('${ARG1}', arg))


# Note: Need to suppress an undefined variable here because our pylint
# plugin clears type-arg declarations (which we don't require to be
# present at runtime) but keeps parent type-args (which we sometimes use
# at runtime).


class GameActivity[PlayerT: bascenev1.Player, TeamT: bascenev1.Team](
    Activity[PlayerT, TeamT]  # pylint: disable=undefined-variable
):
    """Common base class for all game activities."""

    # pylint: disable=too-many-public-methods

    # Tips to be presented to the user at the start of the game.
    #: Tips shown at the start of a game. Plain strings go
    #: through the legacy translate path; prefer authored
    #: :class:`~babase.LangStr` values, which re-render on a
    #: language change. Note this is a *class* attribute
    #: evaluated at import, so a LangStr belongs in __init__
    #: (``self.tips = [...]``) rather than here -- string
    #: accessors are gated until construct-mode hands off.
    tips: list[str | babase.LangStr | bascenev1.GameTip] = []

    # Default getname() will return this if not None.
    name: str | None = None

    # Default get_description() will return this if not None.
    description: str | None = None

    # Default get_available_settings() will return this if not None.
    available_settings: list[bascenev1.Setting] | None = None

    # Default getscoreconfig() will return this if not None.
    scoreconfig: bascenev1.ScoreConfig | None = None

    # Override some defaults.
    allow_pausing = True
    allow_kick_idle_players = True

    # Whether to show points for kills.
    show_kill_points = True

    # If not None, the music type that should play in on_transition_in()
    # (unless overridden by the map).
    default_music: bascenev1.MusicType | None = None

    @classmethod
    def getscoreconfig(cls) -> bascenev1.ScoreConfig:
        """Return info about game scoring setup; can be overridden by games."""
        return cls.scoreconfig if cls.scoreconfig is not None else ScoreConfig()

    @classmethod
    def getname(cls) -> str:
        """Return a str name for this game type.

        This default implementation simply returns the 'name' class attr.
        """
        return cls.name if cls.name is not None else 'Untitled Game'

    @overload
    @classmethod
    def get_display_string(
        cls,
        settings: dict | None = None,
        *,
        langstr: Literal[False] = False,
    ) -> babase.Lstr: ...

    @overload
    @classmethod
    def get_display_string(
        cls, settings: dict | None = None, *, langstr: Literal[True]
    ) -> babase.LangStr: ...

    @classmethod
    def get_display_string(
        cls, settings: dict | None = None, *, langstr: bool = False
    ) -> babase.Lstr | babase.LangStr:
        """Return a descriptive name for this game/settings combo.

        Subclasses should override getname(); not this.

        Pass ``langstr=True`` to receive a :class:`~babase.LangStr` (or
        a plain str for a game we have no entry for, such as a mod's).
        The legacy :class:`~babase.Lstr` form goes away when api 9
        support ends.
        """
        if langstr:
            # pylint: disable-next=cyclic-import
            from bascenev1 import classicassets

            lname = get_game_display_name(cls.getname())
            if settings is not None:
                gstrs = classicassets.strings.game
                if settings.get('Solo Mode'):
                    lname = gstrs.solo_name_filter(name=lname)
                if settings.get('Epic Mode'):
                    lname = gstrs.epic_name_filter(name=lname)
            return lname

        name = babase.Lstr(translate=('gameNames', cls.getname()))

        # A few substitutions for 'Epic', 'Solo' etc. modes.
        # FIXME: Should provide a way for game types to define filters of
        #  their own and should not rely on hard-coded settings names.
        if settings is not None:
            if 'Solo Mode' in settings and settings['Solo Mode']:
                name = babase.Lstr(
                    resource='soloNameFilterText', subs=[('${NAME}', name)]
                )
            if 'Epic Mode' in settings and settings['Epic Mode']:
                name = babase.Lstr(
                    resource='epicNameFilterText', subs=[('${NAME}', name)]
                )

        return name

    @overload
    @classmethod
    def get_team_display_string(
        cls, name: str, *, langstr: Literal[False] = False
    ) -> babase.Lstr: ...

    @overload
    @classmethod
    def get_team_display_string(
        cls, name: str, *, langstr: Literal[True]
    ) -> str | babase.LangStr: ...

    @classmethod
    def get_team_display_string(
        cls, name: str, *, langstr: bool = False
    ) -> babase.Lstr | str | babase.LangStr:
        """Given a team name, returns a localized version of it.

        Pass ``langstr=True`` to receive a :class:`~babase.LangStr` (or
        a plain str for a custom team name the player typed). The
        legacy :class:`~babase.Lstr` form goes away when api 9 support
        ends.
        """
        if langstr:
            return _team.get_default_team_display_name(name)
        return babase.Lstr(translate=('teamNames', name))

    @classmethod
    def get_description(cls, sessiontype: type[bascenev1.Session]) -> str:
        """Get a str description of this game type.

        The default implementation simply returns the 'description' class var.
        Classes which want to change their description depending on the session
        can override this method.
        """
        del sessiontype  # Unused arg.
        return cls.description if cls.description is not None else ''

    @overload
    @classmethod
    def get_description_display_string(
        cls,
        sessiontype: type[bascenev1.Session],
        *,
        langstr: Literal[False] = False,
    ) -> babase.Lstr: ...

    @overload
    @classmethod
    def get_description_display_string(
        cls, sessiontype: type[bascenev1.Session], *, langstr: Literal[True]
    ) -> babase.LangStr: ...

    @classmethod
    def get_description_display_string(
        cls, sessiontype: type[bascenev1.Session], *, langstr: bool = False
    ) -> babase.Lstr | babase.LangStr:
        """Return a translated version of get_description().

        Sub-classes should override get_description(); not this.

        Pass ``langstr=True`` to receive a :class:`~babase.LangStr`. The
        legacy :class:`~babase.Lstr` form goes away when api 9 support
        ends.
        """
        description = cls.get_description(sessiontype)
        if langstr:
            return get_game_description(description)
        return babase.Lstr(translate=('gameDescriptions', description))

    @classmethod
    def get_available_settings(
        cls, sessiontype: type[bascenev1.Session]
    ) -> list[bascenev1.Setting]:
        """Return a list of settings relevant to this game type when
        running under the provided session type.
        """
        del sessiontype  # Unused arg.
        return [] if cls.available_settings is None else cls.available_settings

    @classmethod
    def get_supported_maps(
        cls, sessiontype: type[bascenev1.Session]
    ) -> list[str]:
        """
        Called by the default bascenev1.GameActivity.create_settings_ui()
        implementation; should return a list of map names valid
        for this game-type for the given bascenev1.Session type.
        """
        del sessiontype  # Unused arg.
        assert babase.app.classic is not None
        return babase.app.classic.getmaps('melee')

    @overload
    @classmethod
    def get_settings_display_string(
        cls, config: dict[str, Any], *, langstr: Literal[False] = False
    ) -> babase.Lstr: ...

    @overload
    @classmethod
    def get_settings_display_string(
        cls, config: dict[str, Any], *, langstr: Literal[True]
    ) -> babase.LangStr: ...

    @classmethod
    def get_settings_display_string(
        cls, config: dict[str, Any], *, langstr: bool = False
    ) -> babase.Lstr | babase.LangStr:
        """Given a game config dict, return a short description for it.

        This is used when viewing game-lists or showing what game
        is up next in a series.

        Pass ``langstr=True`` to receive a :class:`~babase.LangStr`. The
        legacy :class:`~babase.Lstr` form goes away when api 9 support
        ends.
        """
        if langstr:
            # pylint: disable-next=cyclic-import
            from bascenev1 import classicassets

            lname = cls.get_display_string(config['settings'], langstr=True)
            mapname = config['settings'].get('map', config.get('map'))
            if mapname is None:
                print('invalid game config - expected map entry under settings')
                return babase.LangStr.from_text('???')
            return classicassets.strings.game.game_on_map(
                name=lname,
                mapname=_map.get_map_display_string(
                    _map.get_filtered_map_name(mapname), langstr=True
                ),
            )

        name = cls.get_display_string(config['settings'])

        # In newer configs, map is in settings; it used to be in the
        # config root.
        if 'map' in config['settings']:
            sval = babase.Lstr(
                value='${NAME} @ ${MAP}',
                subs=[
                    ('${NAME}', name),
                    (
                        '${MAP}',
                        _map.get_map_display_string(
                            _map.get_filtered_map_name(
                                config['settings']['map']
                            )
                        ),
                    ),
                ],
            )
        elif 'map' in config:
            sval = babase.Lstr(
                value='${NAME} @ ${MAP}',
                subs=[
                    ('${NAME}', name),
                    (
                        '${MAP}',
                        _map.get_map_display_string(
                            _map.get_filtered_map_name(config['map'])
                        ),
                    ),
                ],
            )
        else:
            print('invalid game config - expected map entry under settings')
            sval = babase.Lstr(value='???')
        return sval

    @classmethod
    def supports_session_type(
        cls, sessiontype: type[bascenev1.Session]
    ) -> bool:
        """Return whether this game supports the provided session type."""
        from bascenev1._multiteamsession import MultiTeamSession

        # By default, games support any versus mode
        return issubclass(sessiontype, MultiTeamSession)

    def __init__(self, settings: dict):
        """Instantiate the Activity."""
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets

        super().__init__(settings)

        #: Holds some flattened info about the player set at the point
        #: when :meth:`on_begin()` is called.
        self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None

        # Go ahead and get our map loading.
        self._map_type = _map.get_map_class(self._calc_map_name(settings))

        self._spawn_sound = classicassets.audio.spawn.get()
        self._map_type.preload()
        self._map: bascenev1.Map | None = None
        self._powerup_drop_timer: bascenev1.Timer | None = None
        self._tnt_spawners: dict[int, TNTSpawner] | None = None
        self._tnt_drop_timer: bascenev1.Timer | None = None
        self._game_scoreboard_name_text: bascenev1.Actor | None = None
        self._game_scoreboard_description_text: bascenev1.Actor | None = None
        self._standard_time_limit_time: int | None = None
        self._standard_time_limit_timer: bascenev1.Timer | None = None
        self._standard_time_limit_text: bascenev1.NodeActor | None = None
        self._standard_time_limit_text_input: bascenev1.NodeActor | None = None
        self._tournament_time_limit: int | None = None
        self._tournament_time_limit_timer: bascenev1.BaseTimer | None = None
        self._tournament_time_limit_title_text: bascenev1.NodeActor | None = (
            None
        )
        self._tournament_time_limit_text: bascenev1.NodeActor | None = None
        self._tournament_time_limit_text_input: bascenev1.NodeActor | None = (
            None
        )
        self._zoom_message_times: dict[int, float] = {}

    @property
    def map(self) -> _map.Map:
        """The map being used for this game.

        Raises a bascenev1.MapNotFoundError if the map does not currently
        exist.
        """
        if self._map is None:
            raise babase.MapNotFoundError
        return self._map

    @overload
    def get_instance_display_string(
        self, *, langstr: Literal[False] = False
    ) -> babase.Lstr: ...

    @overload
    def get_instance_display_string(
        self, *, langstr: Literal[True]
    ) -> babase.LangStr: ...

    def get_instance_display_string(
        self, *, langstr: bool = False
    ) -> babase.Lstr | babase.LangStr:
        """Return a name for this particular game instance.

        Pass ``langstr=True`` to receive a :class:`~babase.LangStr`. The
        legacy :class:`~babase.Lstr` form goes away when api 9 support
        ends.
        """
        if langstr:
            return self.get_display_string(self.settings_raw, langstr=True)
        return self.get_display_string(self.settings_raw)

    @overload
    def get_instance_scoreboard_display_string(
        self, *, langstr: Literal[False] = False
    ) -> babase.Lstr: ...

    @overload
    def get_instance_scoreboard_display_string(
        self, *, langstr: Literal[True]
    ) -> babase.LangStr: ...

    def get_instance_scoreboard_display_string(
        self, *, langstr: bool = False
    ) -> babase.Lstr | babase.LangStr:
        """Return a name for this particular game instance.

        This name is used above the game scoreboard in the corner
        of the screen, so it should be as concise as possible.

        Pass ``langstr=True`` to receive a :class:`~babase.LangStr`. The
        legacy :class:`~babase.Lstr` form goes away when api 9 support
        ends.
        """
        # If we're in a co-op session, use the level name.
        # FIXME: Should clean this up.
        try:
            from bascenev1._coopsession import CoopSession

            if isinstance(self.session, CoopSession):
                campaign = self.session.campaign
                assert campaign is not None
                level = campaign.getlevel(self.session.campaign_level_name)
                if langstr:
                    return level.displayname_langstr
                return level.displayname
        except Exception:
            logging.exception('Error getting campaign level name.')
        if langstr:
            return self.get_instance_display_string(langstr=True)
        return self.get_instance_display_string()

    def get_instance_description(self) -> str | Sequence:
        """Return a description for this game instance, in English.

        This is shown in the center of the screen below the game name at the
        start of a game. It should start with a capital letter and end with a
        period, and can be a bit more verbose than the version returned by
        get_instance_description_short().

        Note that translation is applied by looking up the specific returned
        value as a key, so the number of returned variations should be limited;
        ideally just one or two. To include arbitrary values in the
        description, you can return a sequence of values in the following
        form instead of just a string:

        # This will give us something like 'Score 3 goals.' in English
        # and can properly translate to 'Anota 3 goles.' in Spanish.
        # If we just returned the string 'Score 3 Goals' here, there would
        # have to be a translation entry for each specific number. ew.
        return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']]

        This way the first string can be consistently translated, with any arg
        values then substituted into the result. ${ARG1} will be replaced with
        the first value, ${ARG2} with the second, etc.
        """
        return self.get_description(type(self.session))

    def get_instance_description_short(self) -> str | Sequence:
        """Return a short description for this game instance in English.

        This description is used above the game scoreboard in the
        corner of the screen, so it should be as concise as possible.
        It should be lowercase and should not contain periods or other
        punctuation.

        Note that translation is applied by looking up the specific returned
        value as a key, so the number of returned variations should be limited;
        ideally just one or two. To include arbitrary values in the
        description, you can return a sequence of values in the following form
        instead of just a string:

        # This will give us something like 'score 3 goals' in English
        # and can properly translate to 'anota 3 goles' in Spanish.
        # If we just returned the string 'score 3 goals' here, there would
        # have to be a translation entry for each specific number. ew.
        return ['score ${ARG1} goals', self.settings_raw['Score to Win']]

        This way the first string can be consistently translated, with any arg
        values then substituted into the result. ${ARG1} will be replaced
        with the first value, ${ARG2} with the second, etc.

        """
        return ''

    @override
    def on_transition_in(self) -> None:
        super().on_transition_in()

        # Make our map.
        self._map = self._map_type()

        # Give our map a chance to override the music
        map_music = self._map_type.get_music_type()
        music = map_music if map_music is not None else self.default_music

        if music is not None:
            _music.setmusic(music)

    @override
    def on_begin(self) -> None:
        super().on_begin()

        if babase.app.classic is not None:
            babase.app.classic.game_begin_analytics()

        _bascenev1.timer(0.001, self._show_scoreboard_info)
        _bascenev1.timer(1.0, self._show_info)
        _bascenev1.timer(2.5, self._show_tip)

        # Store some basic info about players present at start time.
        self.initialplayerinfos = [
            PlayerInfo(name=p.getname(full=True), character=p.character)
            for p in self.players
        ]

        # Sort this by name so high score lists/etc will be consistent
        # regardless of player join order.
        self.initialplayerinfos.sort(key=lambda x: x.name)

        # If this is a tournament, query info about it such as how much
        # time is left.
        tournament_id = self.session.tournament_id
        if tournament_id is not None:
            assert babase.app.plus is not None
            babase.app.plus.tournament_query(
                args={
                    'tournamentIDs': [tournament_id],
                    'source': 'in-game time remaining query',
                },
                callback=babase.WeakCallPartial(
                    self._on_tournament_query_response
                ),
            )

    def _on_tournament_query_response(
        self, data: dict[str, Any] | None
    ) -> None:
        if data is not None:
            data_t = data['t']  # This used to be the whole payload.

            # Keep our cached tourney info up to date
            assert babase.app.classic is not None
            babase.app.classic.accounts.cache_tournament_info(data_t)
            self._setup_tournament_time_limit(
                max(5, data_t[0]['timeRemaining'])
            )

    @override
    def on_player_join(self, player: PlayerT) -> None:
        super().on_player_join(player)

        # By default, just spawn a dude.
        self.spawn_player(player)

    @override
    def handlemessage(self, msg: Any) -> Any:
        if isinstance(msg, PlayerDiedMessage):
            # pylint: disable=cyclic-import
            from bascenev1lib.actor.spaz import Spaz

            player = msg.getplayer(self.playertype)
            killer = msg.getkillerplayer(self.playertype)

            # Inform our stats of the demise.
            self.stats.player_was_killed(
                player, killed=msg.killed, killer=killer
            )

            # Award the killer points if he's on a different team.
            # FIXME: This should not be linked to Spaz actors.
            # (should move get_death_points to Actor or make it a message)
            if killer and killer.team is not player.team:
                assert isinstance(killer.actor, Spaz)
                pts, importance = killer.actor.get_death_points(msg.how)
                if not self.has_ended():
                    self.stats.player_scored(
                        killer,
                        pts,
                        kill=True,
                        victim_player=player,
                        importance=importance,
                        showpoints=self.show_kill_points,
                    )
        else:
            return super().handlemessage(msg)
        return None

    def _show_scoreboard_info(self) -> None:
        """Create the game info display.

        This is the thing in the top left corner showing the name
        and short description of the game.
        """
        from bascenev1._freeforallsession import FreeForAllSession
        from bascenev1._gameutils import animate
        from bascenev1._nodeactor import NodeActor

        sb_name = self.get_instance_scoreboard_display_string(langstr=True)

        # The description can be either a string or a sequence with args
        # to swap in post-translation.
        sb_desc_in = self.get_instance_description_short()
        sb_desc_l: Sequence
        if isinstance(sb_desc_in, str):
            sb_desc_l = [sb_desc_in]  # handle simple string case
        else:
            sb_desc_l = sb_desc_in
        if not isinstance(sb_desc_l[0], str):
            raise TypeError('Invalid format for instance description.')

        is_empty = sb_desc_l[0] == ''
        arg = str(sb_desc_l[1]) if len(sb_desc_l) > 1 else None
        sb_desc = get_game_description(sb_desc_l[0], arg=arg)
        vrmode = babase.app.env.vr
        yval = -34 if is_empty else -20
        yval -= 16
        sbpos = (
            (15, yval)
            if isinstance(self.session, FreeForAllSession)
            else (15, yval)
        )
        self._game_scoreboard_name_text = NodeActor(
            _bascenev1.newnode(
                'text',
                attrs={
                    'text': sb_name,
                    'maxwidth': 300,
                    'position': sbpos,
                    'h_attach': 'left',
                    'vr_depth': 10,
                    'v_attach': 'top',
                    'v_align': 'bottom',
                    'color': (1.0, 1.0, 1.0, 1.0),
                    'shadow': 1.0 if vrmode else 0.6,
                    'flatness': 1.0 if vrmode else 0.5,
                    'scale': 1.1,
                },
            )
        )

        assert self._game_scoreboard_name_text.node
        animate(
            self._game_scoreboard_name_text.node, 'opacity', {0: 0.0, 1.0: 1.0}
        )

        descpos = (
            (17, -44 + 10)
            if isinstance(self.session, FreeForAllSession)
            else (17, -44 + 10)
        )
        self._game_scoreboard_description_text = NodeActor(
            _bascenev1.newnode(
                'text',
                attrs={
                    'text': sb_desc,
                    'maxwidth': 480,
                    'position': descpos,
                    'scale': 0.7,
                    'h_attach': 'left',
                    'v_attach': 'top',
                    'v_align': 'top',
                    'shadow': 1.0 if vrmode else 0.7,
                    'flatness': 1.0 if vrmode else 0.8,
                    'color': (1, 1, 1, 1) if vrmode else (0.9, 0.9, 0.9, 1.0),
                },
            )
        )

        assert self._game_scoreboard_description_text.node
        animate(
            self._game_scoreboard_description_text.node,
            'opacity',
            {0: 0.0, 1.0: 1.0},
        )

    def _show_info(self) -> None:
        """Show the game description."""
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets

        from bascenev1._gameutils import animate
        from bascenev1lib.actor.zoomtext import ZoomText

        name = self.get_instance_display_string(langstr=True)
        ZoomText(
            name,
            maxwidth=800,
            lifespan=2.5,
            jitter=2.0,
            position=(0, 180),
            flash=False,
            color=(0.93 * 1.25, 0.9 * 1.25, 1.0 * 1.25),
            trailcolor=(0.15, 0.05, 1.0, 0.0),
        ).autoretain()
        _bascenev1.timer(0.2, classicassets.audio.gong.get().play)
        # _bascenev1.timer(
        #     0.2, Call(_bascenev1.playsound, classicassets.audio.gong)
        # )

        # The description can be either a string or a sequence with args
        # to swap in post-translation.
        desc_in = self.get_instance_description()
        desc_l: Sequence
        if isinstance(desc_in, str):
            desc_l = [desc_in]  # handle simple string case
        else:
            desc_l = desc_in
        if not isinstance(desc_l[0], str):
            raise TypeError('Invalid format for instance description')
        arg = str(desc_l[1]) if len(desc_l) > 1 else None
        translation: babase.LangStr = get_game_description(desc_l[0], arg=arg)

        # Do some standard filters (epic mode, etc).
        if self.settings_raw.get('Epic Mode', False):
            translation = classicassets.strings.game.epic_description_filter(
                description=translation
            )
        vrmode = babase.app.env.vr
        dnode = _bascenev1.newnode(
            'text',
            attrs={
                'v_attach': 'center',
                'h_attach': 'center',
                'h_align': 'center',
                'color': (1, 1, 1, 1),
                'shadow': 1.0 if vrmode else 0.5,
                'flatness': 1.0 if vrmode else 0.5,
                'vr_depth': -30,
                'position': (0, 80),
                'scale': 1.2,
                'maxwidth': 700,
                'text': translation,
            },
        )
        cnode = _bascenev1.newnode(
            'combine',
            owner=dnode,
            attrs={'input0': 1.0, 'input1': 1.0, 'input2': 1.0, 'size': 4},
        )
        cnode.connectattr('output', dnode, 'color')
        keys = {0.5: 0, 1.0: 1.0, 2.5: 1.0, 4.0: 0.0}
        animate(cnode, 'input3', keys)
        _bascenev1.timer(4.0, dnode.delete)

    def _show_tip(self) -> None:
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets
        from bascenev1._gameutils import animate, GameTip

        # If there's any tips left on the list, display one.
        if self.tips:
            tip = self.tips.pop(random.randrange(len(self.tips)))
            tip_title = classicassets.strings.game.tip_title
            icon: bascenev1.Texture | None = None
            sound: bascenev1.Sound | None = None
            if isinstance(tip, GameTip):
                icon = tip.icon
                sound = tip.sound
                tip = tip.text

            tip_lstr: babase.Lstr | babase.LangStr
            if isinstance(tip, babase.LangStr):
                # Authored tip; already carries any substitutions it
                # needs (see e.g. football's pickup_flag).
                tip_lstr = tip
            else:
                # A plain string -- a mod's tip, or first-party text not
                # yet ported. Legacy translate path, including the
                # ${PICKUP} sub those strings may still contain.
                tip_lstr = babase.Lstr(
                    translate=('tips', tip),
                    subs=[
                        (
                            '${PICKUP}',
                            babase.charstr(babase.SpecialChar.TOP_BUTTON),
                        )
                    ],
                )
            base_position = (75, 50)
            tip_scale = 0.8
            tip_title_scale = 1.2
            vrmode = babase.app.env.vr

            t_offs = -350.0
            tnode = _bascenev1.newnode(
                'text',
                attrs={
                    'text': tip_lstr,
                    'scale': tip_scale,
                    'maxwidth': 900,
                    'position': (base_position[0] + t_offs, base_position[1]),
                    'h_align': 'left',
                    'vr_depth': 300,
                    'shadow': 1.0 if vrmode else 0.5,
                    'flatness': 1.0 if vrmode else 0.5,
                    'v_align': 'center',
                    'v_attach': 'bottom',
                },
            )
            t2pos = (
                base_position[0] + t_offs - (20 if icon is None else 82),
                base_position[1] + 2,
            )
            t2node = _bascenev1.newnode(
                'text',
                owner=tnode,
                attrs={
                    'text': tip_title,
                    'scale': tip_title_scale,
                    'position': t2pos,
                    'h_align': 'right',
                    'vr_depth': 300,
                    'shadow': 1.0 if vrmode else 0.5,
                    'flatness': 1.0 if vrmode else 0.5,
                    'maxwidth': 140,
                    'v_align': 'center',
                    'v_attach': 'bottom',
                },
            )
            if icon is not None:
                ipos = (base_position[0] + t_offs - 40, base_position[1] + 1)
                img = _bascenev1.newnode(
                    'image',
                    attrs={
                        'texture': icon,
                        'position': ipos,
                        'scale': (50, 50),
                        'opacity': 1.0,
                        'vr_depth': 315,
                        'color': (1, 1, 1),
                        'absolute_scale': True,
                        'attach': 'bottomCenter',
                    },
                )
                animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0})
                _bascenev1.timer(5.0, img.delete)
            if sound is not None:
                sound.play()

            combine = _bascenev1.newnode(
                'combine',
                owner=tnode,
                attrs={'input0': 1.0, 'input1': 0.8, 'input2': 1.0, 'size': 4},
            )
            combine.connectattr('output', tnode, 'color')
            combine.connectattr('output', t2node, 'color')
            animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0})
            _bascenev1.timer(5.0, tnode.delete)

    @override
    def end(
        self, results: Any = None, delay: float = 0.0, force: bool = False
    ) -> None:
        from bascenev1._gameresults import GameResults

        # If results is a standard team-game-results, associate it with us
        # so it can grab our score prefs.
        if isinstance(results, GameResults):
            results.set_game(self)

        # If we had a standard time-limit that had not expired, stop it so
        # it doesnt tick annoyingly.
        if (
            self._standard_time_limit_time is not None
            and self._standard_time_limit_time > 0
        ):
            self._standard_time_limit_timer = None
            self._standard_time_limit_text = None

        # Ditto with tournament time limits.
        if (
            self._tournament_time_limit is not None
            and self._tournament_time_limit > 0
        ):
            self._tournament_time_limit_timer = None
            self._tournament_time_limit_text = None
            self._tournament_time_limit_title_text = None

        super().end(results, delay, force)

    def end_game(self) -> None:
        """Tell the game to wrap up and call bascenev1.Activity.end().

        This method should be overridden by subclasses. A game should always
        be prepared to end and deliver results, even if there is no 'winner'
        yet; this way things like the standard time-limit
        (bascenev1.GameActivity.setup_standard_time_limit()) will work with
        the game.
        """
        print(
            'WARNING: default end_game() implementation called;'
            ' your game should override this.'
        )

    def respawn_player(
        self, player: PlayerT, respawn_time: float | None = None
    ) -> None:
        """
        Given a bascenev1.Player, sets up a standard respawn timer,
        along with the standard counter display, etc.
        At the end of the respawn period spawn_player() will
        be called if the Player still exists.
        An explicit 'respawn_time' can optionally be provided
        (in seconds).
        """
        # pylint: disable=cyclic-import

        assert player
        if respawn_time is None:
            teamsize = len(player.team.players)
            if teamsize == 1:
                respawn_time = 3.0
            elif teamsize == 2:
                respawn_time = 5.0
            elif teamsize == 3:
                respawn_time = 6.0
            else:
                respawn_time = 7.0

        # If this standard setting is present, factor it in.
        if 'Respawn Times' in self.settings_raw:
            respawn_time *= self.settings_raw['Respawn Times']

        # We want whole seconds.
        assert respawn_time is not None
        respawn_time = round(max(1.0, respawn_time), 0)

        if player.actor and not self.has_ended():
            from bascenev1lib.actor.respawnicon import RespawnIcon

            player.customdata['respawn_timer'] = _bascenev1.Timer(
                respawn_time,
                babase.WeakCallStrict(self.spawn_player_if_exists, player),
            )
            player.customdata['respawn_icon'] = RespawnIcon(
                player, respawn_time
            )

    def spawn_player_if_exists(self, player: PlayerT) -> None:
        """
        A utility method which calls self.spawn_player() *only* if the
        bascenev1.Player provided still exists; handy for use in timers
        and whatnot.

        There is no need to override this; just override spawn_player().
        """
        if player:
            self.spawn_player(player)

    def spawn_player(self, player: PlayerT) -> bascenev1.Actor:
        """Spawn *something* for the provided player.

        The default implementation simply calls
        :meth:`spawn_player_spaz()`.
        """
        assert player  # Dead references should never be passed as args.

        return self.spawn_player_spaz(player)

    def spawn_player_spaz(
        self,
        player: PlayerT,
        position: Sequence[float] = (0, 0, 0),
        angle: float | None = None,
    ) -> PlayerSpaz:
        """Create and wire up a player-spaz for the provided player."""
        # pylint: disable=cyclic-import
        from bascenev1._gameutils import animate
        from bascenev1._coopsession import CoopSession
        from bascenev1lib.actor.playerspaz import PlayerSpaz

        name = player.getname()
        color = player.color
        highlight = player.highlight

        playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz)
        if not issubclass(playerspaztype, PlayerSpaz):
            playerspaztype = PlayerSpaz

        light_color = babase.normalized_color(color)
        display_color = babase.safecolor(color, target_intensity=0.75)
        spaz = playerspaztype(
            color=color,
            highlight=highlight,
            character=player.character,
            player=player,
        )

        player.actor = spaz
        assert spaz.node

        # If this is co-op and we're on Courtyard or Runaround, add the
        # material that allows us to collide with the player-walls.
        # FIXME: Need to generalize this.
        if isinstance(self.session, CoopSession) and self.map.getname() in [
            'Courtyard',
            'Tower D',
        ]:
            mat = self.map.preloaddata['collide_with_wall_material']
            assert isinstance(spaz.node.materials, tuple)
            assert isinstance(spaz.node.roller_materials, tuple)
            spaz.node.materials += (mat,)
            spaz.node.roller_materials += (mat,)

        spaz.node.name = name
        spaz.node.name_color = display_color
        spaz.connect_controls_to_player()

        # Move to the stand position and add a flash of light.
        spaz.handlemessage(
            StandMessage(
                position, angle if angle is not None else random.uniform(0, 360)
            )
        )
        self._spawn_sound.play(1, position=spaz.node.position)
        light = _bascenev1.newnode('light', attrs={'color': light_color})
        spaz.node.connectattr('position', light, 'position')
        animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0})
        _bascenev1.timer(0.5, light.delete)
        return spaz

    def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None:
        """Create standard powerup drops for the current map."""
        # pylint: disable=cyclic-import
        from bascenev1lib.actor.powerupbox import DEFAULT_POWERUP_INTERVAL

        self._powerup_drop_timer = _bascenev1.Timer(
            DEFAULT_POWERUP_INTERVAL,
            babase.WeakCallStrict(self._standard_drop_powerups),
            repeat=True,
        )
        self._standard_drop_powerups()
        if enable_tnt:
            self._tnt_spawners = {}
            self._setup_standard_tnt_drops()

    def _standard_drop_powerup(self, index: int, expire: bool = True) -> None:
        # pylint: disable=cyclic-import
        from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory

        PowerupBox(
            position=self.map.powerup_spawn_points[index],
            poweruptype=PowerupBoxFactory.get().get_random_powerup_type(),
            expire=expire,
        ).autoretain()

    def _standard_drop_powerups(self) -> None:
        """Standard powerup drop."""

        # Drop one powerup per point.
        points = self.map.powerup_spawn_points
        for i in range(len(points)):
            _bascenev1.timer(
                i * 0.4, babase.WeakCallStrict(self._standard_drop_powerup, i)
            )

    def _setup_standard_tnt_drops(self) -> None:
        """Standard tnt drop."""
        # pylint: disable=cyclic-import
        from bascenev1lib.actor.bomb import TNTSpawner

        for i, point in enumerate(self.map.tnt_points):
            assert self._tnt_spawners is not None
            if self._tnt_spawners.get(i) is None:
                self._tnt_spawners[i] = TNTSpawner(point)

    def setup_standard_time_limit(self, duration: float) -> None:
        """
        Create a standard game time-limit given the provided
        duration in seconds.
        This will be displayed at the top of the screen.
        If the time-limit expires, end_game() will be called.
        """
        from bascenev1._nodeactor import NodeActor

        if duration <= 0.0:
            return
        self._standard_time_limit_time = int(duration)
        self._standard_time_limit_timer = _bascenev1.Timer(
            1.0,
            babase.WeakCallStrict(self._standard_time_limit_tick),
            repeat=True,
        )
        self._standard_time_limit_text = NodeActor(
            _bascenev1.newnode(
                'text',
                attrs={
                    'v_attach': 'top',
                    'h_attach': 'center',
                    'h_align': 'left',
                    'color': (1.0, 1.0, 1.0, 0.5),
                    'position': (-25, -30),
                    'flatness': 1.0,
                    'scale': 0.9,
                },
            )
        )
        self._standard_time_limit_text_input = NodeActor(
            _bascenev1.newnode(
                'timedisplay', attrs={'time2': duration * 1000, 'timemin': 0}
            )
        )
        self.globalsnode.connectattr(
            'time', self._standard_time_limit_text_input.node, 'time1'
        )
        assert self._standard_time_limit_text_input.node
        assert self._standard_time_limit_text.node
        self._standard_time_limit_text_input.node.connectattr(
            'output', self._standard_time_limit_text.node, 'text'
        )

    def _standard_time_limit_tick(self) -> None:
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets

        from bascenev1._gameutils import animate

        assert self._standard_time_limit_time is not None
        self._standard_time_limit_time -= 1
        if self._standard_time_limit_time <= 10:
            if self._standard_time_limit_time == 10:
                assert self._standard_time_limit_text is not None
                assert self._standard_time_limit_text.node
                self._standard_time_limit_text.node.scale = 1.3
                self._standard_time_limit_text.node.position = (-30, -45)
                cnode = _bascenev1.newnode(
                    'combine',
                    owner=self._standard_time_limit_text.node,
                    attrs={'size': 4},
                )
                cnode.connectattr(
                    'output', self._standard_time_limit_text.node, 'color'
                )
                animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True)
                animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True)
                animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True)
                cnode.input3 = 1.0
            classicassets.audio.tick.get().play()
        if self._standard_time_limit_time <= 0:
            self._standard_time_limit_timer = None
            self.end_game()
            node = _bascenev1.newnode(
                'text',
                attrs={
                    'v_attach': 'top',
                    'h_attach': 'center',
                    'h_align': 'center',
                    'color': (1, 0.7, 0, 1),
                    'position': (0, -90),
                    'scale': 1.2,
                    'text': classicassets.strings.game.time_expired,
                },
            )
            classicassets.audio.ref_whistle.get().play()
            animate(node, 'scale', {0.0: 0.0, 0.1: 1.4, 0.15: 1.2})

    def _setup_tournament_time_limit(self, duration: float) -> None:
        """
        Create a tournament game time-limit given the provided
        duration in seconds.
        This will be displayed at the top of the screen.
        If the time-limit expires, end_game() will be called.
        """
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets
        from bascenev1._nodeactor import NodeActor

        if duration <= 0.0:
            return
        self._tournament_time_limit = int(duration)

        # We want this timer to match the server's time as close as possible,
        # so lets go with base-time. Theoretically we should do real-time but
        # then we have to mess with contexts and whatnot since its currently
        # not available in activity contexts. :-/
        self._tournament_time_limit_timer = _bascenev1.BaseTimer(
            1.0,
            babase.WeakCallStrict(self._tournament_time_limit_tick),
            repeat=True,
        )
        self._tournament_time_limit_title_text = NodeActor(
            _bascenev1.newnode(
                'text',
                attrs={
                    'v_attach': 'bottom',
                    'h_attach': 'right',
                    'h_align': 'center',
                    'v_align': 'center',
                    'vr_depth': 300,
                    'maxwidth': 100,
                    'color': (1.0, 1.0, 1.0, 0.5),
                    'position': (-60, 50),
                    'flatness': 1.0,
                    'scale': 0.5,
                    'text': classicassets.strings.coop.tournament,
                },
            )
        )
        self._tournament_time_limit_text = NodeActor(
            _bascenev1.newnode(
                'text',
                attrs={
                    'v_attach': 'bottom',
                    'h_attach': 'right',
                    'h_align': 'center',
                    'v_align': 'center',
                    'vr_depth': 300,
                    'maxwidth': 100,
                    'color': (1.0, 1.0, 1.0, 0.5),
                    'position': (-60, 30),
                    'flatness': 1.0,
                    'scale': 0.9,
                },
            )
        )
        self._tournament_time_limit_text_input = NodeActor(
            _bascenev1.newnode(
                'timedisplay',
                attrs={
                    'timemin': 0,
                    'time2': self._tournament_time_limit * 1000,
                },
            )
        )
        assert self._tournament_time_limit_text.node
        assert self._tournament_time_limit_text_input.node
        self._tournament_time_limit_text_input.node.connectattr(
            'output', self._tournament_time_limit_text.node, 'text'
        )

    def _tournament_time_limit_tick(self) -> None:
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets

        from bascenev1._gameutils import animate

        assert self._tournament_time_limit is not None
        self._tournament_time_limit -= 1
        if self._tournament_time_limit <= 10:
            if self._tournament_time_limit == 10:
                assert self._tournament_time_limit_title_text is not None
                assert self._tournament_time_limit_title_text.node
                assert self._tournament_time_limit_text is not None
                assert self._tournament_time_limit_text.node
                self._tournament_time_limit_title_text.node.scale = 1.0
                self._tournament_time_limit_text.node.scale = 1.3
                self._tournament_time_limit_title_text.node.position = (-80, 85)
                self._tournament_time_limit_text.node.position = (-80, 60)
                cnode = _bascenev1.newnode(
                    'combine',
                    owner=self._tournament_time_limit_text.node,
                    attrs={'size': 4},
                )
                cnode.connectattr(
                    'output',
                    self._tournament_time_limit_title_text.node,
                    'color',
                )
                cnode.connectattr(
                    'output', self._tournament_time_limit_text.node, 'color'
                )
                animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True)
                animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True)
                animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True)
                cnode.input3 = 1.0
            classicassets.audio.tick.get().play()
        if self._tournament_time_limit <= 0:
            self._tournament_time_limit_timer = None
            self.end_game()
            tval = classicassets.strings.game.tournament_time_expired
            node = _bascenev1.newnode(
                'text',
                attrs={
                    'v_attach': 'top',
                    'h_attach': 'center',
                    'h_align': 'center',
                    'color': (1, 0.7, 0, 1),
                    'position': (0, -200),
                    'scale': 1.6,
                    'text': tval,
                },
            )
            classicassets.audio.ref_whistle.get().play()
            animate(node, 'scale', {0: 0.0, 0.1: 1.4, 0.15: 1.2})

        # Normally we just connect this to time, but since this is a bit of a
        # funky setup we just update it manually once per second.
        assert self._tournament_time_limit_text_input is not None
        assert self._tournament_time_limit_text_input.node
        self._tournament_time_limit_text_input.node.time2 = (
            self._tournament_time_limit * 1000
        )

    def show_zoom_message(
        self,
        message: babase.Lstr | babase.LangStr,
        *,
        color: Sequence[float] = (0.9, 0.4, 0.0),
        scale: float = 0.8,
        duration: float = 2.0,
        trail: bool = False,
    ) -> None:
        """Zooming text used to announce game names and winners."""
        # pylint: disable=cyclic-import
        from bascenev1lib.actor.zoomtext import ZoomText

        # Reserve a spot on the screen (in case we get multiple of these so
        # they don't overlap).
        i = 0
        cur_time = babase.apptime()
        while True:
            if (
                i not in self._zoom_message_times
                or self._zoom_message_times[i] < cur_time
            ):
                self._zoom_message_times[i] = cur_time + duration
                break
            i += 1
        ZoomText(
            message,
            lifespan=duration,
            jitter=2.0,
            position=(0, 200 - i * 100),
            scale=scale,
            maxwidth=800,
            trail=trail,
            color=color,
        ).autoretain()

    def _calc_map_name(self, settings: dict) -> str:
        # Safe up-call: bascenev1 is fully imported by the time
        # this runs; the cycle pylint sees is structural only.
        # pylint: disable-next=cyclic-import
        from bascenev1 import classicassets

        map_name: str
        if 'map' in settings:
            map_name = settings['map']
        else:
            # If settings doesn't specify a map, pick a random one from the
            # list of supported ones.
            unowned_maps: list[str] = (
                babase.app.classic.store.get_unowned_maps()
                if babase.app.classic is not None
                else []
            )
            valid_maps: list[str] = [
                m
                for m in self.get_supported_maps(type(self.session))
                if m not in unowned_maps
            ]
            if not valid_maps:
                _bascenev1.broadcastmessage(
                    classicassets.strings.game.no_valid_maps_error
                )
                raise RuntimeError('No valid maps')
            map_name = valid_maps[random.randrange(len(valid_maps))]
        return map_name
