Initial commit

This commit is contained in:
vortex 2024-02-26 00:17:10 +05:30
parent bc49523c99
commit 44d606cce7
1929 changed files with 612166 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality and data common to ballistica client and server components."""

Binary file not shown.

64
dist/ba_data/python/bacommon/assets.py vendored Normal file
View file

@ -0,0 +1,64 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to cloud based assets."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated
from enum import Enum
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
pass
class AssetPackageFlavor(Enum):
"""Flavors for asset package outputs for different platforms/etc."""
# DXT3/DXT5 textures
DESKTOP = 'desktop'
# ASTC textures
MOBILE = 'mobile'
class AssetType(Enum):
"""Types for individual assets within a package."""
TEXTURE = 'texture'
CUBE_TEXTURE = 'cube_texture'
SOUND = 'sound'
DATA = 'data'
MESH = 'mesh'
COLLISION_MESH = 'collision_mesh'
@ioprepped
@dataclass
class AssetPackageFlavorManifest:
"""A manifest of asset info for a specific flavor of an asset package."""
cloudfiles: Annotated[dict[str, str], IOAttrs('cloudfiles')] = field(
default_factory=dict
)
@ioprepped
@dataclass
class AssetPackageBuildState:
"""Contains info about an in-progress asset cloud build."""
# Asset names still being built.
in_progress_builds: Annotated[list[str], IOAttrs('b')] = field(
default_factory=list
)
# The initial number of assets needing to be built.
initial_build_count: Annotated[int, IOAttrs('c')] = 0
# Build error string. If this is present, it should be presented
# to the user and they should required to explicitly restart the build
# in some way if desired.
error: Annotated[str | None, IOAttrs('e')] = None

106
dist/ba_data/python/bacommon/bacloud.py vendored Normal file
View file

@ -0,0 +1,106 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the bacloud tool."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
pass
# Version is sent to the master-server with all commands. Can be incremented
# if we need to change behavior server-side to go along with client changes.
BACLOUD_VERSION = 8
@ioprepped
@dataclass
class RequestData:
"""Request sent to bacloud server."""
command: Annotated[str, IOAttrs('c')]
token: Annotated[str | None, IOAttrs('t')]
payload: Annotated[dict, IOAttrs('p')]
tzoffset: Annotated[float, IOAttrs('z')]
isatty: Annotated[bool, IOAttrs('y')]
@ioprepped
@dataclass
class ResponseData:
# noinspection PyUnresolvedReferences
"""Response sent from the bacloud server to the client.
Attributes:
message: If present, client should print this message before any other
response processing (including error handling) occurs.
message_end: end arg for message print() call.
error: If present, client should abort with this error message.
delay_seconds: How long to wait before proceeding with remaining
response (can be useful when waiting for server progress in a loop).
login: If present, a token that should be stored client-side and passed
with subsequent commands.
logout: If True, any existing client-side token should be discarded.
dir_manifest: If present, client should generate a manifest of this dir.
It should be added to end_command args as 'manifest'.
uploads: If present, client should upload the requested files (arg1)
individually to a server command (arg2) with provided args (arg3).
uploads_inline: If present, a list of pathnames that should be base64
gzipped and uploaded to an 'uploads_inline' dict in end_command args.
This should be limited to relatively small files.
deletes: If present, file paths that should be deleted on the client.
downloads_inline: If present, pathnames mapped to base64 gzipped data to
be written to the client. This should only be used for relatively
small files as they are all included inline as part of the response.
dir_prune_empty: If present, all empty dirs under this one should be
removed.
open_url: If present, url to display to the user.
input_prompt: If present, a line of input is read and placed into
end_command args as 'input'. The first value is the prompt printed
before reading and the second is whether it should be read as a
password (without echoing to the terminal).
end_message: If present, a message that should be printed after all other
response processing is done.
end_message_end: end arg for end_message print() call.
end_command: If present, this command is run with these args at the end
of response processing.
"""
message: Annotated[str | None, IOAttrs('m', store_default=False)] = None
message_end: Annotated[str, IOAttrs('m_end', store_default=False)] = '\n'
error: Annotated[str | None, IOAttrs('e', store_default=False)] = None
delay_seconds: Annotated[float, IOAttrs('d', store_default=False)] = 0.0
login: Annotated[str | None, IOAttrs('l', store_default=False)] = None
logout: Annotated[bool, IOAttrs('lo', store_default=False)] = False
dir_manifest: Annotated[
str | None, IOAttrs('man', store_default=False)
] = None
uploads: Annotated[
tuple[list[str], str, dict] | None, IOAttrs('u', store_default=False)
] = None
uploads_inline: Annotated[
list[str] | None, IOAttrs('uinl', store_default=False)
] = None
deletes: Annotated[
list[str] | None, IOAttrs('dlt', store_default=False)
] = None
downloads_inline: Annotated[
dict[str, str] | None, IOAttrs('dinl', store_default=False)
] = None
dir_prune_empty: Annotated[
str | None, IOAttrs('dpe', store_default=False)
] = None
open_url: Annotated[str | None, IOAttrs('url', store_default=False)] = None
input_prompt: Annotated[
tuple[str, bool] | None, IOAttrs('inp', store_default=False)
] = None
end_message: Annotated[
str | None, IOAttrs('em', store_default=False)
] = None
end_message_end: Annotated[str, IOAttrs('eme', store_default=False)] = '\n'
end_command: Annotated[
tuple[str, dict] | None, IOAttrs('ec', store_default=False)
] = None

35
dist/ba_data/python/bacommon/build.py vendored Normal file
View file

@ -0,0 +1,35 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to game builds."""
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class BuildInfoSet:
"""Set of build infos."""
@dataclass
class Entry:
"""Info about a particular build."""
filename: Annotated[str, IOAttrs('fname')]
size: Annotated[int, IOAttrs('size')]
version: Annotated[str, IOAttrs('version')]
build_number: Annotated[int, IOAttrs('build')]
checksum: Annotated[str, IOAttrs('checksum')]
createtime: Annotated[datetime.datetime, IOAttrs('createtime')]
builds: Annotated[list[Entry], IOAttrs('builds')] = field(
default_factory=list
)

