meta improvements

This commit is contained in:
Eric Froemling 2022-07-26 16:50:13 -07:00
parent ee5f5eef1d
commit 92ab8db91f
No known key found for this signature in database
GPG Key ID: 89C93F0F8D6D5A98
16 changed files with 355 additions and 284 deletions

View File

@ -3995,26 +3995,26 @@
"assets/src/ba_data/python/ba/_generated/__init__.py": "https://files.ballistica.net/cache/ba1/ee/e8/cad05aa531c7faf7ff7b96db7f6e",
"assets/src/ba_data/python/ba/_generated/enums.py": "https://files.ballistica.net/cache/ba1/b2/e5/0ee0561e16257a32830645239f34",
"ballisticacore-windows/Generic/BallisticaCore.ico": "https://files.ballistica.net/cache/ba1/89/c0/e32c7d2a35dc9aef57cc73b0911a",
"build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/36/14/efd9145a5e1562b04f6ff560e00d",
"build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/68/f3/376aecc59b69d288c276327f0a99",
"build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a7/c1/24da478bac1eb0d9e5090a37e2af",
"build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a6/e3/a453b14a443de5dc69e32d62b425",
"build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d2/1d/b1a59e26f6899e7779abb70e7c69",
"build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/62/f8/3798837a711a1e488f97e9d30fea",
"build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/ee/11/e183b40452ced610c0ca90f211b2",
"build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/2c/d1/2732b76fd513d1aed1ca0a9b9597",
"build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/92/26/bb135305ca78c79f03cc54aa8ed6",
"build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/03/8a/6fb8aa21f23580e331079c285bbf",
"build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/67/66/c70b8602a431689381516385ef4c",
"build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e6/e4/cad1d7bf7347a8f505fc9ed3d483",
"build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/75/a6/f2926b1c3083cde654e744fa7081",
"build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/2c/71/b8587bf758d8774dc5363e99c25b",
"build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/78/a8/eea0ff02bd7dfdac45f5e8bb1b91",
"build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/2e/cb/c100875d6d87b162c5ee5d8e7623",
"build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/51/06/f5d5e4167caf3c1920e041ce79ae",
"build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/89/fb/53cbead744939cbef30386a74b0b",
"build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/ec/f4/45308c7323f909964fb81ce0b2cc",
"build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/b0/f1/0e699be629f7936a67e7b336a383",
"build/prefab/full/linux_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/79/7a/c383c0a5aa5cd3a4e998ae2d607f",
"build/prefab/full/linux_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/52/36/3db8e70d87bc3399bdc39a26dea4",
"build/prefab/full/linux_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/26/d4/3209d789bbf8a73f4914aa7ce4d3",
"build/prefab/full/linux_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/59/e3/c2bc89f60961f4ed0708a5340ec8",
"build/prefab/full/linux_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d4/8e/f7cb156fb91af56adeb9ed08a76d",
"build/prefab/full/linux_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/44/88/634e154803570f4b977a51114706",
"build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/85/fd/5afc22c9dfe62bdbec27694b0076",
"build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a1/d0/0c906dd30d70314b13d4bbc6b5bf",
"build/prefab/full/mac_arm64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/32/2d/80933cdd46b3f8f5a41e431a8ac1",
"build/prefab/full/mac_arm64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/e3/1c/2cc790a44ed8abe0d7cab40a4fba",
"build/prefab/full/mac_arm64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/df/ab/9222758826b0a48a87abcaa1d4a6",
"build/prefab/full/mac_arm64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/77/f4/52b8e2abd145a276e614a9286dbe",
"build/prefab/full/mac_x86_64_gui/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/95/9b/58a4d68b9eaafefa0a108a5cf293",
"build/prefab/full/mac_x86_64_gui/release/ballisticacore": "https://files.ballistica.net/cache/ba1/cf/ab/572963d01007626cfa067ad750b4",
"build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/c5/6b/3a003ef79f24b9d4d7b2c2b71be2",
"build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/23/1c/4027cf14f282242f2ea8a11c684d",
"build/prefab/full/windows_x86_gui/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/3b/b9/d897246697814e97bd241909d78c",
"build/prefab/full/windows_x86_gui/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/11/2e/5019b997e6703cc65033a5ad9aaa",
"build/prefab/full/windows_x86_server/debug/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/9c/1f/c045bc85e0d062fbe496279ecea8",
"build/prefab/full/windows_x86_server/release/dist/BallisticaCoreHeadless.exe": "https://files.ballistica.net/cache/ba1/e2/c2/2bcbf1dcf557a21b82a8b91dc0a1",
"build/prefab/lib/linux_arm64_gui/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1a/6c/0c89987987ba2394da51b13d48cb",
"build/prefab/lib/linux_arm64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b8/b1/7ce57441f44a48185d1842cc954e",
"build/prefab/lib/linux_arm64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/0d/3e/77ed4e2948754ceb60353ab90091",
@ -4031,14 +4031,14 @@
"build/prefab/lib/mac_x86_64_gui/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/82/b5/e8a3fa193cff285aa20afeeae35f",
"build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/b5/a5/aba084a4026ebb1cd8ef8c141bc4",
"build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/1c/17/d35f66f340e9cddd948dcec5453d",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/b5/cd/789085462ab13214d6addbe06921",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/5a/98/cbca8986e08d5fcfdc7aeaef25e2",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/7a/2d/cef593252fbe3fddc22f6e788130",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/0a/30/b9b0d8629eb372e48cb0bc2d0241",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/5e/1f/86c5af445fe4c21e58ebcf5c7f39",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/b5/38/ac11b6c33d404a421e8adf354202",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/64/2d/2833633e1277af02c072b1cbc03c",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/bd/1c/36e1ee0184d510d548f002ddacfe",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/f1/c6/8faa5f4940ea61876f3e8e8dfb06",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/ed/c8/c72049370c7fa1f8ef703526b818",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/88/74/1404c5895de5eb6da05c32be5cd5",
"build/prefab/lib/windows/Debug_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/36/1b/2710d1d788bf5a6518f6092a9829",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.lib": "https://files.ballistica.net/cache/ba1/5e/8f/53b5139a47bd6d8b637b3e8c97c2",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreGenericInternal.pdb": "https://files.ballistica.net/cache/ba1/84/e3/f5b80f011508edfabc2e042ea356",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.lib": "https://files.ballistica.net/cache/ba1/a0/4b/f177f48bb3a0125c072079c561d6",
"build/prefab/lib/windows/Release_Win32/BallisticaCoreHeadlessInternal.pdb": "https://files.ballistica.net/cache/ba1/3c/57/39092393925cb999582a15023275",
"src/ballistica/generated/python_embedded/binding.inc": "https://files.ballistica.net/cache/ba1/7d/3e/229a581cb2454ed856f1d8b564a7",
"src/ballistica/generated/python_embedded/bootstrap.inc": "https://files.ballistica.net/cache/ba1/d3/db/e73d4dcf1280d5f677c3cf8b47c3"
}

