mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-02-06 07:23:37 +08:00
Merge pull request #302 from Dliwk/coop-headless
Co-op in headless builds
This commit is contained in:
commit
7e07169fad
1
.idea/dictionaries/roman.xml
generated
1
.idea/dictionaries/roman.xml
generated
@ -1,6 +1,7 @@
|
|||||||
<component name="ProjectDictionaryState">
|
<component name="ProjectDictionaryState">
|
||||||
<dictionary name="roman">
|
<dictionary name="roman">
|
||||||
<words>
|
<words>
|
||||||
|
<w>gamename</w>
|
||||||
<w>maxlen</w>
|
<w>maxlen</w>
|
||||||
<w>pagename</w>
|
<w>pagename</w>
|
||||||
</words>
|
</words>
|
||||||
|
|||||||
@ -112,6 +112,10 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
|
|||||||
# transitions).
|
# transitions).
|
||||||
inherits_tint = False
|
inherits_tint = False
|
||||||
|
|
||||||
|
# Whether players should be allowed to join in the middle of
|
||||||
|
# activity.
|
||||||
|
allow_mid_activity_joins: bool = True
|
||||||
|
|
||||||
# If the activity fades or transitions in, it should set the length of
|
# If the activity fades or transitions in, it should set the length of
|
||||||
# time here so that previous activities will be kept alive for that
|
# time here so that previous activities will be kept alive for that
|
||||||
# long (avoiding 'holes' in the screen)
|
# long (avoiding 'holes' in the screen)
|
||||||
|
|||||||
@ -90,6 +90,16 @@ class CoopSession(Session):
|
|||||||
"""Get the game instance currently being played."""
|
"""Get the game instance currently being played."""
|
||||||
return self._current_game_instance
|
return self._current_game_instance
|
||||||
|
|
||||||
|
def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
|
||||||
|
# pylint: disable=cyclic-import
|
||||||
|
from ba._gameactivity import GameActivity
|
||||||
|
|
||||||
|
# Disallow any joins in the middle of the game.
|
||||||
|
if isinstance(activity, GameActivity):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _update_on_deck_game_instances(self) -> None:
|
def _update_on_deck_game_instances(self) -> None:
|
||||||
# pylint: disable=cyclic-import
|
# pylint: disable=cyclic-import
|
||||||
from ba._gameactivity import GameActivity
|
from ba._gameactivity import GameActivity
|
||||||
@ -156,8 +166,24 @@ class CoopSession(Session):
|
|||||||
from ba._general import WeakCall
|
from ba._general import WeakCall
|
||||||
super().on_player_leave(sessionplayer)
|
super().on_player_leave(sessionplayer)
|
||||||
|
|
||||||
# If all our players leave we wanna quit out of the session.
|
_ba.timer(2.0, WeakCall(self._check_end_game))
|
||||||
_ba.timer(2.0, WeakCall(self._end_session_if_empty))
|
|
||||||
|
def _check_end_game(self) -> None:
|
||||||
|
if not _ba.app.server:
|
||||||
|
self._end_session_if_empty()
|
||||||
|
|
||||||
|
activity = self.getactivity()
|
||||||
|
if activity is None:
|
||||||
|
return # Probably everything is already broken, why do something?
|
||||||
|
|
||||||
|
if [player for player in activity.players if player.is_alive()]:
|
||||||
|
return
|
||||||
|
|
||||||
|
with _ba.Context(activity):
|
||||||
|
from ba._gameactivity import GameActivity
|
||||||
|
|
||||||
|
if isinstance(activity, GameActivity):
|
||||||
|
activity.end_game()
|
||||||
|
|
||||||
def _end_session_if_empty(self) -> None:
|
def _end_session_if_empty(self) -> None:
|
||||||
activity = self.getactivity()
|
activity = self.getactivity()
|
||||||
@ -250,10 +276,11 @@ class CoopSession(Session):
|
|||||||
|
|
||||||
# If at any point we have no in-game players, quit out of the session
|
# If at any point we have no in-game players, quit out of the session
|
||||||
# (this can happen if someone leaves in the tutorial for instance).
|
# (this can happen if someone leaves in the tutorial for instance).
|
||||||
active_players = [p for p in self.sessionplayers if p.in_game]
|
if isinstance(activity, TutorialActivity):
|
||||||
if not active_players:
|
active_players = [p for p in self.sessionplayers if p.in_game]
|
||||||
self.end()
|
if not active_players:
|
||||||
return
|
self.end()
|
||||||
|
return
|
||||||
|
|
||||||
# If we're in a between-round activity or a restart-activity,
|
# If we're in a between-round activity or a restart-activity,
|
||||||
# hop into a round.
|
# hop into a round.
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import _ba
|
|||||||
from ba._generated.enums import TimeType
|
from ba._generated.enums import TimeType
|
||||||
from ba._freeforallsession import FreeForAllSession
|
from ba._freeforallsession import FreeForAllSession
|
||||||
from ba._dualteamsession import DualTeamSession
|
from ba._dualteamsession import DualTeamSession
|
||||||
|
from ba._coopsession import CoopSession
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import Optional, Dict, Any, Type
|
from typing import Optional, Dict, Any, Type
|
||||||
@ -97,6 +98,8 @@ class ServerController:
|
|||||||
self._playlist_fetch_got_response = False
|
self._playlist_fetch_got_response = False
|
||||||
self._playlist_fetch_code = -1
|
self._playlist_fetch_code = -1
|
||||||
|
|
||||||
|
self._coop_game_name = self._config.coop_game_name
|
||||||
|
|
||||||
# Now sit around doing any pre-launch prep such as waiting for
|
# Now sit around doing any pre-launch prep such as waiting for
|
||||||
# account sign-in or fetching playlists; this will kick off the
|
# account sign-in or fetching playlists; this will kick off the
|
||||||
# session once done.
|
# session once done.
|
||||||
@ -289,11 +292,14 @@ class ServerController:
|
|||||||
return FreeForAllSession
|
return FreeForAllSession
|
||||||
if self._config.session_type == 'teams':
|
if self._config.session_type == 'teams':
|
||||||
return DualTeamSession
|
return DualTeamSession
|
||||||
|
if self._config.session_type == 'coop':
|
||||||
|
return CoopSession
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'Invalid session_type: "{self._config.session_type}"')
|
f'Invalid session_type: "{self._config.session_type}"')
|
||||||
|
|
||||||
def _launch_server_session(self) -> None:
|
def _launch_server_session(self) -> None:
|
||||||
"""Kick off a host-session based on the current server config."""
|
"""Kick off a host-session based on the current server config."""
|
||||||
|
# pylint: disable=too-many-branches
|
||||||
app = _ba.app
|
app = _ba.app
|
||||||
appcfg = app.config
|
appcfg = app.config
|
||||||
sessiontype = self._get_session_type()
|
sessiontype = self._get_session_type()
|
||||||
@ -311,6 +317,8 @@ class ServerController:
|
|||||||
ptypename = 'Free-for-All'
|
ptypename = 'Free-for-All'
|
||||||
elif sessiontype is DualTeamSession:
|
elif sessiontype is DualTeamSession:
|
||||||
ptypename = 'Team Tournament'
|
ptypename = 'Team Tournament'
|
||||||
|
elif sessiontype is CoopSession:
|
||||||
|
ptypename = 'Coop'
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'Unknown session type {sessiontype}')
|
raise RuntimeError(f'Unknown session type {sessiontype}')
|
||||||
|
|
||||||
@ -340,6 +348,13 @@ class ServerController:
|
|||||||
appcfg['Team Tournament Playlist Selection'] = self._playlist_name
|
appcfg['Team Tournament Playlist Selection'] = self._playlist_name
|
||||||
appcfg['Team Tournament Playlist Randomize'] = (
|
appcfg['Team Tournament Playlist Randomize'] = (
|
||||||
self._config.playlist_shuffle)
|
self._config.playlist_shuffle)
|
||||||
|
elif sessiontype is CoopSession:
|
||||||
|
gamename = self._coop_game_name or 'Default:Onslaught Training'
|
||||||
|
campaignname, levelname = gamename.split(':')
|
||||||
|
app.coop_session_args = {
|
||||||
|
'campaign': campaignname,
|
||||||
|
'level': levelname,
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'Unknown session type {sessiontype}')
|
raise RuntimeError(f'Unknown session type {sessiontype}')
|
||||||
|
|
||||||
|
|||||||
@ -63,10 +63,6 @@ class Session:
|
|||||||
team instead of their own profile colors. This only applies if
|
team instead of their own profile colors. This only applies if
|
||||||
use_teams is enabled.
|
use_teams is enabled.
|
||||||
|
|
||||||
allow_mid_activity_joins
|
|
||||||
Whether players should be allowed to join in the middle of
|
|
||||||
activities.
|
|
||||||
|
|
||||||
customdata
|
customdata
|
||||||
A shared dictionary for objects to use as storage on this session.
|
A shared dictionary for objects to use as storage on this session.
|
||||||
Ensure that keys here are unique to avoid collisions.
|
Ensure that keys here are unique to avoid collisions.
|
||||||
@ -74,7 +70,6 @@ class Session:
|
|||||||
"""
|
"""
|
||||||
use_teams: bool = False
|
use_teams: bool = False
|
||||||
use_team_colors: bool = True
|
use_team_colors: bool = True
|
||||||
allow_mid_activity_joins: bool = True
|
|
||||||
|
|
||||||
# Note: even though these are instance vars, we annotate them at the
|
# Note: even though these are instance vars, we annotate them at the
|
||||||
# class level so that docs generation can access their types.
|
# class level so that docs generation can access their types.
|
||||||
@ -210,6 +205,15 @@ class Session:
|
|||||||
raise NodeNotFoundError()
|
raise NodeNotFoundError()
|
||||||
return node
|
return node
|
||||||
|
|
||||||
|
def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
|
||||||
|
"""Returned value is used by the Session to determine
|
||||||
|
whether to allow players to join in the middle of activity.
|
||||||
|
|
||||||
|
Activity.allow_mid_activity_joins is also required to allow these
|
||||||
|
joins."""
|
||||||
|
del activity # Unused.
|
||||||
|
return True
|
||||||
|
|
||||||
def on_player_request(self, player: ba.SessionPlayer) -> bool:
|
def on_player_request(self, player: ba.SessionPlayer) -> bool:
|
||||||
"""Called when a new ba.Player wants to join the Session.
|
"""Called when a new ba.Player wants to join the Session.
|
||||||
|
|
||||||
@ -220,7 +224,6 @@ class Session:
|
|||||||
if _ba.app.stress_test_reset_timer is None:
|
if _ba.app.stress_test_reset_timer is None:
|
||||||
|
|
||||||
if len(self.sessionplayers) >= self.max_players:
|
if len(self.sessionplayers) >= self.max_players:
|
||||||
|
|
||||||
# Print a rejection message *only* to the client trying to
|
# Print a rejection message *only* to the client trying to
|
||||||
# join (prevents spamming everyone else in the game).
|
# join (prevents spamming everyone else in the game).
|
||||||
_ba.playsound(_ba.getsound('error'))
|
_ba.playsound(_ba.getsound('error'))
|
||||||
@ -657,7 +660,8 @@ class Session:
|
|||||||
# However, if we're not allowing mid-game joins, don't actually pass;
|
# However, if we're not allowing mid-game joins, don't actually pass;
|
||||||
# just announce the arrival and say they'll partake next round.
|
# just announce the arrival and say they'll partake next round.
|
||||||
if pass_to_activity:
|
if pass_to_activity:
|
||||||
if not self.allow_mid_activity_joins:
|
if not (activity.allow_mid_activity_joins
|
||||||
|
and self.should_allow_mid_activity_joins(activity)):
|
||||||
pass_to_activity = False
|
pass_to_activity = False
|
||||||
with _ba.Context(self):
|
with _ba.Context(self):
|
||||||
_ba.screenmessage(
|
_ba.screenmessage(
|
||||||
|
|||||||
@ -118,6 +118,12 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
|
|||||||
self._tournament_time_remaining_text: Optional[Text] = None
|
self._tournament_time_remaining_text: Optional[Text] = None
|
||||||
self._tournament_time_remaining_text_timer: Optional[ba.Timer] = None
|
self._tournament_time_remaining_text_timer: Optional[ba.Timer] = None
|
||||||
|
|
||||||
|
# Stuff for activity skip by pressing button
|
||||||
|
self._birth_time = ba.time()
|
||||||
|
self._min_view_time = 5.0
|
||||||
|
self._allow_server_transition = False
|
||||||
|
self._server_transitioning: Optional[bool] = None
|
||||||
|
|
||||||
self._playerinfos: List[ba.PlayerInfo] = settings['playerinfos']
|
self._playerinfos: List[ba.PlayerInfo] = settings['playerinfos']
|
||||||
assert isinstance(self._playerinfos, list)
|
assert isinstance(self._playerinfos, list)
|
||||||
assert (isinstance(i, ba.PlayerInfo) for i in self._playerinfos)
|
assert (isinstance(i, ba.PlayerInfo) for i in self._playerinfos)
|
||||||
@ -485,6 +491,46 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
|
|||||||
if self._store_button_instance is not None:
|
if self._store_button_instance is not None:
|
||||||
self._store_button_instance.set_position((pos_x + 100, pos_y))
|
self._store_button_instance.set_position((pos_x + 100, pos_y))
|
||||||
|
|
||||||
|
def _player_press(self) -> None:
|
||||||
|
# (Only for headless builds).
|
||||||
|
|
||||||
|
# If this activity is a good 'end point', ask server-mode just once if
|
||||||
|
# it wants to do anything special like switch sessions or kill the app.
|
||||||
|
if (self._allow_server_transition and _ba.app.server is not None
|
||||||
|
and self._server_transitioning is None):
|
||||||
|
self._server_transitioning = _ba.app.server.handle_transition()
|
||||||
|
assert isinstance(self._server_transitioning, bool)
|
||||||
|
|
||||||
|
# If server-mode is handling this, don't do anything ourself.
|
||||||
|
if self._server_transitioning is True:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Otherwise restart current level.
|
||||||
|
self._campaign.set_selected_level(self._level_name)
|
||||||
|
with ba.Context(self):
|
||||||
|
self.end({'outcome': 'restart'})
|
||||||
|
|
||||||
|
def _safe_assign(self, player: ba.Player) -> None:
|
||||||
|
# (Only for headless builds).
|
||||||
|
|
||||||
|
# Just to be extra careful, don't assign if we're transitioning out.
|
||||||
|
# (though theoretically that should be ok).
|
||||||
|
if not self.is_transitioning_out() and player:
|
||||||
|
player.assigninput(
|
||||||
|
(ba.InputType.JUMP_PRESS, ba.InputType.PUNCH_PRESS,
|
||||||
|
ba.InputType.BOMB_PRESS, ba.InputType.PICK_UP_PRESS),
|
||||||
|
self._player_press)
|
||||||
|
|
||||||
|
def on_player_join(self, player: ba.Player) -> None:
|
||||||
|
super().on_player_join(player)
|
||||||
|
|
||||||
|
if ba.app.server is not None:
|
||||||
|
# Host can't press retry button, so anyone can do it instead.
|
||||||
|
time_till_assign = max(
|
||||||
|
0, self._birth_time + self._min_view_time - _ba.time())
|
||||||
|
|
||||||
|
ba.timer(time_till_assign, ba.WeakCall(self._safe_assign, player))
|
||||||
|
|
||||||
def on_begin(self) -> None:
|
def on_begin(self) -> None:
|
||||||
# FIXME: Clean this up.
|
# FIXME: Clean this up.
|
||||||
# pylint: disable=too-many-statements
|
# pylint: disable=too-many-statements
|
||||||
@ -582,19 +628,35 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
|
|||||||
color=(0.5, 0.7, 0.5, 1),
|
color=(0.5, 0.7, 0.5, 1),
|
||||||
position=(0, 230)).autoretain()
|
position=(0, 230)).autoretain()
|
||||||
|
|
||||||
adisp = _ba.get_account_display_string()
|
if ba.app.server is None:
|
||||||
txt = Text(ba.Lstr(resource='waitingForHostText',
|
# If we're running in normal non-headless build, show this text
|
||||||
subs=[('${HOST}', adisp)]),
|
# because only host can continue the game.
|
||||||
maxwidth=300,
|
adisp = _ba.get_account_display_string()
|
||||||
transition=Text.Transition.FADE_IN,
|
txt = Text(ba.Lstr(resource='waitingForHostText',
|
||||||
transition_delay=8.0,
|
subs=[('${HOST}', adisp)]),
|
||||||
scale=0.85,
|
maxwidth=300,
|
||||||
h_align=Text.HAlign.CENTER,
|
transition=Text.Transition.FADE_IN,
|
||||||
v_align=Text.VAlign.CENTER,
|
transition_delay=8.0,
|
||||||
color=(1, 1, 0, 1),
|
scale=0.85,
|
||||||
position=(0, -230)).autoretain()
|
h_align=Text.HAlign.CENTER,
|
||||||
assert txt.node
|
v_align=Text.VAlign.CENTER,
|
||||||
txt.node.client_only = True
|
color=(1, 1, 0, 1),
|
||||||
|
position=(0, -230)).autoretain()
|
||||||
|
assert txt.node
|
||||||
|
txt.node.client_only = True
|
||||||
|
else:
|
||||||
|
# In headless build, anyone can continue the game.
|
||||||
|
sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
|
||||||
|
Text(sval,
|
||||||
|
v_attach=Text.VAttach.BOTTOM,
|
||||||
|
h_align=Text.HAlign.CENTER,
|
||||||
|
flash=True,
|
||||||
|
vr_depth=50,
|
||||||
|
position=(0, 60),
|
||||||
|
scale=0.8,
|
||||||
|
color=(0.5, 0.7, 0.5, 0.5),
|
||||||
|
transition=Text.Transition.IN_BOTTOM_SLOW,
|
||||||
|
transition_delay=self._min_view_time).autoretain()
|
||||||
|
|
||||||
if self._score is not None:
|
if self._score is not None:
|
||||||
ba.timer(0.35,
|
ba.timer(0.35,
|
||||||
|
|||||||
@ -179,6 +179,8 @@ class EliminationGame(ba.TeamGameActivity[Player, Team]):
|
|||||||
# Show messages when players die since it's meaningful here.
|
# Show messages when players die since it's meaningful here.
|
||||||
announce_player_deaths = True
|
announce_player_deaths = True
|
||||||
|
|
||||||
|
allow_mid_activity_joins = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_available_settings(
|
def get_available_settings(
|
||||||
cls, sessiontype: Type[ba.Session]) -> List[ba.Setting]:
|
cls, sessiontype: Type[ba.Session]) -> List[ba.Setting]:
|
||||||
@ -257,23 +259,6 @@ class EliminationGame(ba.TeamGameActivity[Player, Team]):
|
|||||||
self.session, ba.DualTeamSession) else 'last one standing wins'
|
self.session, ba.DualTeamSession) else 'last one standing wins'
|
||||||
|
|
||||||
def on_player_join(self, player: Player) -> None:
|
def on_player_join(self, player: Player) -> None:
|
||||||
|
|
||||||
# No longer allowing mid-game joiners here; too easy to exploit.
|
|
||||||
if self.has_begun():
|
|
||||||
|
|
||||||
# Make sure their team has survival seconds set if they're all dead
|
|
||||||
# (otherwise blocked new ffa players are considered 'still alive'
|
|
||||||
# in score tallying).
|
|
||||||
if (self._get_total_team_lives(player.team) == 0
|
|
||||||
and player.team.survival_seconds is None):
|
|
||||||
player.team.survival_seconds = 0
|
|
||||||
ba.screenmessage(
|
|
||||||
ba.Lstr(resource='playerDelayedJoinText',
|
|
||||||
subs=[('${PLAYER}', player.getname(full=True))]),
|
|
||||||
color=(0, 1, 0),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
player.lives = self._lives_per_player
|
player.lives = self._lives_per_player
|
||||||
|
|
||||||
if self._solo_mode:
|
if self._solo_mode:
|
||||||
|
|||||||
@ -44,7 +44,11 @@ class MeteorShowerGame(ba.TeamGameActivity[Player, Team]):
|
|||||||
# Print messages when players die (since its meaningful in this game).
|
# Print messages when players die (since its meaningful in this game).
|
||||||
announce_player_deaths = True
|
announce_player_deaths = True
|
||||||
|
|
||||||
# we're currently hard-coded for one map..
|
# Don't allow joining after we start
|
||||||
|
# (would enable leave/rejoin tomfoolery).
|
||||||
|
allow_mid_activity_joins = False
|
||||||
|
|
||||||
|
# We're currently hard-coded for one map.
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]:
|
def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]:
|
||||||
return ['Rampage']
|
return ['Rampage']
|
||||||
@ -93,22 +97,6 @@ class MeteorShowerGame(ba.TeamGameActivity[Player, Team]):
|
|||||||
# Check for immediate end (if we've only got 1 player, etc).
|
# Check for immediate end (if we've only got 1 player, etc).
|
||||||
ba.timer(5.0, self._check_end_game)
|
ba.timer(5.0, self._check_end_game)
|
||||||
|
|
||||||
def on_player_join(self, player: Player) -> None:
|
|
||||||
# Don't allow joining after we start
|
|
||||||
# (would enable leave/rejoin tomfoolery).
|
|
||||||
if self.has_begun():
|
|
||||||
ba.screenmessage(
|
|
||||||
ba.Lstr(resource='playerDelayedJoinText',
|
|
||||||
subs=[('${PLAYER}', player.getname(full=True))]),
|
|
||||||
color=(0, 1, 0),
|
|
||||||
)
|
|
||||||
# For score purposes, mark them as having died right as the
|
|
||||||
# game started.
|
|
||||||
assert self._timer is not None
|
|
||||||
player.death_time = self._timer.getstarttime()
|
|
||||||
return
|
|
||||||
self.spawn_player(player)
|
|
||||||
|
|
||||||
def on_player_leave(self, player: Player) -> None:
|
def on_player_leave(self, player: Player) -> None:
|
||||||
# Augment default behavior.
|
# Augment default behavior.
|
||||||
super().on_player_leave(player)
|
super().on_player_leave(player)
|
||||||
|
|||||||
@ -51,10 +51,14 @@ class ServerConfig:
|
|||||||
# exposed but I'll try to add that soon.
|
# exposed but I'll try to add that soon.
|
||||||
max_party_size: int = 6
|
max_party_size: int = 6
|
||||||
|
|
||||||
# Options here are 'ffa' (free-for-all) and 'teams'
|
# Options here are 'ffa' (free-for-all), 'teams' and 'coop' (cooperative)
|
||||||
# This value is ignored if you supply a playlist_code (see below).
|
# This value is ignored if you supply a playlist_code (see below).
|
||||||
session_type: str = 'ffa'
|
session_type: str = 'ffa'
|
||||||
|
|
||||||
|
# There are unavailable co-op playlists now, so if you want to host a co-op
|
||||||
|
# game, pass level name here.
|
||||||
|
coop_game_name: Optional[str] = None
|
||||||
|
|
||||||
# To host your own custom playlists, use the 'share' functionality in the
|
# To host your own custom playlists, use the 'share' functionality in the
|
||||||
# playlist editor in the regular version of the game.
|
# playlist editor in the regular version of the game.
|
||||||
# This will give you a numeric code you can enter here to host that
|
# This will give you a numeric code you can enter here to host that
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user