218
dist/ba_data/python/bacommon/cloud.py vendored Normal file
View file

@ -0,0 +1,218 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to cloud functionality."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated
from enum import Enum
from efro.message import Message, Response
from efro.dataclassio import ioprepped, IOAttrs
from bacommon.transfer import DirectoryManifest
from bacommon.login import LoginType
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class LoginProxyRequestMessage(Message):
"""Request send to the cloud to ask for a login-proxy."""
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [LoginProxyRequestResponse]
@ioprepped
@dataclass
class LoginProxyRequestResponse(Response):
"""Response to a request for a login proxy."""
# URL to direct the user to for login.
url: Annotated[str, IOAttrs('u')]
# Proxy-Login id for querying results.
proxyid: Annotated[str, IOAttrs('p')]
# Proxy-Login key for querying results.
proxykey: Annotated[str, IOAttrs('k')]
@ioprepped
@dataclass
class LoginProxyStateQueryMessage(Message):
"""Soo.. how is that login proxy going?"""
proxyid: Annotated[str, IOAttrs('p')]
proxykey: Annotated[str, IOAttrs('k')]
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [LoginProxyStateQueryResponse]
@ioprepped
@dataclass
class LoginProxyStateQueryResponse(Response):
"""Here's the info on that login-proxy you asked about, boss."""
class State(Enum):
"""States a login-proxy can be in."""
WAITING = 'waiting'
SUCCESS = 'success'
FAIL = 'fail'
state: Annotated[State, IOAttrs('s')]
# On success, these will be filled out.
credentials: Annotated[str | None, IOAttrs('tk')]
@ioprepped
@dataclass
class LoginProxyCompleteMessage(Message):
"""Just so you know, we're done with this proxy."""
proxyid: Annotated[str, IOAttrs('p')]
@ioprepped
@dataclass
class PingMessage(Message):
"""Standard ping."""
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [PingResponse]
@ioprepped
@dataclass
class PingResponse(Response):
"""pong."""
@ioprepped
@dataclass
class TestMessage(Message):
"""Can I get some of that workspace action?"""
testfoo: Annotated[int, IOAttrs('f')]
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [TestResponse]
@ioprepped
@dataclass
class TestResponse(Response):
"""Here's that workspace you asked for, boss."""
testfoo: Annotated[int, IOAttrs('f')]
@ioprepped
@dataclass
class WorkspaceFetchState:
"""Common state data for a workspace fetch."""
manifest: Annotated[DirectoryManifest, IOAttrs('m')]
iteration: Annotated[int, IOAttrs('i')] = 0
total_deletes: Annotated[int, IOAttrs('tdels')] = 0
total_downloads: Annotated[int, IOAttrs('tdlds')] = 0
total_up_to_date: Annotated[int | None, IOAttrs('tunmd')] = None
@ioprepped
@dataclass
class WorkspaceFetchMessage(Message):
"""Can I get some of that workspace action?"""
workspaceid: Annotated[str, IOAttrs('w')]
state: Annotated[WorkspaceFetchState, IOAttrs('s')]
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [WorkspaceFetchResponse]
@ioprepped
@dataclass
class WorkspaceFetchResponse(Response):
"""Here's that workspace you asked for, boss."""
state: Annotated[WorkspaceFetchState, IOAttrs('s')]
deletes: Annotated[list[str], IOAttrs('dlt', store_default=False)] = field(
default_factory=list
)
downloads_inline: Annotated[
dict[str, bytes], IOAttrs('dinl', store_default=False)
] = field(default_factory=dict)
done: Annotated[bool, IOAttrs('d')] = False
@ioprepped
@dataclass
class MerchAvailabilityMessage(Message):
"""Can we show merch link?"""
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [MerchAvailabilityResponse]
@ioprepped
@dataclass
class MerchAvailabilityResponse(Response):
"""About that merch..."""
url: Annotated[str | None, IOAttrs('u')]
@ioprepped
@dataclass
class SignInMessage(Message):
"""Can I sign in please?"""
login_type: Annotated[LoginType, IOAttrs('l')]
sign_in_token: Annotated[str, IOAttrs('t')]
# For debugging. Can remove soft_default once build 20988+ is ubiquitous.
description: Annotated[str, IOAttrs('d', soft_default='-')]
apptime: Annotated[float, IOAttrs('at', soft_default=-1.0)]
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [SignInResponse]
@ioprepped
@dataclass
class SignInResponse(Response):
"""Here's that sign-in result you asked for, boss."""
credentials: Annotated[str | None, IOAttrs('c')]
@ioprepped
@dataclass
class ManageAccountMessage(Message):
"""Message asking for a manage-account url."""
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ManageAccountResponse]
@ioprepped
@dataclass
class ManageAccountResponse(Response):
"""Here's that sign-in result you asked for, boss."""
url: Annotated[str | None, IOAttrs('u')]