View File

@ -799,9 +799,11 @@
<w>expectedsig</w>
<w>explodable</w>
<w>explodey</w>
<w>exportlist</w>
<w>exportoptions</w>
<w>exportoptionspath</w>
<w>exporttype</w>
<w>exporttypestr</w>
<w>extradata</w>
<w>extraflagmat</w>
<w>extrahash</w>
@ -1266,6 +1268,7 @@
<w>jsonutils</w>
<w>juleskie</w>
<w>kbclass</w>
<w>kbexports</w>
<w>kbytecount</w>
<w>keepalive</w>
<w>keepalives</w>
@ -1782,6 +1785,7 @@
<w>pathcapture</w>
<w>pathdst</w>
<w>pathlib</w>
<w>pathlist</w>
<w>pathnames</w>
<w>pathparts</w>
<w>pathsrc</w>

View File

@ -1,4 +1,7 @@
### 1.7.6 (build 20674, api 7, 2022-07-25)
### 1.7.6 (build 20676, api 7, 2022-07-26)
- Cleaned up MetaSubsystem code
- It is now possible to tell the meta system about arbitrary classes (ba_meta export foo.bar.Class) instead of just the preset types 'plugin', 'game', etc.
- Newly discovered plugins are now activated immediately instead of requiring a restart.
### 1.7.5 (build 20672, api 7, 2022-07-25)
- Android build now uses the ReLinker library to load the native main.so, which will (hopefully) avoid some random load failures on older Android versions.

View File

@ -223,6 +223,7 @@ class App:
self._launch_completed = False
self._initial_login_completed = False
self._meta_scan_completed = False
self._called_on_app_running = False
self._app_paused = False
@ -344,6 +345,8 @@ class App:
from bastd.actor import spazappearance
from ba._generated.enums import TimeType
assert _ba.in_game_thread()
self._aioloop = _asyncio.setup_asyncio()
cfg = self.config
@ -415,6 +418,9 @@ class App:
if not self.headless_mode:
_ba.timer(3.0, check_special_offer, timetype=TimeType.REAL)
# Get meta-system scanning built-in stuff in the bg.
self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete)
self.accounts_v2.on_app_launch()
self.accounts_v1.on_app_launch()
@ -429,17 +435,27 @@ class App:
def on_app_running(self) -> None:
"""Called when initially entering the running state."""
self.meta.on_app_running()
self.plugins.on_app_running()
# from ba._dependency import test_depset
# test_depset()
def on_meta_scan_complete(self) -> None:
"""Called by meta-scan when it is done doing its thing."""
assert _ba.in_game_thread()
self.plugins.on_meta_scan_complete()
assert not self._meta_scan_completed
self._meta_scan_completed = True
self._update_state()
def _update_state(self) -> None:
assert _ba.in_game_thread()
if self._app_paused:
self.state = self.State.PAUSED
else:
if self._initial_login_completed:
if self._initial_login_completed and self._meta_scan_completed:
self.state = self.State.RUNNING
if not self._called_on_app_running:
self._called_on_app_running = True
@ -650,5 +666,9 @@ class App:
This should also run after a short amount of time if no login
has occurred.
"""
# Tell meta it can start scanning extra stuff that just showed up
# (account workspaces).
self.meta.start_extra_scan()
self._initial_login_completed = True
self._update_state()

View File

@ -17,6 +17,7 @@ from ba._error import NotFoundError, print_error, print_exception
from ba._general import Call, WeakCall
from ba._player import PlayerInfo
from ba import _map
from ba import _store
if TYPE_CHECKING:
from typing import Any, Callable, Sequence
@ -1159,7 +1160,7 @@ class GameActivity(Activity[PlayerType, TeamType]):
else:
# If settings doesn't specify a map, pick a random one from the
# list of supported ones.
unowned_maps = _map.get_unowned_maps()
unowned_maps = _store.get_unowned_maps()
valid_maps: list[str] = [
m for m in self.get_supported_maps(type(self.session))
if m not in unowned_maps

View File

@ -399,3 +399,24 @@ def cameraflash(duration: float = 999.0) -> None:
light.node.delete,
timeformat=TimeFormat.MILLISECONDS)
activity.camera_flash_data.append(light) # type: ignore
def get_game_types() -> list[type[ba.GameActivity]]:
"""Return all available game types."""
# pylint: disable=cyclic-import
from ba._general import getclass
from ba._gameactivity import GameActivity
from ba._store import get_unowned_game_types
gameclassnames = _ba.app.meta.wait_for_scan_results().exports_of_class(
GameActivity)
gameclasses = []
for gameclassname in gameclassnames:
try:
cls = getclass(gameclassname, GameActivity)
gameclasses.append(cls)
except Exception:
from ba import _error
_error.print_exception('error importing ' + str(gameclassname))
unowned = get_unowned_game_types()
return [cls for cls in gameclasses if cls not in unowned]

View File

@ -101,22 +101,6 @@ def getmaps(playtype: str) -> list[str]:
if playtype in val.get_play_types())
def get_unowned_maps() -> list[str]:
"""Return the list of local maps not owned by the current account.
Category: **Asset Functions**
"""
from ba import _store
unowned_maps: set[str] = set()
if not _ba.app.headless_mode:
for map_section in _store.get_store_layout()['maps']:
for mapitem in map_section['items']:
if not _ba.get_purchased(mapitem):
m_info = _store.get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps)
def get_map_class(name: str) -> type[ba.Map]:
"""Return a map type given a name.

View File