18
dist/ba_data/python/bacommon/err.py vendored Normal file
View file

@ -0,0 +1,18 @@
# Released under the MIT License. See LICENSE for details.
#
"""Error related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class RemoteError(Exception):
"""An error occurred on the other end of some connection."""
def __str__(self) -> str:
s = ''.join(str(arg) for arg in self.args)
return f'Remote Exception Follows:\n{s}'

31
dist/ba_data/python/bacommon/login.py vendored Normal file
View file

@ -0,0 +1,31 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to cloud based assets."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class LoginType(Enum):
"""Types of logins available."""
# Email/password
EMAIL = 'email'
# Google Play Game Services
GPGS = 'gpgs'
@property
def displayname(self) -> str:
"""Human readable name for this value."""
cls = type(self)
match self:
case cls.EMAIL:
return 'Email/Password'
case cls.GPGS:
return 'Google Play Games'

83
dist/ba_data/python/bacommon/net.py vendored Normal file
View file

@ -0,0 +1,83 @@
# Released under the MIT License. See LICENSE for details.
#
"""Network related data and functionality."""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any, Annotated
from dataclasses import dataclass, field
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class ServerNodeEntry:
"""Information about a specific server."""
zone: Annotated[str, IOAttrs('r')]
address: Annotated[str, IOAttrs('a')]
port: Annotated[int, IOAttrs('p')]
@ioprepped
@dataclass
class ServerNodeQueryResponse:
"""A response to a query about server-nodes."""
# The current utc time on the master server.
time: Annotated[datetime.datetime, IOAttrs('t')]
# If present, something went wrong, and this describes it.
error: Annotated[str | None, IOAttrs('e', store_default=False)] = None
# The set of servernodes.
servers: Annotated[
list[ServerNodeEntry], IOAttrs('s', store_default=False)
] = field(default_factory=list)
@ioprepped
@dataclass
class PrivateHostingState:
"""Combined state of whether we're hosting, whether we can, etc."""
unavailable_error: str | None = None
party_code: str | None = None
tickets_to_host_now: int = 0
minutes_until_free_host: float | None = None
free_host_minutes_remaining: float | None = None
@ioprepped
@dataclass
class PrivateHostingConfig:
"""Config provided when hosting a private party."""
session_type: str = 'ffa'
playlist_name: str = 'Unknown'
randomize: bool = False
tutorial: bool = False
custom_team_names: tuple[str, str] | None = None
custom_team_colors: tuple[
tuple[float, float, float], tuple[float, float, float]
] | None = None
playlist: list[dict[str, Any]] | None = None
exit_minutes: float = 120.0
exit_minutes_unclean: float = 180.0
exit_minutes_idle: float = 10.0
@ioprepped
@dataclass
class PrivatePartyConnectResult:
"""Info about a server we get back when connecting."""
error: str | None = None
addr: str | None = None
port: int | None = None
password: str | None = None