@ -6,15 +6,16 @@ from __future__ import annotations
import os
import time
import threading
import logging
from threading import Thread
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypeVar
from dataclasses import dataclass, field
import _ba
if TYPE_CHECKING:
import ba
from typing import Callable
# The meta api version of this build of the game.
# Only packages and modules requiring this exact api version
@ -22,15 +23,28 @@ if TYPE_CHECKING:
# See: https://ballistica.net/wiki/Meta-Tags
CURRENT_API_VERSION = 7
# Meta export lines can use these names to represent these classes.
# This is purely a convenience; it is possible to use full class paths
# instead of these or to make the meta system aware of arbitrary classes.
EXPORT_CLASS_NAME_SHORTCUTS: dict[str, str] = {
'plugin': 'ba.Plugin',
'keyboard': 'ba.Keyboard',
'game': 'ba.GameActivity',
}
T = TypeVar('T')
@dataclass
class ScanResults:
"""Final results from a metadata scan."""
games: list[str] = field(default_factory=list)
plugins: list[str] = field(default_factory=list)
keyboards: list[str] = field(default_factory=list)
errors: str = ''
warnings: str = ''
"""Final results from a meta-scan."""
exports: dict[str, list[str]] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def exports_of_class(self, cls: type) -> list[str]:
"""Return exports of a given class."""
return self.exports.get(f'{cls.__module__}.{cls.__qualname__}', [])
class MetadataSubsystem:
@ -42,99 +56,52 @@ class MetadataSubsystem:
"""
def __init__(self) -> None:
self.scanresults: ScanResults | None = None
self._scan: DirectoryScan | None = None
# Can be populated before starting the scan.
self.extra_scan_dirs: list[str] = []
def on_app_running(self) -> None:
"""Should be called when the app enters the running state."""
# Results populated once scan is complete.
self.scanresults: ScanResults | None = None
# Start scanning for things exposed via ba_meta.
self.start_scan()
self._scan_complete_cb: Callable[[], None] | None = None
def start_scan(self) -> None:
"""Begin scanning script directories for scripts containing metadata.
def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
"""Begin the overall scan.
Should be called only once at launch."""
app = _ba.app
if self.scanresults is not None:
print('WARNING: meta scan run more than once.')
pythondirs = ([app.python_directory_app, app.python_directory_user] +
self.extra_scan_dirs)
thread = ScanThread(pythondirs)
thread.start()
This will start scanning built in directories (which for vanilla
installs should be the vast majority of the work). This should only
be called once.
"""
assert self._scan_complete_cb is None
assert self._scan is None
def handle_scan_results(self, results: ScanResults) -> None:
"""Called in the game thread with results of a completed scan."""
self._scan_complete_cb = scan_complete_cb
self._scan = DirectoryScan(
[_ba.app.python_directory_app, _ba.app.python_directory_user])
from ba._language import Lstr
from ba._plugin import PotentialPlugin
Thread(target=self._do_scan_dirs, daemon=True).start()
# Warnings generally only get printed locally for users' benefit
# (things like out-of-date scripts being ignored, etc.)
# Errors are more serious and will get included in the regular log
# warnings = results.get('warnings', '')
# errors = results.get('errors', '')
if results.warnings != '' or results.errors != '':
import textwrap
_ba.screenmessage(Lstr(resource='scanScriptsErrorText'),
color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
if results.warnings != '':
_ba.log(textwrap.indent(results.warnings,
'Warning (meta-scan): '),
to_server=False)
if results.errors != '':
_ba.log(textwrap.indent(results.errors, 'Error (meta-scan): '))
def start_extra_scan(self) -> None:
"""Provide extra dirs to be scanned (namely Workspace dirs).
# Handle plugins.
plugs = _ba.app.plugins
config_changed = False
found_new = False
plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {})
assert isinstance(plugstates, dict)
This is the bare minimum part of the scan that must be delayed until
workspaces have been synced/etc. This must be called exactly once.
"""
assert self._scan is not None
self._scan.set_extras(self.extra_scan_dirs)
# Create a potential-plugin for each class we found in the scan.
for class_path in results.plugins:
plugs.potential_plugins.append(
PotentialPlugin(display_name=Lstr(value=class_path),
class_path=class_path,
available=True))
if class_path not in plugstates:
# Go ahead and enable new plugins by default, but we'll
# inform the user that they need to restart to pick them up.
# they can also disable them in settings so they never load.
plugstates[class_path] = {'enabled': True}
config_changed = True
found_new = True
# Let the game know we're done.
assert self._scan_complete_cb is not None
self._scan_complete_cb()
# Also add a special one for any plugins set to load but *not* found
# in the scan (this way they will show up in the UI so we can disable
# them)
for class_path, plugstate in plugstates.items():
enabled = plugstate.get('enabled', False)
assert isinstance(enabled, bool)
if enabled and class_path not in results.plugins:
plugs.potential_plugins.append(
PotentialPlugin(display_name=Lstr(value=class_path),
class_path=class_path,
available=False))
plugs.potential_plugins.sort(key=lambda p: p.class_path)
if found_new:
_ba.screenmessage(Lstr(resource='pluginsDetectedText'),
color=(0, 1, 0))
_ba.playsound(_ba.getsound('ding'))
if config_changed:
_ba.app.config.commit()
def get_scan_results(self) -> ScanResults:
"""Return meta scan results; block if the scan is not yet complete."""
def wait_for_scan_results(self) -> ScanResults:
"""Return scan results, blocking if the scan is not yet complete."""
if self.scanresults is None:
print('WARNING: ba.meta.get_scan_results()'
' called before scan completed.'
' This can cause hitches.')
logging.warning('ba.meta.wait_for_scan_results()'
' called before scan completed;'
' this can cause hitches.')
# Now wait a bit for the scan to complete.
# Eventually error though if it doesn't.
@ -146,69 +113,49 @@ class MetadataSubsystem:
'timeout waiting for meta scan to complete.')
return self.scanresults
def get_game_types(self) -> list[type[ba.GameActivity]]:
"""Return available game types."""
from ba._general import getclass
from ba._gameactivity import GameActivity
gameclassnames = self.get_scan_results().games
gameclasses = []
for gameclassname in gameclassnames:
try:
cls = getclass(gameclassname, GameActivity)
gameclasses.append(cls)
except Exception:
from ba import _error
_error.print_exception('error importing ' + str(gameclassname))
unowned = self.get_unowned_game_types()
return [cls for cls in gameclasses if cls not in unowned]
def _handle_scan_results(self) -> None:
"""Called in the logic thread with results of a completed scan."""
from ba._language import Lstr
assert _ba.in_game_thread()
def get_unowned_game_types(self) -> set[type[ba.GameActivity]]:
"""Return present game types not owned by the current account."""
results = self.scanresults
assert results is not None
# Spit out any warnings/errors that happened.
# Warnings generally only get printed locally for users' benefit
# (things like out-of-date scripts being ignored, etc.)
# Errors are more serious and will get included in the regular log.
if results.warnings or results.errors:
import textwrap
_ba.screenmessage(Lstr(resource='scanScriptsErrorText'),
color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
if results.warnings:
_ba.log(textwrap.indent('\n'.join(results.warnings),
'Warning (meta-scan): '),
to_server=False)
if results.errors:
_ba.log(
textwrap.indent('\n'.join(results.errors),
'Error (meta-scan): '))
def _do_scan_dirs(self) -> None:
"""Runs a scan (for use in background thread)."""
try:
from ba import _store
unowned_games: set[type[ba.GameActivity]] = set()
if not _ba.app.headless_mode:
for section in _store.get_store_layout()['minigames']:
for mname in section['items']:
if not _ba.get_purchased(mname):
m_info = _store.get_store_item(mname)
unowned_games.add(m_info['gametype'])
return unowned_games
except Exception:
from ba import _error
_error.print_exception('error calcing un-owned games')
return set()
class ScanThread(threading.Thread):
"""Thread to scan script dirs for metadata."""
def __init__(self, dirs: list[str]):
super().__init__()
self._dirs = dirs
def run(self) -> None:
from ba._general import Call
try:
scan = DirectoryScan(self._dirs)
scan.scan()
results = scan.results
assert self._scan is not None
self._scan.run()
results = self._scan.results
self._scan = None
except Exception as exc:
results = ScanResults(errors=f'Scan exception: {exc}')
results = ScanResults(errors=[f'Scan exception: {exc}'])
# Push a call to the game thread to print warnings/errors
# or otherwise deal with scan results.
_ba.pushcall(Call(_ba.app.meta.handle_scan_results, results),
from_other_thread=True)
# We also, however, immediately make results available.
# This is because the game thread may be blocked waiting
# for them so we can't push a call or we'd get deadlock.
_ba.app.meta.scanresults = results
# Place results and tell the logic thread they're ready.
self.scanresults = results
_ba.pushcall(self._handle_scan_results, from_other_thread=True)
class DirectoryScan:
"""Handles scanning directories for metadata."""
"""Scans directories for metadata."""
def __init__(self, paths: list[str]):
"""Given one or more paths, parses available meta information.
@ -218,9 +165,42 @@ class DirectoryScan:
"""
# Skip non-existent paths completely.
self.paths = [Path(p) for p in paths if os.path.isdir(p)]
self.base_paths = [Path(p) for p in paths if os.path.isdir(p)]
self.extra_paths: list[Path] = []
self.extra_paths_set = False
self.results = ScanResults()
def set_extras(self, paths: list[str]) -> None:
"""Set extra portion."""
# Skip non-existent paths completely.
self.extra_paths += [Path(p) for p in paths if os.path.isdir(p)]
self.extra_paths_set = True
def run(self) -> None:
"""Do the thing."""
for pathlist in [self.base_paths, self.extra_paths]:
# Spin and wait until extra paths are provided before doing them.
if pathlist is self.extra_paths:
while not self.extra_paths_set:
time.sleep(0.001)
modules: list[tuple[Path, Path]] = []
for path in pathlist:
self._get_path_module_entries(path, '', modules)
for moduledir, subpath in modules:
try:
self._scan_module(moduledir, subpath)
except Exception:
import traceback
self.results.warnings.append(
f"Error scanning '{subpath}': " +
traceback.format_exc())
# Sort our results
for exportlist in self.results.exports.values():
exportlist.sort()
def _get_path_module_entries(self, path: Path, subpath: str | Path,
modules: list[tuple[Path, Path]]) -> None:
"""Scan provided path and add module entries to provided list."""
@ -235,7 +215,7 @@ class DirectoryScan:
entries = []
except Exception as exc:
# Unexpected; report this.
self.results.errors += f'{exc}\n'
self.results.errors.append(str(exc))
entries = []
# Now identify python packages/modules out of what we found.
@ -246,24 +226,7 @@ class DirectoryScan:
and Path(entry[0], entry[1], '__init__.py').is_file()):
modules.append(entry)
def scan(self) -> None:
"""Scan provided paths."""
modules: list[tuple[Path, Path]] = []
for path in self.paths:
self._get_path_module_entries(path, '', modules)
for moduledir, subpath in modules:
try:
self.scan_module(moduledir, subpath)
except Exception:
import traceback
self.results.warnings += ("Error scanning '" + str(subpath) +
"': " + traceback.format_exc() +
'\n')
# Sort our results
self.results.games.sort()
self.results.plugins.sort()
def scan_module(self, moduledir: Path, subpath: Path) -> None:
def _scan_module(self, moduledir: Path, subpath: Path) -> None:
"""Scan an individual module and add the findings to results."""
if subpath.name.endswith('.py'):
fpath = Path(moduledir, subpath)
@ -277,19 +240,20 @@ class DirectoryScan:
lnum: l[1:].split()
for lnum, l in enumerate(flines) if '# ba_meta ' in l
}
toplevel = len(subpath.parts) <= 1
required_api = self.get_api_requirement(subpath, meta_lines, toplevel)
is_top_level = len(subpath.parts) <= 1
required_api = self._get_api_requirement(subpath, meta_lines,
is_top_level)
# Top level modules with no discernible api version get ignored.
if toplevel and required_api is None:
if is_top_level and required_api is None:
return
# If we find a module requiring a different api version, warn
# and ignore.
if required_api is not None and required_api != CURRENT_API_VERSION:
self.results.warnings += (
self.results.warnings.append(
f'Warning: {subpath} requires api {required_api} but'
f' we are running {CURRENT_API_VERSION}; ignoring module.\n')
f' we are running {CURRENT_API_VERSION}; ignoring module.')
return
# Ok; can proceed with a full scan of this module.
@ -302,11 +266,11 @@ class DirectoryScan:
self._get_path_module_entries(moduledir, subpath, submodules)
for submodule in submodules:
if submodule[1].name != '__init__.py':
self.scan_module(submodule[0], submodule[1])
self._scan_module(submodule[0], submodule[1])
except Exception:
import traceback
self.results.warnings += (
f"Error scanning '{subpath}': {traceback.format_exc()}\n")
self.results.warnings.append(
f"Error scanning '{subpath}': {traceback.format_exc()}")
def _process_module_meta_tags(self, subpath: Path, flines: list[str],
meta_lines: dict[int, list[str]]) -> None:
@ -315,10 +279,9 @@ class DirectoryScan:
# meta_lines is just anything containing '# ba_meta '; make sure
# the ba_meta is in the right place.
if mline[0] != 'ba_meta':
self.results.warnings += (
'Warning: ' + str(subpath) +
': malformed ba_meta statement on line ' +
str(lindex + 1) + '.\n')
self.results.warnings.append(
f'Warning: {subpath}:'
f' malformed ba_meta statement on line {lindex + 1}.')
elif (len(mline) == 4 and mline[1] == 'require'
and mline[2] == 'api'):
# Ignore 'require api X' lines in this pass.
@ -326,31 +289,28 @@ class DirectoryScan:
elif len(mline) != 3 or mline[1] != 'export':
# Currently we only support 'ba_meta export FOO';
# complain for anything else we see.
self.results.warnings += (
'Warning: ' + str(subpath) +
': unrecognized ba_meta statement on line ' +
str(lindex + 1) + '.\n')
self.results.warnings.append(
f'Warning: {subpath}'
f': unrecognized ba_meta statement on line {lindex + 1}.')
else:
# Looks like we've got a valid export line!
modulename = '.'.join(subpath.parts)
if subpath.name.endswith('.py'):
modulename = modulename[:-3]
exporttype = mline[2]
exporttypestr = mline[2]
export_class_name = self._get_export_class_name(
subpath, flines, lindex)
if export_class_name is not None:
classname = modulename + '.' + export_class_name
if exporttype == 'game':
self.results.games.append(classname)
elif exporttype == 'plugin':
self.results.plugins.append(classname)
elif exporttype == 'keyboard':
self.results.keyboards.append(classname)
else:
self.results.warnings += (
'Warning: ' + str(subpath) +
': unrecognized export type "' + exporttype +
'" on line ' + str(lindex + 1) + '.\n')
# If export type is one of our shortcuts, sub in the
# actual class path. Otherwise assume its a classpath
# itself.
exporttype = EXPORT_CLASS_NAME_SHORTCUTS.get(exporttypestr)
if exporttype is None:
exporttype = exporttypestr
self.results.exports.setdefault(exporttype,
[]).append(classname)
def _get_export_class_name(self, subpath: Path, lines: list[str],
lindex: int) -> str | None:
@ -372,13 +332,12 @@ class DirectoryScan:
classname = cbits[0]
break # Success!
if classname is None:
self.results.warnings += (
'Warning: ' + str(subpath) + ': class definition not found'
' below "ba_meta export" statement on line ' +
str(lindexorig + 1) + '.\n')
self.results.warnings.append(
f'Warning: {subpath}: class definition not found below'
f' "ba_meta export" statement on line {lindexorig + 1}.')
return classname
def get_api_requirement(
def _get_api_requirement(
self,
subpath: Path,
meta_lines: dict[int, list[str]],
@ -399,15 +358,15 @@ class DirectoryScan:
# Ok; not successful. lets issue warnings for a few error cases.
if len(lines) > 1:
self.results.warnings += (
'Warning: ' + str(subpath) +
': multiple "# ba_meta require api <NUM>" lines found;'
' ignoring module.\n')
self.results.warnings.append(
f'Warning: {subpath}: multiple'
' "# ba_meta require api <NUM>" lines found;'
' ignoring module.')
elif not lines and toplevel and meta_lines:
# If we're a top-level module containing meta lines but
# no valid "require api" line found, complain.
self.results.warnings += (
'Warning: ' + str(subpath) +
': no valid "# ba_meta require api <NUM>" line found;'
' ignoring module.\n')
self.results.warnings.append(
f'Warning: {subpath}:'
' no valid "# ba_meta require api <NUM>" line found;'
' ignoring module.')
return None

View File

@ -29,14 +29,15 @@ def filter_playlist(playlist: PlaylistType,
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
import _ba
from ba import _map
from ba import _general
from ba import _gameactivity
from ba._map import get_filtered_map_name
from ba._store import get_unowned_maps, get_unowned_game_types
from ba._general import getclass
from ba._gameactivity import GameActivity
goodlist: list[dict] = []
unowned_maps: Sequence[str]
if remove_unowned or mark_unowned:
unowned_maps = _map.get_unowned_maps()
unowned_game_types = _ba.app.meta.get_unowned_game_types()
unowned_maps = get_unowned_maps()
unowned_game_types = get_unowned_game_types()
else:
unowned_maps = []
unowned_game_types = set()
@ -53,7 +54,7 @@ def filter_playlist(playlist: PlaylistType,
del entry['map']
# Update old map names to new ones.
entry['settings']['map'] = _map.get_filtered_map_name(
entry['settings']['map'] = get_filtered_map_name(
entry['settings']['map'])
if remove_unowned and entry['settings']['map'] in unowned_maps:
continue
@ -120,8 +121,7 @@ def filter_playlist(playlist: PlaylistType,
entry['type'] = (
'bastd.game.targetpractice.TargetPracticeGame')
gameclass = _general.getclass(entry['type'],
_gameactivity.GameActivity)
gameclass = getclass(entry['type'], GameActivity)
if remove_unowned and gameclass in unowned_game_types:
continue

View File

@ -25,6 +25,46 @@ class PluginSubsystem:
self.potential_plugins: list[ba.PotentialPlugin] = []
self.active_plugins: dict[str, ba.Plugin] = {}
def on_meta_scan_complete(self) -> None:
"""Should be called when meta-scanning is complete."""
from ba._language import Lstr
plugs = _ba.app.plugins
config_changed = False
found_new = False
plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {})
assert isinstance(plugstates, dict)
results = _ba.app.meta.scanresults
assert results is not None
# Create a potential-plugin for each class we found in the scan.
for class_path in results.exports_of_class(Plugin):
plugs.potential_plugins.append(
PotentialPlugin(display_name=Lstr(value=class_path),
class_path=class_path,
available=True))
if class_path not in plugstates:
# Go ahead and enable new plugins by default, but we'll
# inform the user that they need to restart to pick them up.
# they can also disable them in settings so they never load.
plugstates[class_path] = {'enabled': True}
config_changed = True
found_new = True
plugs.potential_plugins.sort(key=lambda p: p.class_path)
# Note: these days we complete meta-scan and immediately activate
# plugins, so we don't need the message about 'restart to activate'
# anymore.
if found_new and bool(False):
_ba.screenmessage(Lstr(resource='pluginsDetectedText'),
color=(0, 1, 0))
_ba.playsound(_ba.getsound('ding'))
if config_changed:
_ba.app.config.commit()
def on_app_running(self) -> None:
"""Should be called when the app reaches the running state."""
# Load up our plugins and go ahead and call their on_app_running calls.
@ -69,10 +109,7 @@ class PluginSubsystem:
from ba._language import Lstr
# Note: the plugins we load is purely based on what's enabled
# in the app config. Our meta-scan gives us a list of available
# plugins, but that is only used to give the user a list of plugins
# that they can enable. (we wouldn't want to look at meta-scan here
# anyway because it may not be done yet at this point in the launch)
# in the app config. Its not our job to look at meta stuff here.
plugstates: dict[str, dict] = _ba.app.config.get('Plugins', {})
assert isinstance(plugstates, dict)
plugkeys: list[str] = sorted(key for key, val in plugstates.items()

View File

@ -509,3 +509,35 @@ def get_available_sale_time(tab: str) -> int | None:
from ba import _error
_error.print_exception('error calcing sale time')
return None
def get_unowned_maps() -> list[str]:
"""Return the list of local maps not owned by the current account.
Category: **Asset Functions**
"""
unowned_maps: set[str] = set()
if not _ba.app.headless_mode:
for map_section in get_store_layout()['maps']:
for mapitem in map_section['items']:
if not _ba.get_purchased(mapitem):
m_info = get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps)
def get_unowned_game_types() -> set[type[ba.GameActivity]]:
"""Return present game types not owned by the current account."""
try:
unowned_games: set[type[ba.GameActivity]] = set()
if not _ba.app.headless_mode:
for section in get_store_layout()['minigames']:
for mname in section['items']:
if not _ba.get_purchased(mname):
m_info = get_store_item(mname)
unowned_games.add(m_info['gametype'])
return unowned_games
except Exception:
from ba import _error
_error.print_exception('error calcing un-owned games')
return set()

View File

@ -7,9 +7,8 @@ or disappear without warning, so should be avoided (or used sparingly and
defensively) in mods.
"""
from ba._map import (get_unowned_maps, get_map_class, register_map,
preload_map_preview_media, get_map_display_string,
get_filtered_map_name)
from ba._map import (get_map_class, register_map, preload_map_preview_media,
get_map_display_string, get_filtered_map_name)
from ba._appconfig import commit_app_config
from ba._input import (get_device_value, get_input_map_hash,
get_input_device_config)
@ -34,13 +33,14 @@ from ba._playlist import (get_default_free_for_all_playlist,
from ba._store import (get_available_sale_time, get_available_purchase_count,
get_store_item_name_translated,
get_store_item_display_size, get_store_layout,
get_store_item, get_clean_price)
get_store_item, get_clean_price, get_unowned_maps,
get_unowned_game_types)
from ba._tournament import get_tournament_prize_strings
from ba._gameutils import get_trophy_string
from ba._gameutils import get_trophy_string, get_game_types
__all__ = [
'get_unowned_maps', 'get_map_class', 'register_map',
'preload_map_preview_media', 'get_map_display_string',
'get_unowned_maps', 'get_unowned_game_types', 'get_map_class',
'register_map', 'preload_map_preview_media', 'get_map_display_string',
'get_filtered_map_name', 'commit_app_config', 'get_device_value',
'get_input_map_hash', 'get_input_device_config', 'getclass', 'json_prep',
'get_type_name', 'JoinActivity', 'ScoreScreenActivity',
@ -56,5 +56,6 @@ __all__ = [
'get_default_teams_playlist', 'filter_playlist', 'get_available_sale_time',
'get_available_purchase_count', 'get_store_item_name_translated',
'get_store_item_display_size', 'get_store_layout', 'get_store_item',
'get_clean_price', 'get_tournament_prize_strings', 'get_trophy_string'
'get_clean_price', 'get_tournament_prize_strings', 'get_trophy_string',
'get_game_types'
]

View File

@ -213,8 +213,10 @@ class OnScreenKeyboardWindow(ba.Window):
# Show change instructions only if we have more than one
# keyboard option.
if (ba.app.meta.scanresults is not None
and len(ba.app.meta.scanresults.keyboards) > 1):
keyboards = (ba.app.meta.scanresults.exports_of_class(
ba.Keyboard) if ba.app.meta.scanresults is not None
else [])
if len(keyboards) > 1:
ba.textwidget(
parent=self._root_widget,
h_align='center',
@ -239,7 +241,8 @@ class OnScreenKeyboardWindow(ba.Window):
def _get_keyboard(self) -> ba.Keyboard:
assert ba.app.meta.scanresults is not None
classname = ba.app.meta.scanresults.keyboards[self._keyboard_index]
classname = ba.app.meta.scanresults.exports_of_class(
ba.Keyboard)[self._keyboard_index]
kbclass = ba.getclass(classname, ba.Keyboard)
return kbclass()
@ -318,10 +321,11 @@ class OnScreenKeyboardWindow(ba.Window):
def _next_keyboard(self) -> None:
assert ba.app.meta.scanresults is not None
self._keyboard_index = (self._keyboard_index + 1) % len(
ba.app.meta.scanresults.keyboards)
kbexports = ba.app.meta.scanresults.exports_of_class(ba.Keyboard)
self._keyboard_index = (self._keyboard_index + 1) % len(kbexports)
self._load_keyboard()
if len(ba.app.meta.scanresults.keyboards) < 2:
if len(kbexports) < 2:
ba.playsound(ba.getsound('error'))
ba.screenmessage(ba.Lstr(resource='keyboardNoOthersAvailableText'),
color=(1, 0, 0))

View File

@ -119,6 +119,7 @@ class PlaylistAddGameWindow(ba.Window):
self._refresh()
def _refresh(self, select_get_more_games_button: bool = False) -> None:
from ba.internal import get_game_types
if self._column is not None:
self._column.delete()
@ -128,8 +129,8 @@ class PlaylistAddGameWindow(ba.Window):
margin=0)
gametypes = [
gt for gt in ba.app.meta.get_game_types() if
gt.supports_session_type(self._editcontroller.get_session_type())
gt for gt in get_game_types() if gt.supports_session_type(
self._editcontroller.get_session_type())
]
# Sort in the current language.

View File

@ -412,6 +412,8 @@
<w>expbool</w>
<w>expectedsig</w>
<w>expl</w>
<w>exportlist</w>
<w>exporttypestr</w>
<w>extradata</w>
<w>extrahash</w>
<w>extrascale</w>
@ -653,6 +655,7 @@
<w>jnames</w>
<w>json's</w>
<w>juleskie</w>
<w>kbexports</w>
<w>keepalives</w>
<w>kerploople</w>
<w>keyanntype</w>
@ -931,6 +934,7 @@
<w>passcode</w>
<w>pathcapture</w>
<w>pathdst</w>
<w>pathlist</w>
<w>pathparts</w>
<w>pathsrc</w>
<w>pausable</w>

View File

@ -21,7 +21,7 @@
namespace ballistica {
// These are set automatically via script; don't modify them here.
const int kAppBuildNumber = 20674;
const int kAppBuildNumber = 20676;
const char* kAppVersion = "1.7.6";
// Our standalone globals.