View file

@ -0,0 +1,206 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the server manager script."""
from __future__ import annotations
from enum import Enum
from dataclasses import field, dataclass
from typing import TYPE_CHECKING, Any
from efro.dataclassio import ioprepped
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class ServerConfig:
"""Configuration for the server manager app (<appname>_server)."""
# Name of our server in the public parties list.
party_name: str = 'FFA'
# If True, your party will show up in the global public party list
# Otherwise it will still be joinable via LAN or connecting by IP address.
party_is_public: bool = True
# If True, all connecting clients will be authenticated through the master
# server to screen for fake account info. Generally this should always
# be enabled unless you are hosting on a LAN with no internet connection.
authenticate_clients: bool = True
# IDs of server admins. Server admins are not kickable through the default
# kick vote system and they are able to kick players without a vote. To get
# your account id, enter 'getaccountid' in settings->advanced->enter-code.
admins: list[str] = field(default_factory=list)
# Whether the default kick-voting system is enabled.
enable_default_kick_voting: bool = True
# UDP port to host on. Change this to work around firewalls or run multiple
# servers on one machine.
# 43210 is the default and the only port that will show up in the LAN
# browser tab.
port: int = 43222
# Max devices in the party. Note that this does *NOT* mean max players.
# Any device in the party can have more than one player on it if they have
# multiple controllers. Also, this number currently includes the server so
# generally make it 1 bigger than you need. Max-players is not currently
# exposed but I'll try to add that soon.
max_party_size: int = 6
# Options here are 'ffa' (free-for-all), 'teams' and 'coop' (cooperative)
# This value is ignored if you supply a playlist_code (see below).
session_type: str = 'ffa'
# Playlist-code for teams or free-for-all mode sessions.
# To host your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game.
# This will give you a numeric code you can enter here to host that
# playlist.
playlist_code: int | None = None
# Alternately, you can embed playlist data here instead of using codes.
# Make sure to set session_type to the correct type for the data here.
playlist_inline: list[dict[str, Any]] | None = None
# Whether to shuffle the playlist or play its games in designated order.
playlist_shuffle: bool = True
# If True, keeps team sizes equal by disallowing joining the largest team
# (teams mode only).
auto_balance_teams: bool = True
# The campaign used when in co-op session mode.
# Do print(ba.app.campaigns) to see available campaign names.
coop_campaign: str = 'Easy'
# The level name within the campaign used in co-op session mode.
# For campaign name FOO, do print(ba.app.campaigns['FOO'].levels) to see
# available level names.
coop_level: str = 'Onslaught Training'
# Whether to enable telnet access.
# IMPORTANT: This option is no longer available, as it was being used
# for exploits. Live access to the running server is still possible through
# the mgr.cmd() function in the server script. Run your server through
# tools such as 'screen' or 'tmux' and you can reconnect to it remotely
# over a secure ssh connection.
enable_telnet: bool = False
# Series length in teams mode (7 == 'best-of-7' series; a team must
# get 4 wins)
teams_series_length: int = 7
# Points to win in free-for-all mode (Points are awarded per game based on
# performance)
ffa_series_length: int = 24
# If you have a custom stats webpage for your server, you can use this
# to provide a convenient in-game link to it in the server-browser
# alongside the server name.
# if ${ACCOUNT} is present in the string, it will be replaced by the
# currently-signed-in account's id. To fetch info about an account,
# your back-end server can use the following url:
# https://legacy.ballistica.net/accountquery?id=ACCOUNT_ID_HERE
stats_url: str | None = None
# If present, the server subprocess will attempt to gracefully exit after
# this amount of time. A graceful exit can occur at the end of a series
# or other opportune time. Server-managers set to auto-restart (the
# default) will then spin up a fresh subprocess. This mechanism can be
# useful to clear out any memory leaks or other accumulated bad state
# in the server subprocess.
clean_exit_minutes: float | None = None
# If present, the server subprocess will shut down immediately after this
# amount of time. This can be useful as a fallback for clean_exit_time.
# The server manager will then spin up a fresh server subprocess if
# auto-restart is enabled (the default).
unclean_exit_minutes: float | None = None
# If present, the server subprocess will shut down immediately if this
# amount of time passes with no activity from any players. The server
# manager will then spin up a fresh server subprocess if auto-restart is
# enabled (the default).
idle_exit_minutes: float | None = None
# Should the tutorial be shown at the beginning of games?
show_tutorial: bool = False
# Team names (teams mode only).
team_names: tuple[str, str] | None = None
# Team colors (teams mode only).
team_colors: tuple[
tuple[float, float, float], tuple[float, float, float]
] | None = None
# Whether to enable the queue where players can line up before entering
# your server. Disabling this can be used as a workaround to deal with
# queue spamming attacks.
enable_queue: bool = True
# (internal) stress-testing mode.
stress_test_players: int | None = None
# NOTE: as much as possible, communication from the server-manager to the
# child-process should go through these and not ad-hoc Python string commands
# since this way is type safe.
class ServerCommand:
"""Base class for commands that can be sent to the server."""
@dataclass
class StartServerModeCommand(ServerCommand):
"""Tells the app to switch into 'server' mode."""
config: ServerConfig
class ShutdownReason(Enum):
"""Reason a server is shutting down."""
NONE = 'none'
RESTARTING = 'restarting'
@dataclass
class ShutdownCommand(ServerCommand):
"""Tells the server to shut down."""
reason: ShutdownReason
immediate: bool
@dataclass
class ChatMessageCommand(ServerCommand):
"""Chat message from the server."""
message: str
clients: list[int] | None
@dataclass
class ScreenMessageCommand(ServerCommand):
"""Screen-message from the server."""
message: str
color: tuple[float, float, float] | None
clients: list[int] | None
@dataclass
class ClientListCommand(ServerCommand):
"""Print a list of clients."""
@dataclass
class KickCommand(ServerCommand):
"""Kick a client."""
client_id: int
ban_time: int | None

103
dist/ba_data/python/bacommon/transfer.py vendored Normal file
View file

@ -0,0 +1,103 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to transferring files/data."""
from __future__ import annotations
import os
from pathlib import Path
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class DirectoryManifestFile:
"""Describes metadata and hashes for a file in a manifest."""
filehash: Annotated[str, IOAttrs('h')]
filesize: Annotated[int, IOAttrs('s')]
@ioprepped
@dataclass
class DirectoryManifest:
"""Contains a summary of files in a directory."""
files: Annotated[dict[str, DirectoryManifestFile], IOAttrs('f')]
_empty_hash: str | None = None
@classmethod
def create_from_disk(cls, path: Path) -> DirectoryManifest:
"""Create a manifest from a directory on disk."""
import hashlib
from concurrent.futures import ThreadPoolExecutor
pathstr = str(path)
paths: list[str] = []
if path.is_dir():
# Build the full list of relative paths.
for basename, _dirnames, filenames in os.walk(path):
for filename in filenames:
fullname = os.path.join(basename, filename)
assert fullname.startswith(pathstr)
# Make sure we end up with forward slashes no matter
# what the os.* stuff above here was using.
paths.append(Path(fullname[len(pathstr) + 1 :]).as_posix())
elif path.exists():
# Just return a single file entry if path is not a dir.
paths.append(path.as_posix())
def _get_file_info(filepath: str) -> tuple[str, DirectoryManifestFile]:
sha = hashlib.sha256()
fullfilepath = os.path.join(pathstr, filepath)
if not os.path.isfile(fullfilepath):
raise Exception(f'File not found: "{fullfilepath}"')
with open(fullfilepath, 'rb') as infile:
filebytes = infile.read()
filesize = len(filebytes)
sha.update(filebytes)
return (
filepath,
DirectoryManifestFile(
filehash=sha.hexdigest(), filesize=filesize
),
)
# Now use all procs to hash the files efficiently.
cpus = os.cpu_count()
if cpus is None:
cpus = 4
with ThreadPoolExecutor(max_workers=cpus) as executor:
return cls(files=dict(executor.map(_get_file_info, paths)))
def validate(self) -> None:
"""Log any odd data in the manifest; for debugging."""
import logging
for fpath, _fentry in self.files.items():
# We want to be dealing in only forward slashes; make sure
# that's the case (wondering if we'll ever see backslashes
# for escape purposes).
if '\\' in fpath:
logging.exception(
"Found unusual path in manifest: '%s'.", fpath
)
break # 1 error is enough for now.
@classmethod
def get_empty_hash(cls) -> str:
"""Return the hash for an empty file."""
if cls._empty_hash is None:
import hashlib
sha = hashlib.sha256()
cls._empty_hash = sha.hexdigest()
return cls._empty_hash