Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
|
df37890dcf | ||
|
1963ec5946 | ||
|
7137ce1b49 |
14 changed files with 3651 additions and 2 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,4 +1,8 @@
|
|||
lina.log
|
||||
__pycache__
|
||||
constants.py
|
||||
venv
|
||||
stkserver
|
||||
logs
|
||||
config.json
|
||||
extensions
|
||||
lina.log
|
||||
|
|
10
bot.py
10
bot.py
|
@ -19,6 +19,7 @@ log = logging.getLogger("lina.main")
|
|||
if TYPE_CHECKING:
|
||||
from cogs import PlayerTrack
|
||||
from cogs import Online
|
||||
from third_party.stkwrapper.stkserver_wrapper import STKServer
|
||||
|
||||
extensions = (
|
||||
"cogs.online",
|
||||
|
@ -37,6 +38,7 @@ class Lina(commands.Bot):
|
|||
"""
|
||||
|
||||
pool: asyncpg.Pool
|
||||
stkserver: STKServer
|
||||
|
||||
def __init__(self):
|
||||
|
||||
|
@ -199,7 +201,10 @@ class Lina(commands.Bot):
|
|||
finally:
|
||||
await self.session.close()
|
||||
|
||||
await super().close()
|
||||
try:
|
||||
await self.stkserver.stop()
|
||||
finally:
|
||||
await super().close()
|
||||
|
||||
async def start(self):
|
||||
"""Bring lina to life"""
|
||||
|
@ -219,3 +224,6 @@ class Lina(commands.Bot):
|
|||
async def on_ready(self):
|
||||
log.info(f"Bot {self.user} ({self.user.id}) is ready!")
|
||||
self.stkPoll.start()
|
||||
log.info("Starting verification server.")
|
||||
await self.stkserver.launch()
|
||||
|
||||
|
|
12
cogs/core.py
12
cogs/core.py
|
@ -77,5 +77,17 @@ class Core(commands.Cog):
|
|||
await ctx.reply("Shutting down :wave:")
|
||||
await self.bot.close()
|
||||
|
||||
@commands.command(hidden=True)
|
||||
async def shutdownverificationserver(self, ctx: commands.Context):
|
||||
self.bot.stkserver.restart = False
|
||||
await ctx.reply("Shutting down verification server.")
|
||||
await self.bot.stkserver.stop()
|
||||
|
||||
@commands.command(hidden=True)
|
||||
async def restartverificationserver(self, ctx: commands.Context):
|
||||
self.bot.stkserver.restart = True
|
||||
await ctx.reply("Restarting verification server.")
|
||||
await self.bot.stkserver.stop()
|
||||
|
||||
async def setup(bot: Lina):
|
||||
await bot.add_cog(Core(bot))
|
||||
|
|
34
cogs/stubs.py
Normal file
34
cogs/stubs.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bot import Lina
|
||||
|
||||
|
||||
class Stub(commands.Cog):
|
||||
|
||||
def __init__(self, bot: Lina):
|
||||
self.bot: Lina = bot
|
||||
|
||||
def noticeEmbed(self, name: str):
|
||||
return discord.Embed(
|
||||
description=(
|
||||
"Please use slash command `{}` to execute this command.".format(name) # noqa
|
||||
),
|
||||
color=self.bot.accent_color
|
||||
)
|
||||
|
||||
@commands.command(name="trackuser", aliases=["stk-trackuser-dm"])
|
||||
async def stub_trackuser(self, ctx: commands.Context):
|
||||
|
||||
return await ctx.reply(
|
||||
embed=self.noticeEmbed("trackuser"), mention_author=False
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot: Lina):
|
||||
await bot.add_cog(Stub(bot))
|
14
launch.py
14
launch.py
|
@ -6,6 +6,8 @@ import asyncpg
|
|||
import contextlib
|
||||
import discord
|
||||
import logging
|
||||
import os
|
||||
from third_party.stkwrapper import stkserver_wrapper
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
class RemoveNoise(logging.Filter):
|
||||
|
@ -100,6 +102,18 @@ async def runBot():
|
|||
|
||||
async with Lina() as lina:
|
||||
lina.pool = pool
|
||||
lina.stkserver = stkserver_wrapper.STKServer(
|
||||
logger=log,
|
||||
cwd=os.getcwd() + "/stkserver",
|
||||
autostart=False,
|
||||
extra_args=("--disable-addon-karts","--disable-addon-tracks"),
|
||||
datapath="/usr/share/supertuxkart",
|
||||
executable_path="/usr/bin/supertuxkart",
|
||||
name="linaSTK Verification Server",
|
||||
cfgpath=os.getcwd() + "/stkserver/config.xml",
|
||||
writeln=lambda _: None,
|
||||
extra_env={"XDG_DATA_DIRS": ""}
|
||||
)
|
||||
await lina.start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
160
third_party/stkwrapper/.gitignore
vendored
Normal file
160
third_party/stkwrapper/.gitignore
vendored
Normal file
|
@ -0,0 +1,160 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
504
third_party/stkwrapper/LICENSE
vendored
Normal file
504
third_party/stkwrapper/LICENSE
vendored
Normal file
|
@ -0,0 +1,504 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
|
||||
USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random
|
||||
Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
193
third_party/stkwrapper/README.md
vendored
Normal file
193
third_party/stkwrapper/README.md
vendored
Normal file
|
@ -0,0 +1,193 @@
|
|||
# stkwrapper
|
||||
SuperTuxKart Server written in Python with CLI interface. Supports autorestarting, extensions and automatic add-on downloading/upgrading.
|
||||
|
||||
Tested on STK 1.4 git version.
|
||||
|
||||
## Compatibility notice
|
||||
Please note that older STK versions won't work properly because of [this](https://github.com/supertuxkart/stk-code/pull/4871) issue in the official STK code that is only resolved in the git version.
|
||||
|
||||
|
||||
# Requirements
|
||||
These Python packages must be present in the system. Install it with your preferred way (system package manager if you are running Linux/Unix, or pip inside a virtual environment):
|
||||
```
|
||||
packaging
|
||||
defusedxml
|
||||
emoji
|
||||
git+https://github.com/NobWow/admin-console-python.git
|
||||
```
|
||||
Otherwise you can install all the requirements with `pip install -r requirements.txt`
|
||||
-------------------------------------------------------------------------------------
|
||||
# Brief tutorial
|
||||
## CLI
|
||||
The wrapper utilizes [admin-console-python](https://github.com/NobWow/admin-console-python) package for the interface. Therefore, to list all the commands you can either use tab completion or enter `help` command.
|
||||
## Default STK configuration
|
||||
`stkw_advanced` extension requires `extensions/stkdefault.xml` config file to be present.
|
||||
It's a default STK server configuration file (however it is not copied to the server directory when server is created, it is a known issue)
|
||||
Copy the contents from [NETWORKING.md](https://github.com/supertuxkart/stk-code/blob/master/NETWORKING.md) or use a different of your preferences.
|
||||
## Automatic Add-On Updater
|
||||
Firstly, make sure to configure it through `extensions/stkswrapper.conf`. Here is an example configuration:
|
||||
```ini
|
||||
[AddonUpdater]
|
||||
online_assets_url = https://online.supertuxkart.net/downloads/xml/online_assets.xml
|
||||
# Since 1.4 servers support add-on karts.
|
||||
fetch_karts = True
|
||||
autoupdate = True
|
||||
autoupdate_interval = 21600
|
||||
# These add-ons won't be updated. For example:
|
||||
autoupdate_banlist = kart_corner, soccer-arena
|
||||
# If this option is True, it will install new addons that meet the requirements
|
||||
autoinstall = False
|
||||
autoinstall_karts = True
|
||||
# Addons below this rating won't be installed. Specify value between 0.0 and 5.0
|
||||
autoinstall_minrating = 1.0
|
||||
# Specify the requirements for the addons to be autoinstalled separated with commas.
|
||||
# Prefix + means that the flag must be present, and - makes sure that it does not install the add-ons with this flag.
|
||||
# Supported flags are: APPROVED ALPHA BETA RC INVISIBLE HQ DFSG FEATURED LATEST BAD_DIM
|
||||
autoinstall_requirements = +APPROVED,+DFSG,-ALPHA
|
||||
# These add-ons won't be installed automatically. For example:
|
||||
autoinstall_banlist = kart_corner, soccer-arena
|
||||
# Directory to temporarily download zip files
|
||||
downloadpath = downloads
|
||||
# Absolute path to the addons directory. Replace ~ with your home directory
|
||||
addonpath = ~/.local/share/supertuxkart/addons
|
||||
```
|
||||
---
|
||||
## Server creation
|
||||
Make sure you have a compiled binary of SuperTuxKart and followed all the instructions in [INSTALL.md](https://github.com/supertuxkart/stk-code/blob/master/INSTALL.md) and [NETWORKING.md](https://github.com/supertuxkart/stk-code/blob/master/NETWORKING.md), except the server launching command as it is handled by the wrapper.
|
||||
You must keep stk-code available, as it is required to run a binary.
|
||||
Once everything STK related is ready, start the wrapper:
|
||||
```
|
||||
python stkserver_wrapper.py
|
||||
```
|
||||
Wrapper supports interactive server creation to make it easier. Dispatch this command:
|
||||
```
|
||||
stk-make-server
|
||||
```
|
||||
It will guide you through the server creation process.
|
||||
Step 1: it will prompt for the server name that will be used for commands. Let's say it will be `tutorial`
|
||||
```
|
||||
-=STK=-: stk-make-server
|
||||
Enter the server name. It will be used for further interaction with the server,
|
||||
You cannot change it later
|
||||
but every name should be unique.
|
||||
name: tutorial
|
||||
```
|
||||
Step 2: working directory for the server process. Useful in some cases. Let's use the default value `./tutorial` just by pressing Enter in this case:
|
||||
```
|
||||
Enter the path to server's working directory.
|
||||
You can change it later
|
||||
Current working directory: "/home/user/stkwrapper" for relative path reference
|
||||
Press return to skip and set the default value "/home/user/stkwrapper/tutorial"
|
||||
cwd:
|
||||
```
|
||||
Additionally, if this directory doesn't exist, which is likely, it will ask you to create this directory. Press `y` to confirm:
|
||||
```
|
||||
This directory doesn't exist. Create one?
|
||||
create dir "/home/user/stkwrapper/tutorial"? y
|
||||
```
|
||||
Step 3: server configuration file path. It will be passed as `--server-config=` argument to the server process as noted in [NETWORKING.md](https://github.com/supertuxkart/stk-code/blob/master/NETWORKING.md). Let's specify `server.xml`:
|
||||
```
|
||||
Directory created for server
|
||||
Enter the path to configuration file. It must have the XML format (.xml).
|
||||
You can change it later
|
||||
Current working directory: "/home/user/stkwrapper/tutorial" for relative path reference
|
||||
Press return to skip and set the default value ""
|
||||
cfgpath: server.xml
|
||||
```
|
||||
If the file doesn't exist, it will notice it and suggest to correct the path. You can skip this step to let the server create the configuraton file by hitting `y`. If you hit `n` it will repeat the step above. Let's skip this warning:
|
||||
```
|
||||
This file doesn't exist. Skip?
|
||||
skip? y
|
||||
```
|
||||
Step 4: path to the directory that contains `data/`. It is required for every SuperTuxKart instance. If SuperTuxKart is installed in the system, the path could be `/usr/share/supertuxkart` or `/usr/local/share/supertuxkart`. If you built STK from the source and did not run `sudo make install` without deleting the sources, specify the path to the source code directory. Let's say that the supertuxkart repository is cloned to the home directory: `~/stk-code`
|
||||
```
|
||||
Enter the path to the "data" directory that will be used for the new server.
|
||||
You can change it later
|
||||
Current working directory: "/home/user/tutorial" for relative path reference
|
||||
Press return to skip and set the default value "stk-code"
|
||||
TIP: it is usually either /usr/share/supertuxkart
|
||||
or in case of GIT version /path/to/stk-code
|
||||
datapath: ~/stk-code
|
||||
```
|
||||
Step 5: Specify where the executable file is. It is either at `/usr/bin/supertuxkart` or `/usr/local/bin/supertuxkart` depending on your STK installation. In this tutorial, it is assumed that the STK has been built from the sources inside the `~/stk-code/build/` directory. So let's say that the executable is at `~/stk-code/build/bin/supertuxkart`:
|
||||
```
|
||||
Enter the path to supertuxkart executable file (program).
|
||||
You can change it later
|
||||
Current working directory: "/home/user/tutorial" for relative path reference
|
||||
Press return to skip and set the default value "supertuxkart"
|
||||
exec: ~/stk-code/build/bin/supertuxkart
|
||||
```
|
||||
Next steps will be purely wrapper-related.
|
||||
Step 6: whether or not the server is started when wrapper is started. Let's hit the `y` key so you don't have to manually start the server with `stk-start tutorial`:
|
||||
```
|
||||
Should server automatically start after wrapper has been launched? Hit y for yes or n for no.
|
||||
You can change it later
|
||||
autostart? y
|
||||
```
|
||||
Step 7: whether or not enable autorestart on server crash. Let's hit the `y` key to make sure that the server is back online when something happens.
|
||||
```
|
||||
In case the server crashes, does it require automatic restart? Enter yes or no.
|
||||
You can change it later
|
||||
autorestart? y
|
||||
```
|
||||
Step 8: forced interval-based autorestart timer. Let's leave the empty prompt and just hit Enter:
|
||||
```
|
||||
Is it needed to restart the server every N minutes? Leave empty string or 0 if autorestarts aren't required.
|
||||
You can change it later
|
||||
Note: the server will not restart if there are players at the moment
|
||||
timed autorestart minutes (or empty):
|
||||
```
|
||||
Step 9: how much time should the server not exceed when starting up? Let's leave the default value (120 seconds) by hitting Enter:
|
||||
```
|
||||
How many seconds the server has to initialize? When this timeout exceeds during server startup, the process is killed.
|
||||
Current working directory: "/home/user" for relative path reference
|
||||
Press return to skip and set the default value "120.0"
|
||||
startup timeout (n.n):
|
||||
```
|
||||
Step 10: same as the above, but when the server is shutting down. The default value can be too high in most cases so let's specify `6.0` seconds:
|
||||
```
|
||||
How many seconds the server has to shutdown?When this timeout exceeds during server shutdown, the process is killed.
|
||||
Current working directory: "/home/user" for relative path reference
|
||||
Press return to skip and set the default value "120.0"
|
||||
shutdown timeout (n.n): 6.0
|
||||
```
|
||||
**Next steps are for advanced administrators**
|
||||
Step 11: specify additional environment variables. Useful for ranked servers or for servers that has a separate addon setup. Let's specify `-`:
|
||||
```
|
||||
Advanced: which additional environment variables to pass to the process?
|
||||
For example, you can specify XDG_DATA_HOME=/path/to/directory HOME=/some/directory/path
|
||||
You can change it later
|
||||
To clear extra argument, specify -
|
||||
extra environment variables: -
|
||||
```
|
||||
Step 12: additional command line arguments to be passed to the process. Let's specify `-`:
|
||||
```
|
||||
Advanced: any additional arguments to the command line? Just leave it empty if you have no idea.
|
||||
You can change it later
|
||||
To clear extra argument, specify -
|
||||
extra args: -
|
||||
```
|
||||
|
||||
Final step: the server is ready to be started:
|
||||
```
|
||||
Server successfully created. Start it right now?
|
||||
start tutorial?
|
||||
```
|
||||
Hit `y` to start the server immediately or `n` to skip and start it manually later.
|
||||
|
||||
## Starting, restarting and stopping the server
|
||||
To start the server, execute `stk-start server_name`, for example `stk-start tutorial` if you did the tutorial above.
|
||||
To stop the server, execute `stk-stop server_name`. This command will make sure that there are no online players. To forcefully stop the server use `stk-stop server_name yes`
|
||||
Restart command combines both of them: `stk-restart server_name`
|
||||
|
||||
If the wrapper is closed with `Ctrl+C` or `exit` command, it will stop all the servers forcefully **without checking if there are players online**. Be careful with that!
|
||||
|
||||
## Executing network console commands
|
||||
You can send one line with `stk-cmd server_name line...` where `line...` is a command (spaces are allowed)
|
||||
Alternatively, you can enter the network console mode with `stk-nc server_name` and send commands directly to the server, when done enter `.quit` to return to the normal command prompt.
|
||||
|
||||
## Undocumented
|
||||
Even this short brief tutorial is quite big, so, here are the features that are undocumented:
|
||||
* patterns for ignoring logs.
|
||||
* configuration editing commands.
|
||||
* `stk-enhance` command.
|
781
third_party/stkwrapper/extensions/addon_updater.py
vendored
Normal file
781
third_party/stkwrapper/extensions/addon_updater.py
vendored
Normal file
|
@ -0,0 +1,781 @@
|
|||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import logging
|
||||
from configparser import ConfigParser
|
||||
from shutil import rmtree
|
||||
from zipfile import ZipFile
|
||||
from math import floor
|
||||
from aiohttp import ClientSession, ClientResponse
|
||||
from admin_console import AdminCommandExecutor, AdminCommandExtension, paginate_range
|
||||
from aiohndchain import AIOHandlerChain
|
||||
from defusedxml import ElementTree as dElementTree
|
||||
from xml.etree import ElementTree
|
||||
from enum import Flag, Enum
|
||||
from itertools import islice, repeat, chain, count
|
||||
from packaging.version import parse as parseVersion, InvalidVersion
|
||||
from typing import Sequence, Tuple, MutableSequence
|
||||
|
||||
|
||||
black_star = chr(9733)
|
||||
white_star = chr(9734)
|
||||
dot = '.'
|
||||
kart_l = 'kart'
|
||||
track_l = 'track'
|
||||
soccer_l = 'soccer'
|
||||
arena_l = 'arena'
|
||||
ctf_l = 'ctf'
|
||||
false_l = 'false'
|
||||
Y_l = 'Y'
|
||||
N_l = 'N'
|
||||
empty_str = ''
|
||||
delimiter = re.compile(r'[,./;: ] *')
|
||||
nostatus = re.compile(r'(?:-)[a-zA-Z_]+')
|
||||
status = re.compile(r'(?:\+)?[a-zA-Z_]+')
|
||||
stripper = re.compile(r'[+-]')
|
||||
heavy_check_sign = chr(9989)
|
||||
defaultconf = {
|
||||
'AddonUpdater': {
|
||||
'online_assets_url': 'https://online.supertuxkart.net/downloads/xml/online_assets.xml',
|
||||
'fetch_karts': False,
|
||||
'autoupdate': True,
|
||||
'autoupdate_interval': 3600 * 6,
|
||||
'autoupdate_banlist': "",
|
||||
'autoinstall': True,
|
||||
'autoinstall_karts': False,
|
||||
'autoinstall_minrating': 1.0,
|
||||
'autoinstall_requirements': "+APPROVED,+DFSG,-ALPHA",
|
||||
'autoinstall_banlist': "",
|
||||
'downloadpath': 'downloads',
|
||||
'addonpath': os.path.expanduser('~/.local/share/supertuxkart/addons')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AddonStatus(Flag):
|
||||
APPROVED = 0x0001
|
||||
ALPHA = 0x0002
|
||||
BETA = 0x0004
|
||||
RC = 0x0008
|
||||
INVISIBLE = 0x0010
|
||||
HQ = 0x0020
|
||||
DFSG = 0x0040
|
||||
FEATURED = 0x0080
|
||||
LATEST = 0x0100
|
||||
BAD_DIM = 0x0200
|
||||
|
||||
def _predicate(self, item: Enum):
|
||||
return bool(self.value & item.value)
|
||||
|
||||
def _strict_predicate(self, item: Enum):
|
||||
return self.value & item.value == self.value
|
||||
|
||||
def _xml_predicate(self, item: ElementTree.Element):
|
||||
return self.value & int(item.attrib['status']) == self.value
|
||||
|
||||
@staticmethod
|
||||
def _xml_allowdeny_predicate(triple: Tuple[ElementTree.Element, int, int]):
|
||||
element, allow, deny = triple
|
||||
status_ = int(element.attrib['status'])
|
||||
if status_ & deny:
|
||||
return False
|
||||
return status_ & allow
|
||||
|
||||
def __iter__(self):
|
||||
return filter(self._predicate, self.__class__.__members__.values())
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, flags: str):
|
||||
flags_ = stripper.sub('', flags.upper())
|
||||
return cls(sum(cls[item].value for item in delimiter.split(flags_)))
|
||||
|
||||
@classmethod
|
||||
def allowdeny_pair(cls, flags: str) -> Tuple[int, int]:
|
||||
flags_ = delimiter.split(flags.upper())
|
||||
return cls.strlist_allowed(flags_), cls.strlist_denied(flags_)
|
||||
|
||||
@classmethod
|
||||
def strlist_allowed(cls, flags: Sequence[str]) -> int:
|
||||
return sum(cls[stripper.sub('', item)].value for item in filter(status.fullmatch, flags)) or sum(x.value for x in cls)
|
||||
|
||||
@classmethod
|
||||
def strlist_denied(cls, flags: Sequence[str]) -> int:
|
||||
return sum(cls[stripper.sub('', item)].value for item in filter(nostatus.fullmatch, flags))
|
||||
|
||||
|
||||
def ratingStars(rating: float) -> str:
|
||||
return ''.join(islice(chain(repeat(black_star, int(rating * 2)), repeat(white_star)), 6))
|
||||
|
||||
|
||||
def flags(value: str):
|
||||
return ', '.join(item.name.title() for item in AddonStatus(int(value)))
|
||||
|
||||
|
||||
def classifyAddon(addonInfo: ElementTree.Element) -> Sequence[str]:
|
||||
if addonInfo.tag == track_l:
|
||||
tags = []
|
||||
_track = True
|
||||
if addonInfo.attrib.get(soccer_l, N_l) == Y_l:
|
||||
tags.append(soccer_l)
|
||||
_track = False
|
||||
if addonInfo.attrib.get(arena_l, N_l) == Y_l:
|
||||
tags.append(arena_l)
|
||||
_track = False
|
||||
if addonInfo.attrib.get(ctf_l, N_l) == Y_l:
|
||||
tags.append(ctf_l)
|
||||
if _track:
|
||||
tags.append(track_l)
|
||||
return tags
|
||||
if addonInfo.attrib.get(ctf_l, N_l) == Y_l:
|
||||
return [addonInfo.tag, ctf_l]
|
||||
return [addonInfo.tag]
|
||||
|
||||
|
||||
def tab_complete_addontype(arg: str):
|
||||
_res = []
|
||||
if track_l.startswith(arg):
|
||||
_res.append(track_l)
|
||||
if soccer_l.startswith(arg):
|
||||
_res.append(soccer_l)
|
||||
if arena_l.startswith(arg):
|
||||
_res.append(arena_l)
|
||||
if ctf_l.startswith(arg):
|
||||
_res.append(ctf_l)
|
||||
return _res
|
||||
|
||||
|
||||
def tab_complete_onlineaddon(ext: AdminCommandExtension, arg: str):
|
||||
def _predicate(target: str):
|
||||
return target.startswith(arg)
|
||||
return list(filter(_predicate, ext.data['addons_dict'].keys()))
|
||||
|
||||
|
||||
def tab_complete_installedaddon(ext: AdminCommandExtension, arg: str):
|
||||
def _predicate(target: str):
|
||||
return target.startswith(arg)
|
||||
return list(filter(_predicate, ext.data['installed_dict'].keys()))
|
||||
|
||||
|
||||
async def fetch(ext: AdminCommandExtension, check_updates=True):
|
||||
fetch_karts = ext.mconfig.getboolean('fetch_karts')
|
||||
if 'addons_tree' not in ext.data:
|
||||
ext.logger.info('Fetching online_addons for the first time...')
|
||||
else:
|
||||
ext.logger.info('Fetching online_addons again...')
|
||||
online_assets_url = ext.mconfig['online_assets_url']
|
||||
clientsession: ClientSession = ext.clientsession
|
||||
async with clientsession.get(online_assets_url) as resp:
|
||||
data = await resp.text()
|
||||
element = dElementTree.fromstring(data)
|
||||
tree = ElementTree.ElementTree(element)
|
||||
ext.data['addons_tree'] = tree
|
||||
addons_dict = ext.data['addons_dict'] = {}
|
||||
ext.logger.info(f'Fetched data size is {len(data)}, {len(element)} entries')
|
||||
if check_updates and 'installed_dict' not in ext.data:
|
||||
ext.logger.warning("Cannot check for updates: installed addons haven't been checked before fetching online addons")
|
||||
check_updates = False
|
||||
updates_available = None
|
||||
elif check_updates and 'updates_available' not in ext.data:
|
||||
updates_available = ext.data['updates_available'] = []
|
||||
elif check_updates:
|
||||
updates_available = ext.data['updates_available']
|
||||
else:
|
||||
return
|
||||
for i in range(len(element) - 1, -1, -1):
|
||||
try:
|
||||
addon = element[i]
|
||||
_rawMinVer = addon.attrib['min-include-version']
|
||||
_rawMaxVer = addon.attrib['max-include-version']
|
||||
_curVer = ext.ace.stuff['stk_version']
|
||||
_minVer = parseVersion(_rawMinVer) if _rawMinVer else _curVer
|
||||
_maxVer = parseVersion(_rawMaxVer) if _rawMaxVer else _curVer
|
||||
if (_minVer > _curVer and _rawMinVer) or (_rawMaxVer and _curVer > _maxVer):
|
||||
del element[i]
|
||||
ext.logger.debug(f'Found incompatible addon "{addon.attrib["name"]}" id={addon.attrib["id"]} which has version {_minVer}-{_maxVer}')
|
||||
continue
|
||||
if addon.tag == 'kart' and not fetch_karts:
|
||||
del element[i]
|
||||
ext.logger.debug(f'Skipping kart "{addon.attrib["name"]} id={addon.attrib["id"]}"')
|
||||
continue
|
||||
# benau moment...
|
||||
# if AddonStatus.LATEST not in AddonStatus(int(addon.attrib['status'])):
|
||||
# ext.logger.debug(f'Skipping non-latest "{addon.attrib["name"]} id={addon.attrib["id"]}"')
|
||||
# del element[i]
|
||||
# continue
|
||||
# instead, just manually check revision number
|
||||
if addon.attrib['id'] in addons_dict:
|
||||
taddon = addons_dict[addon.attrib['id']]
|
||||
if int(addon.attrib['revision']) < int(taddon.attrib['revision']):
|
||||
continue
|
||||
addons_dict[addon.attrib['id']] = addon
|
||||
if check_updates and addon.attrib['id'] in ext.data['installed_dict']:
|
||||
try:
|
||||
_installed = ext.data['installed_dict'][addon.attrib['id']]
|
||||
_local_revision = int(_installed.attrib['revision'])
|
||||
_remote_revision = int(addon.attrib['revision'])
|
||||
if _remote_revision > _local_revision: # did you know that str can be compared exactly as int? NO
|
||||
ext.logger.info(f"{addon.attrib['name']} id={addon.attrib['id']} can be updated from rev{_local_revision} to rev{_remote_revision}")
|
||||
updates_available.append(addon)
|
||||
except KeyError as exc:
|
||||
ext.logger.error(f"Failed to check available update for {addon.attrib['name']}, missing {exc}")
|
||||
except InvalidVersion as exc:
|
||||
ext.logger.warning(f"Addon {addon.attrib['name']} has an invalid version: {repr(exc)}")
|
||||
ext.logger.info('Operation complete')
|
||||
|
||||
|
||||
def fetch_installed(ext: AdminCommandExtension):
|
||||
fetch_karts = ext.mconfig.getboolean('fetch_karts')
|
||||
ext.logger.info('Retrieving local addons...')
|
||||
if 'installed_addons' not in ext.data:
|
||||
installed_addons = ext.data['installed_addons'] = ElementTree.Element('installed-addons')
|
||||
else:
|
||||
installed_addons = ext.data['installed_addons']
|
||||
for item in ('installed_dict', track_l, soccer_l, arena_l, kart_l):
|
||||
if item not in ext.data:
|
||||
installed_dict = ext.data[item] = {}
|
||||
installed_dict = ext.data['installed_dict']
|
||||
track_addons = ext.data[track_l]
|
||||
soccer_addons = ext.data[soccer_l]
|
||||
arena_addons = ext.data[arena_l]
|
||||
kart_addons = ext.data[kart_l]
|
||||
# generate xml and dictionary
|
||||
for addontype in (track_l, kart_l):
|
||||
with os.scandir(os.path.join(os.path.expanduser(ext.mconfig['addonpath']), addontype + 's')) as scandir:
|
||||
for addon_dir in scandir:
|
||||
if addon_dir.is_dir():
|
||||
# can load information about this addon
|
||||
_xmlname = f'{addontype}.xml'
|
||||
_xml_path = os.path.join(addon_dir.path, _xmlname)
|
||||
if not os.path.isfile(_xml_path):
|
||||
ext.logger.error(f"Addon {addon_dir.name} doesn't have {_xmlname}, cannot load into known addons list")
|
||||
continue
|
||||
try:
|
||||
_xml = ElementTree.parse(_xml_path).getroot()
|
||||
except ElementTree.ParseError as exc:
|
||||
ext.logger.debug(f'Cannot load addon data for {addon_dir.name}: {exc}')
|
||||
continue
|
||||
types_ = classifyAddon(_xml)
|
||||
if track_l in types_:
|
||||
track_addons[addon_dir.name] = _xml
|
||||
if soccer_l in types_:
|
||||
soccer_addons[addon_dir.name] = _xml
|
||||
if arena_l in types_:
|
||||
arena_addons[addon_dir.name] = _xml
|
||||
if kart_l in types_:
|
||||
kart_addons[addon_dir.name] = _xml
|
||||
installed_addons.append(_xml)
|
||||
installed_dict[addon_dir.name] = _xml
|
||||
if not fetch_karts:
|
||||
break
|
||||
ext.logger.info('Local addons retrieved')
|
||||
|
||||
|
||||
async def download_addon(ext: AdminCommandExtension, addonid: str, download_link: str) -> str:
|
||||
# _installed: ElementTree.Element = ext.data['installed_dict'][addonid]
|
||||
clientsession: ClientSession = ext.clientsession
|
||||
# ext.logger.info(f'Updating addon {addonid} from rev{_installed.attrib.get("revision", "?")}'
|
||||
# f' to rev{_addon.attrib["revision"]}, '
|
||||
# f'download link is {_download_link}.')
|
||||
_download_path = ext.mconfig['downloadpath']
|
||||
_filepath = os.path.join(_download_path, f'{addonid}.zip')
|
||||
ext.logger.info(f'Downloading {addonid} from {download_link} to {_filepath}...')
|
||||
if os.path.isfile(_filepath):
|
||||
ext.logger.info(f'Deleting previous version file {_filepath}')
|
||||
_mode = 'wb'
|
||||
else:
|
||||
_mode = 'xb'
|
||||
with open(_filepath, _mode) as file:
|
||||
_downloaded_bytes = 0
|
||||
async with clientsession.get(download_link) as resp:
|
||||
resp: ClientResponse
|
||||
_length = resp.content_length
|
||||
_length_kb = (_length or 0) / 1024
|
||||
ext.logger.info(f'{addonid}: downloadable zip file is {_length_kb} kb of data')
|
||||
counter = count()
|
||||
async for data in resp.content.iter_any():
|
||||
file.write(data)
|
||||
_downloaded_bytes += len(data)
|
||||
_progress = floor(_downloaded_bytes / _length * 100)
|
||||
if _progress == 100 or not (next(counter) % 16):
|
||||
ext.logger.info(f'{addonid}: download progress {_progress}%')
|
||||
ext.logger.info(f'{addonid} downloaded!')
|
||||
return _filepath
|
||||
|
||||
|
||||
def clear_directory(path: str):
|
||||
with os.scandir(path) as directory:
|
||||
for entry in directory:
|
||||
if entry.is_dir():
|
||||
rmtree(entry.path)
|
||||
else:
|
||||
os.remove(entry.path)
|
||||
|
||||
|
||||
def unpack_addon(ext: AdminCommandExtension, addonid: str, addontag: str, filepath: str):
|
||||
if addontag == arena_l:
|
||||
addontag = track_l
|
||||
_addonpath: str = os.path.expanduser(ext.mconfig['addonpath'])
|
||||
_addonsubdir = addontag + 's'
|
||||
_target_path = os.path.join(_addonpath, _addonsubdir, addonid)
|
||||
ext.logger.info(f'Unpacking {filepath} to {_target_path}...')
|
||||
if os.path.isdir(_target_path):
|
||||
ext.logger.info(f'Removing contents of {_target_path} and replacing with new ones...')
|
||||
clear_directory(_target_path)
|
||||
else:
|
||||
os.mkdir(_target_path)
|
||||
with ZipFile(filepath) as archive:
|
||||
archive.extractall(_target_path)
|
||||
ext.logger.info(f'{addonid} successfully extracted to the addon directory.')
|
||||
return _target_path
|
||||
|
||||
|
||||
async def update_addon(ext: AdminCommandExtension, addon: ElementTree.Element, unoutdate=True, restart=False):
|
||||
_addonid = addon.attrib['id']
|
||||
_archive_path = await download_addon(ext, _addonid, addon.attrib['file'])
|
||||
unpack_addon(ext, _addonid, addon.tag, _archive_path)
|
||||
ext.data['installed_dict'][_addonid].attrib['revision'] = addon.attrib['revision']
|
||||
ext.logger.info(f'{addon.tag.title()} "{addon.attrib["id"]}" has been updated.')
|
||||
if unoutdate:
|
||||
ext.data['updates_available'].remove(addon)
|
||||
ext.data['addonmodflag'] = True
|
||||
if restart:
|
||||
ext.ace.server_restart_clk()
|
||||
ext.data['addonmodflag'] = False
|
||||
|
||||
|
||||
async def update_all(ext: AdminCommandExtension):
|
||||
if 'updates_available' not in ext.data:
|
||||
ext.logger.error('Cannot update: no updates available or updates are not enabled.')
|
||||
return
|
||||
_updates: Sequence[ElementTree.Element] = ext.data['updates_available']
|
||||
_update_banlist: Sequence[str] = delimiter.split(ext.mconfig['autoupdate_banlist'])
|
||||
ext.logger.info(f'Updating all addons ({len(_updates)} can be updated)...')
|
||||
for i in range(len(_updates) - 1, -1, -1):
|
||||
addon = _updates[i]
|
||||
_addonid = addon.attrib['id']
|
||||
if _addonid in _update_banlist:
|
||||
ext.logger.debug(f'Skipping frozen addon {addon.attrib["name"]} id={_addonid}')
|
||||
continue
|
||||
try:
|
||||
await update_addon(ext, addon, unoutdate=False)
|
||||
del _updates[i]
|
||||
except Exception:
|
||||
ext.logger.error(f'update_all: error occurred during "{addon.attrib["id"]}" update:\n{traceback.format_exc()}')
|
||||
ext.logger.info('Addons are updated')
|
||||
|
||||
|
||||
async def install_addon(ext: AdminCommandExtension, addon: ElementTree.Element, restart=False) -> bool:
|
||||
async with ext.addon_installed.emit_and_handle(addon) as handle:
|
||||
_res, _args, _kw = handle()
|
||||
if not _res:
|
||||
return False
|
||||
_addonid = addon.attrib['id']
|
||||
_archive_path = await download_addon(ext, _addonid, addon.attrib['file'])
|
||||
addontype = addon.tag
|
||||
if addontype == arena_l:
|
||||
addontype = track_l
|
||||
addon_dir = unpack_addon(ext, _addonid, addontype, _archive_path)
|
||||
_xmlname = f'{addontype}.xml'
|
||||
_xml_path = os.path.join(addon_dir, _xmlname)
|
||||
if not os.path.isfile(_xml_path):
|
||||
ext.logger.error(f"Addon {_addonid} doesn't have {_xmlname}, cannot load into known addons list")
|
||||
_kw['error'] = 0
|
||||
handle(False)
|
||||
return False
|
||||
try:
|
||||
_xml = ElementTree.parse(_xml_path).getroot()
|
||||
except ElementTree.ParseError as exc:
|
||||
ext.logger.error(f'Cannot load addon data for {_addonid}: {exc}')
|
||||
_kw['error'] = 1
|
||||
handle(False)
|
||||
return False
|
||||
types_ = classifyAddon(_xml)
|
||||
if track_l in types_:
|
||||
ext.data['track'][_addonid] = _xml
|
||||
if soccer_l in types_:
|
||||
ext.data['soccer'][_addonid] = _xml
|
||||
if arena_l in types_:
|
||||
ext.data['arena'][_addonid] = _xml
|
||||
if kart_l in types_:
|
||||
ext.data['kart'][_addonid] = _xml
|
||||
ext.data['installed_addons'].append(_xml)
|
||||
ext.data['installed_dict'][_addonid] = _xml
|
||||
ext.data['addonmodflag'] = True
|
||||
ext.logger.info(f'Addon {_addonid} has been installed.')
|
||||
if restart:
|
||||
ext.ace.server_restart_clk()
|
||||
ext.data['addonmodflag'] = False
|
||||
return True
|
||||
|
||||
|
||||
def ban_addon(ext: AdminCommandExtension, addon_id: str) -> bool:
|
||||
try:
|
||||
_install_banlist: MutableSequence[str] = delimiter.split(ext.mconfig['autoinstall_banlist'])
|
||||
if addon_id not in _install_banlist:
|
||||
ext.logger.info(f'Adding "{addon_id}" to the autoinstall ban list')
|
||||
_install_banlist.append(addon_id)
|
||||
ext.mconfig['autoinstall_banlist'] = ', '.join(_install_banlist)
|
||||
ext.save_config()
|
||||
return True
|
||||
except Exception:
|
||||
ext.logger.exception('Failed to ban addon "{}":'.format(addon_id))
|
||||
return False
|
||||
|
||||
|
||||
def unban_addon(ext: AdminCommandExtension, addon_id: str) -> bool:
|
||||
try:
|
||||
_install_banlist: MutableSequence[str] = delimiter.split(ext.mconfig['autoinstall_banlist'])
|
||||
if addon_id in _install_banlist:
|
||||
ext.logger.info(f'Removing "{addon_id}" from the autoinstall ban list')
|
||||
_install_banlist.remove(addon_id)
|
||||
ext.mconfig['autoinstall_banlist'] = ', '.join(_install_banlist)
|
||||
ext.save_config()
|
||||
return True
|
||||
except Exception:
|
||||
ext.logger.exception('Failed to unban addon "{}":'.format(addon_id))
|
||||
return False
|
||||
|
||||
|
||||
async def uninstall_addon(ext: AdminCommandExtension, addon: ElementTree.Element, ban=True, restart=False) -> bool:
|
||||
async with ext.addon_uninstalled.emit_and_handle(addon) as handle:
|
||||
_res, _args, _kw = handle()
|
||||
if not _res:
|
||||
return False
|
||||
if addon.tag == arena_l:
|
||||
addontag = track_l
|
||||
else:
|
||||
addontag = addon.tag
|
||||
_addonpath: str = ext.mconfig['addonpath']
|
||||
_addonsubdir = addontag + 's'
|
||||
_addonid = addon.attrib['id']
|
||||
_target_path = os.path.join(_addonpath, _addonsubdir, _addonid)
|
||||
xmlfile_name = kart_l if _addonid in ext.data[kart_l] else track_l
|
||||
if not os.path.isdir(_target_path):
|
||||
ext.logger.error(f'Cannot uninstall addon {_addonid}: directory "{_target_path}" not found.')
|
||||
handle(False)
|
||||
return False
|
||||
elif not os.path.isfile(os.path.join(_target_path, xmlfile_name)):
|
||||
ext.logger.warning(f'Addon {_addonid} does not have a {xmlfile_name}, uninstallation won\'t affect STK servers.')
|
||||
restart = False
|
||||
ext.logger.info(f'Removing directory "{_target_path}"...')
|
||||
rmtree(_target_path)
|
||||
ext.data['installed_addons'].remove(addon)
|
||||
del ext.data['installed_dict'][_addonid]
|
||||
if ban:
|
||||
ban_addon(ext, _addonid)
|
||||
ext.data['addonmodflag'] = True
|
||||
ext.logger.info(f'Addon {_addonid} has been uninstalled.')
|
||||
if restart:
|
||||
ext.ace.server_restart_clk()
|
||||
ext.data['addonmodflag'] = False
|
||||
return True
|
||||
|
||||
|
||||
async def install_new_addons(ext: AdminCommandExtension):
|
||||
_autoinstall_banlist: Sequence[str] = delimiter.split(ext.mconfig['autoinstall_banlist'])
|
||||
|
||||
def _predicate(triplet):
|
||||
_addon = triplet[0]
|
||||
if _addon.attrib['id'] in ext.data['installed_dict']:
|
||||
return False
|
||||
if float(_addon.attrib['rating']) < float(ext.mconfig['autoinstall_minrating']):
|
||||
return False
|
||||
if _addon.attrib['id'] in _autoinstall_banlist:
|
||||
ext.logger.debug(f'Skipping banned addon {_addon.attrib["id"]}')
|
||||
return False
|
||||
if _addon.tag == kart_l and not ext.mconfig.getboolean('autoinstall_karts'):
|
||||
return False
|
||||
return AddonStatus._xml_allowdeny_predicate(triplet)
|
||||
_allow, _deny = AddonStatus.allowdeny_pair(ext.mconfig['autoinstall_requirements'])
|
||||
_addons: Sequence[ElementTree.Element] = tuple(
|
||||
triplet[0] for triplet in filter(
|
||||
_predicate,
|
||||
zip(
|
||||
ext.data['addons_tree'].getroot(), repeat(_allow), repeat(_deny)
|
||||
)
|
||||
)
|
||||
)
|
||||
ext.logger.info(f'Downloading new addons ({len(_addons)} available)...')
|
||||
for addon in _addons:
|
||||
try:
|
||||
await install_addon(ext, addon)
|
||||
except Exception:
|
||||
ext.logger.error(f'Cannot install addon {addon.attrib["name"]} id={addon.attrib["id"]}:\n{traceback.format_exc()}')
|
||||
|
||||
|
||||
async def update_all_installmore(ext: AdminCommandExtension, install_more=True):
|
||||
await update_all(ext)
|
||||
if install_more:
|
||||
await install_new_addons(ext)
|
||||
if ext.data['addonmodflag']:
|
||||
ext.ace.server_restart_clk()
|
||||
ext.data['addonmodflag'] = False
|
||||
await ext.addon_bulk_modified.emit()
|
||||
|
||||
|
||||
async def autoupdate_task(ext: AdminCommandExtension):
|
||||
try:
|
||||
if 'stkaddons_autoupdate_task' in ext.tasks:
|
||||
ext.logger.error('Another autoupdate_task is already running! Aborting!')
|
||||
return
|
||||
ext.tasks['stkaddons_autoupdate_task'] = asyncio.current_task()
|
||||
if 'autoupdate_interval' not in ext.mconfig:
|
||||
raise KeyError('autoupdate_interval is not set in ext.mconfig')
|
||||
ext.logger.info(f'Autofetcher is enabled. Interval = {ext.mconfig["autoupdate_interval"]} seconds')
|
||||
while ext.mconfig.getboolean('autoupdate'):
|
||||
await asyncio.sleep(ext.mconfig.getfloat('autoupdate_interval'))
|
||||
await fetch(ext)
|
||||
await update_all(ext)
|
||||
if ext.mconfig.getboolean('autoinstall'):
|
||||
await install_new_addons(ext)
|
||||
ext.logger.info('Cleaning downloads directory')
|
||||
clear_directory(ext.mconfig['downloadpath'])
|
||||
if ext.data['addonmodflag']:
|
||||
ext.ace.server_restart_clk()
|
||||
ext.data['addonmodflag'] = False
|
||||
except Exception:
|
||||
ext.logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def stkaddons_command_set(ext: AdminCommandExtension):
|
||||
async def check_available(cmd: AdminCommandExecutor):
|
||||
cmd.print('Checking...')
|
||||
asyncio.create_task(fetch(ext))
|
||||
ext.add_command(check_available, 'check-available', tuple(), tuple(), 'Check if there are any available addons to update')
|
||||
|
||||
async def listaddons(cmd: AdminCommandExecutor, cpage: int = 1, flags_: str = None, not_installed=False):
|
||||
if not_installed:
|
||||
def _predicate(triplet):
|
||||
_addon = triplet[0]
|
||||
if _addon.attrib['id'] in ext.data['installed_dict']:
|
||||
return False
|
||||
return AddonStatus._xml_allowdeny_predicate(triplet)
|
||||
else:
|
||||
_predicate = AddonStatus._xml_allowdeny_predicate
|
||||
if flags_ is not None:
|
||||
_allow, _deny = AddonStatus.allowdeny_pair(flags_)
|
||||
_addons: Sequence[ElementTree.Element] = tuple(
|
||||
triplet[0] for triplet in filter(
|
||||
_predicate,
|
||||
zip(
|
||||
ext.data['addons_tree'].getroot(), repeat(_allow), repeat(_deny)
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
_addons: ElementTree.Element = ext.data['addons_tree'].getroot()
|
||||
_len = len(_addons)
|
||||
_maxpage, _start, _end = paginate_range(_len, 10, cpage)
|
||||
cmd.print(f'Online addons (page {cpage} of {_maxpage}):')
|
||||
cmd.print('\n'.join(f'{ratingStars(float(addon.attrib["rating"]))} '
|
||||
f'{kart_l.title() + " " if addon.tag == kart_l else empty_str}'
|
||||
f'{heavy_check_sign if addon.attrib["id"] in ext.data["installed_dict"] else empty_str}'
|
||||
f'{addon.attrib["id"]}={html.unescape(addon.attrib["name"])} '
|
||||
f'by {addon.attrib["designer"]} '
|
||||
f'(pub: {html.unescape(addon.attrib["uploader"])}): '
|
||||
f'"{flags(addon.attrib["status"])}"' for addon in (_addons[i] for i in range(_start, _end))))
|
||||
ext.add_command(listaddons, 'listaddons', tuple(), ((int, 'page'), (str, 'addon flags'), (bool, 'not installed?')), 'Show online list of track addons')
|
||||
|
||||
async def listinstalled(cmd: AdminCommandExecutor, cpage: int = 1, addon_type=None):
|
||||
if addon_type in (soccer_l, arena_l, track_l):
|
||||
_installed: ElementTree.Element = ext.data[addon_type]
|
||||
else:
|
||||
_installed: ElementTree.Element = ext.data['installed_dict']
|
||||
_len = len(_installed)
|
||||
_maxpage, _start, _end = paginate_range(_len, 10, cpage)
|
||||
items = tuple(_installed.items())[_start:_end]
|
||||
cmd.print(f'Installed addons (page {cpage} of {_maxpage})')
|
||||
cmd.print('\n'.join(f'{_id}: {_addon.attrib["name"]} by '
|
||||
f'{_addon.attrib["designer"]}, '
|
||||
f'v{_addon.attrib["version"]}, '
|
||||
f'rev{_addon.attrib.get("revision", "?")}: {", ".join(classifyAddon(_addon))}'
|
||||
'' for _id, _addon in items))
|
||||
ext.add_command(listinstalled, 'listinstalled', tuple(), ((int, 'page'), (str, 'type')), 'Shows the list of already installed addons')
|
||||
|
||||
async def addoninfo(cmd: AdminCommandExecutor, addonid: str):
|
||||
if addonid in ext.data['addons_dict']:
|
||||
_addon: ElementTree.Element = ext.data['addons_dict'][addonid]
|
||||
_name = _addon.attrib.get('name', '-')
|
||||
_author = _addon.attrib.get('designer', '(Unknown author)')
|
||||
_uploader = _addon.attrib.get('uploader', '(Unknown uploader)')
|
||||
_remote_revision = _addon.attrib.get('revision', '-')
|
||||
_min_include_version = _addon.attrib.get('min-include-version', '*') or '*'
|
||||
_max_include_version = _addon.attrib.get('max-include-version', '*') or '*'
|
||||
_download_link = _addon.attrib.get('file', '(not provided)')
|
||||
_rating = float(_addon.attrib.get('rating', '0'))
|
||||
_rating_stars = ratingStars(_rating)
|
||||
if addonid in ext.data['installed_dict']:
|
||||
installed = True
|
||||
_installed = ext.data['installed_dict'][addonid]
|
||||
_classes = ', '.join(classifyAddon(_installed))
|
||||
_default_lap_count = _installed.attrib.get('default-lap-count', '-')
|
||||
_local_revision = _installed.attrib.get('revision', '-')
|
||||
_version = _installed.attrib.get('version', '-')
|
||||
else:
|
||||
installed = False
|
||||
_on_install = '(on-install)'
|
||||
_classes = _on_install
|
||||
_default_lap_count = _on_install
|
||||
_local_revision = _on_install
|
||||
_version = _on_install
|
||||
elif addonid in ext.data['installed_dict']:
|
||||
cmd.print('This addon has been removed or was never uploaded')
|
||||
_installed = ext.data['installed_dict'][addonid]
|
||||
installed = True
|
||||
_name = _installed.attrib.get('name', '-')
|
||||
_classes = ', '.join(classifyAddon(_installed))
|
||||
_default_lap_count = _installed.attrib.get('default-lap-count', '-')
|
||||
_author = _installed.attrib.get('designer', '(Unknown author)')
|
||||
_not_uploaded = '(not uploaded)'
|
||||
_uploader = _not_uploaded
|
||||
_remote_revision = _not_uploaded
|
||||
_local_revision = _installed.attrib.get('revision', '-')
|
||||
_min_include_version = '*'
|
||||
_max_include_version = _min_include_version
|
||||
_download_link = _not_uploaded
|
||||
_rating = _not_uploaded
|
||||
_rating_stars = 'xxxxxx'
|
||||
_version = _installed.attrib.get('version', '-')
|
||||
else:
|
||||
cmd.error(f'Addon "{addonid}" not found.')
|
||||
return
|
||||
cmd.print('\n'.join((f'{_rating_stars} {_name} ',
|
||||
f'{heavy_check_sign if installed else empty_str} ',
|
||||
f'Author(s): {_author}',
|
||||
f'Uploader: {_uploader}',
|
||||
f'Game mode(s): {_classes}',
|
||||
f'Default lap count: {_default_lap_count}',
|
||||
f'Version: {_version}',
|
||||
f'Remote revision: {_remote_revision}',
|
||||
f'Installed revision: {_local_revision}',
|
||||
f'Supported version range: {_min_include_version}-{_max_include_version}',
|
||||
f'Download link: {_download_link}')))
|
||||
|
||||
async def addoninfo_tab(cmd: AdminCommandExecutor, addonid: str = '', *, argl: str):
|
||||
return tab_complete_onlineaddon(ext, addonid)
|
||||
ext.add_command(addoninfo, 'addoninfo', ((str, 'addonid'), ), tuple(), 'Show common information about addon', addoninfo_tab)
|
||||
|
||||
async def updates(cmd: AdminCommandExecutor, cpage: int = 1):
|
||||
if 'updates_available' in ext.data:
|
||||
_upd: Sequence[ElementTree.Element] = ext.data['updates_available']
|
||||
_len = len(_upd)
|
||||
_maxpage, _start, _end = paginate_range(_len, 10, cpage)
|
||||
cmd.print(f'Available updates for addons (page {cpage} of {_maxpage}):')
|
||||
cmd.print('\n'.join(f'{_upd[i].attrib["id"]}: {_upd[i].attrib["name"]} by {_upd[i].attrib["designer"]}'
|
||||
'' for i in range(_start, _end)))
|
||||
ext.add_command(updates, 'updates', tuple(), ((int, 'page'), ), 'Show list of outdated addons')
|
||||
|
||||
async def downloadaddon(cmd: AdminCommandExecutor, addonid: str):
|
||||
if addonid not in ext.data['addons_dict']:
|
||||
cmd.error(f'Addon {addonid} not found.')
|
||||
return
|
||||
_addon: ElementTree.Element = ext.data['addons_dict'][addonid]
|
||||
asyncio.create_task(download_addon(cmd, addonid, _addon.attrib['file']))
|
||||
cmd.print('Starting to download addon {addonid}')
|
||||
|
||||
async def downloadaddon_tab(cmd: AdminCommandExecutor, addonid: str = '', *, argl: str):
|
||||
return tab_complete_onlineaddon(ext, addonid)
|
||||
ext.add_command(downloadaddon, 'downloadaddon', ((str, 'addonid'), ), description='Download addon archive to downloads directory', atabcomplete=downloadaddon_tab)
|
||||
|
||||
async def unpackaddon(cmd: AdminCommandExecutor, addonid: str):
|
||||
_archive_path = os.path.join(ext.mconfig['downloadpath'], addonid + '.zip')
|
||||
_addontype: str = ext.data['addons_dict'][addonid].tag
|
||||
if not os.path.isfile(_archive_path):
|
||||
cmd.error(f'Addon {_archive_path} is not downloaded')
|
||||
return
|
||||
try:
|
||||
unpack_addon(ext, addonid, _addontype, _archive_path)
|
||||
except Exception:
|
||||
cmd.error(traceback.format_exc())
|
||||
if ext.data['addonmodflag']:
|
||||
ext.ace.server_restart_clk()
|
||||
ext.data['addonmodflag'] = False
|
||||
cmd.print(f'{_addontype.title()} addon extracted.')
|
||||
|
||||
async def unpackaddon_tab(cmd: AdminCommandExecutor, addonid: str = '', *, argl: str):
|
||||
_res = []
|
||||
with os.scandir(ext.mconfig['downloadpath']) as directory:
|
||||
_res.extend(entry.name.rpartition(dot)[0] for entry in directory if entry.name.endswith('.zip') and entry.name.startswith(addonid))
|
||||
return _res
|
||||
ext.add_command(unpackaddon, 'unpackaddon', ((str, 'addonid'), ), description='Unpack downloaded addon to the addons directory', atabcomplete=unpackaddon_tab)
|
||||
|
||||
async def installaddon(cmd: AdminCommandExecutor, addonid: str):
|
||||
if addonid not in ext.data['addons_dict']:
|
||||
cmd.error(f'Couldn\'t find addon "{addonid}"')
|
||||
_addon: ElementTree.Element = ext.data['addons_dict'][addonid]
|
||||
task = asyncio.create_task(install_addon(ext, _addon, restart=True))
|
||||
ext.tasks[task.get_name()] = task
|
||||
|
||||
async def installaddon_tab(cmd: AdminCommandExecutor, addonid: str = '', *, argl: str):
|
||||
def _predicate(_addon):
|
||||
if _addon.attrib['id'] in ext.data['installed_dict']:
|
||||
return False
|
||||
return _addon.attrib['id'].startswith(addonid)
|
||||
return list(
|
||||
addon.attrib['id'] for addon in filter(
|
||||
_predicate,
|
||||
ext.data['addons_tree'].getroot()
|
||||
)
|
||||
)
|
||||
ext.add_command(installaddon, 'installaddon', ((str, 'addonid'), ), description='Download and unpack an addon', atabcomplete=installaddon_tab)
|
||||
|
||||
async def updateaddon(cmd: AdminCommandExecutor, addonid: str):
|
||||
_updates_available = ext.data['updates_available']
|
||||
_addon: ElementTree.Element = ext.data['addons_dict'][addonid]
|
||||
if _addon not in _updates_available:
|
||||
cmd.error(f'No available update for "{addonid}"')
|
||||
return
|
||||
task = asyncio.create_task(update_addon(ext, _addon, restart=True))
|
||||
ext.tasks[task.get_name()] = task
|
||||
|
||||
async def updateaddon_tab(cmd: AdminCommandExecutor, addonid: str = '', *, argl: str):
|
||||
return list(filter(lambda name: name.startswith(addonid), (addon.attrib['id'] for addon in ext.data['updates_available'])))
|
||||
ext.add_command(updateaddon, 'updateaddon', ((str, 'addonid'), ), description='Install update for an addon', atabcomplete=updateaddon_tab)
|
||||
|
||||
async def updateall(cmd: AdminCommandExecutor, _installnew=False):
|
||||
task = asyncio.create_task(update_all_installmore(ext, _installnew))
|
||||
ext.tasks[task.get_name()] = task
|
||||
ext.add_command(updateall, 'updateall', optargs=((bool, 'install new addons?'), ), description='Install all available updates (and download more addons if specified)')
|
||||
|
||||
|
||||
async def extension_init(ext: AdminCommandExtension):
|
||||
ext.data['addonmodflag'] = False
|
||||
ext.addon_installed = AIOHandlerChain() # (addon: Element)
|
||||
ext.addon_uninstalled = AIOHandlerChain() # (addon: Element)
|
||||
ext.addon_updated = AIOHandlerChain() # (addon: Element)
|
||||
# fired explicitly
|
||||
ext.addon_bulk_modified = AIOHandlerChain(cancellable=False) # ()
|
||||
ext.mconfig_path = config_path = os.path.join(ext.ace.extpath, 'stkswrapper.conf')
|
||||
|
||||
def save_config(newfile=False):
|
||||
with open(config_path, 'x' if newfile else 'w') as file:
|
||||
ext.config.write(file)
|
||||
|
||||
def load_config():
|
||||
ext.config.read(config_path)
|
||||
ext.load_config = load_config
|
||||
ext.save_config = save_config
|
||||
ext.logger = ext.ace.logger.getChild('STKSWrapper')
|
||||
ext.logger.propagate = True
|
||||
ext.logger.setLevel(logging.INFO)
|
||||
config = ext.config = ConfigParser(allow_no_value=True)
|
||||
config.read_dict(defaultconf)
|
||||
ext.mconfig = config['AddonUpdater']
|
||||
if not os.path.isfile(config_path):
|
||||
save_config()
|
||||
else:
|
||||
load_config()
|
||||
if not os.path.isdir(ext.mconfig['downloadpath']):
|
||||
os.mkdir(ext.mconfig['downloadpath'])
|
||||
fetch_installed(ext)
|
||||
stkaddons_command_set(ext)
|
||||
ext.clientsession = ClientSession()
|
||||
if ext.mconfig.getboolean('autoupdate'):
|
||||
asyncio.create_task(autoupdate_task(ext))
|
||||
# asyncio.create_task(fetch(ext))
|
||||
|
||||
|
||||
async def extension_cleanup(ext: AdminCommandExtension):
|
||||
await ext.clientsession.close()
|
73
third_party/stkwrapper/extensions/interactive.py
vendored
Normal file
73
third_party/stkwrapper/extensions/interactive.py
vendored
Normal file
|
@ -0,0 +1,73 @@
|
|||
from admin_console import AdminCommandExtension, AdminCommandExecutor
|
||||
# from code import InteractiveInterpreter
|
||||
from functools import partial
|
||||
import re
|
||||
|
||||
|
||||
async def aexec(code, globals_=globals(), locals_=locals()):
|
||||
# Make an async function with the code and `exec` it
|
||||
if '__ex' in locals_:
|
||||
prev_ex_ = True
|
||||
prev_ex = locals_['__ex']
|
||||
else:
|
||||
prev_ex_ = False
|
||||
exec(
|
||||
f'async def __ex(): {code}',
|
||||
globals_, locals_
|
||||
)
|
||||
|
||||
# Get `__ex` from local variables, call it and return the result
|
||||
_val = await locals_['__ex']()
|
||||
if prev_ex_:
|
||||
locals_['__ex'] = prev_ex
|
||||
else:
|
||||
del locals_['__ex']
|
||||
return _val
|
||||
|
||||
|
||||
async def extension_init(ext: AdminCommandExtension):
|
||||
await_p = re.compile(r'await +(.*)')
|
||||
_locals = ext.locals = {'ext': ext, 'cmd': ext.ace, 'print': ext.ace.print}
|
||||
# interpreter = ext.interpreter = InteractiveInterpreter(ext.locals)
|
||||
# ext.locals['interpreter'] = interpreter
|
||||
# interpreter.write = ext.ace.print
|
||||
# interpreter_builtins = interpreter.locals['__builtins__']
|
||||
|
||||
async def pyexec(cmd: AdminCommandExecutor, expr: str, async_: bool):
|
||||
if async_:
|
||||
await aexec(expr, _locals)
|
||||
else:
|
||||
exec(expr, _locals)
|
||||
ext.add_command(partial(pyexec, async_=False), 'exec>', ((None, 'python code'), ), description='Execute Python code')
|
||||
ext.add_command(partial(pyexec, async_=True), 'async>', ((None, 'python code'), ), description='Execute Python code as a coroutine')
|
||||
|
||||
async def pyeval(cmd: AdminCommandExecutor, expr: str):
|
||||
_await_match = await_p.fullmatch(expr)
|
||||
if _await_match:
|
||||
# _code = interpreter.compile(_await_match.group(1))
|
||||
# _val = eval(_code, ext.locals)
|
||||
_val = await eval(_await_match.group(1), _locals)
|
||||
else:
|
||||
# _code = interpreter.compile(expr)
|
||||
# _val = eval(_code, ext.locals)
|
||||
_val = eval(expr, _locals)
|
||||
cmd.print(_val)
|
||||
_locals['_'] = _val
|
||||
# cmd.print(type(interpreter.locals['__builtins__']['_']))
|
||||
ext.add_command(pyeval, '>', ((None, 'python code'), ), description='Evaluate Python code')
|
||||
|
||||
async def pyexec_console(cmd: AdminCommandExecutor, quitstr="quit"):
|
||||
while True:
|
||||
_line = await cmd.ainput.prompt_line("exec>>> ")
|
||||
if _line == quitstr:
|
||||
break
|
||||
_await_match = await_p.fullmatch(_line)
|
||||
if _await_match:
|
||||
await aexec(_line, _locals)
|
||||
else:
|
||||
exec(_line, _locals)
|
||||
ext.add_command(pyexec_console, 'pyexec-console', optargs=((None, 'quitstr'), ), description='Enter interactive Python console "quit" to return back')
|
||||
|
||||
|
||||
async def extension_cleanup(ext: AdminCommandExtension):
|
||||
pass
|
471
third_party/stkwrapper/extensions/stkw_advanced.py
vendored
Normal file
471
third_party/stkwrapper/extensions/stkw_advanced.py
vendored
Normal file
|
@ -0,0 +1,471 @@
|
|||
"""
|
||||
SuperTuxKart Wrapper Advanced
|
||||
|
||||
This extension has an additions required for sessioned servers,
|
||||
scheduled servers, tracked servers,
|
||||
config manipulation etc.
|
||||
Especially designed for STK-supertournament core functionality
|
||||
on starting servers
|
||||
"""
|
||||
|
||||
|
||||
from admin_console import AdminCommandExtension, AdminCommandExecutor, paginate_range
|
||||
from aiohndchain import AIOHandlerChain
|
||||
from typing import Optional, MutableMapping, Union
|
||||
from defusedxml import ElementTree as dElementTree
|
||||
from xml.etree import ElementTree
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from configparser import ConfigParser
|
||||
import emoji
|
||||
import weakref
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
joinmsg_parser = re.compile(r'New player (?P<username>\S+) with online id (?P<online_id>\d+) from '
|
||||
r'(?P<ipv4_addr>[\d.]+)?(?P<ipv6_addr>[0-9a-fA-F:]+)?(?::(?P<port>\d+))? with '
|
||||
r'(?P<version>.*)\..*')
|
||||
validatemsg_parser = re.compile(r'(?P<username>\S+) validated')
|
||||
leavemsg_parser = re.compile(r'(?P<username>\S+) disconnected')
|
||||
modediff_parser = re.compile(r'Updating server info with new difficulty: (?P<difficulty>\d+), game mode: (?P<mode>\d+) to stk-addons\.')
|
||||
modediff_obj = joinleave_logobject = gamestopped_obj = gameresumed_obj = 'ServerLobby'
|
||||
modediff_level = joinleave_loglevel = gamestopped_lvl = gameresumed_lvl = logging.INFO
|
||||
soccergoal_red = re.compile(r'(own_)?goal (\S*) red\.?')
|
||||
soccergoal_blue = re.compile(r'(own_)?goal (\S*) blue\.?')
|
||||
gamestopped_l = 'The game is stopped.'
|
||||
gameresumed_l = 'The game is resumed.'
|
||||
soccergoal_logobject = 'GoalLog'
|
||||
soccergoal_loglevel = logging.INFO
|
||||
main = sys.modules['__main__']
|
||||
STKServer = main.STKServer
|
||||
gamemode_names = (
|
||||
'normal grand prix',
|
||||
'time-trial grand prix',
|
||||
'follow the leader', # unable for multiplayer
|
||||
'normal race',
|
||||
'time-trial',
|
||||
'easter egg hunt', # unable for multiplayer
|
||||
'soccer',
|
||||
'free-for-all',
|
||||
'capture the flag'
|
||||
)
|
||||
difficulty_names = (
|
||||
'novice',
|
||||
'intermediate',
|
||||
'expert',
|
||||
'supertux'
|
||||
)
|
||||
defaultconf = {
|
||||
'RegularEnhancers': {
|
||||
'servers': ''
|
||||
},
|
||||
'SoccerEnhancers': {
|
||||
'servers': '',
|
||||
'sayNiceWhen69': True,
|
||||
'sayBrDeFlagsWhen17': True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def load_stkdefault(ext: AdminCommandExtension):
|
||||
ext.logmsg(f'Loading "{ext.stkdefaultxml_path}"')
|
||||
with open(ext.stkdefaultxml_path, 'r') as file:
|
||||
ext.stkdefault = dElementTree.parse(file)
|
||||
ext.logmsg(f'"{ext.stkdefaultxml_path}" loaded')
|
||||
|
||||
|
||||
def _startswith_predicate(name: str, stkservername: str):
|
||||
return stkservername.startswith(name)
|
||||
|
||||
|
||||
async def stkserver_tab(cmd: AdminCommandExecutor, name: str = '', *args, argl: str):
|
||||
if argl:
|
||||
return list(cmd.servers.keys())
|
||||
elif args:
|
||||
return
|
||||
return list(filter(partial(_startswith_predicate, name), cmd.servers.keys()))
|
||||
|
||||
|
||||
async def extension_init(ext: AdminCommandExtension):
|
||||
ext.stkdefaultxml_path = os.path.join(ext.ace.extpath, 'stkdefault.xml')
|
||||
confpath = ext.confpath = os.path.join(ext.ace.extpath, 'server_enhancers.conf')
|
||||
config = ext.config = ConfigParser(allow_no_value=True)
|
||||
load_stkdefault(ext)
|
||||
|
||||
def _finalizer(name) -> bool:
|
||||
del ext.server_enhancers[name]
|
||||
return True
|
||||
|
||||
def load_config():
|
||||
config.read_dict(defaultconf)
|
||||
if os.path.isfile(confpath):
|
||||
config.read(confpath)
|
||||
else:
|
||||
with open(confpath, 'x') as conffile:
|
||||
config.write(conffile)
|
||||
|
||||
def save_config():
|
||||
with open(confpath, 'w' if os.path.isfile(confpath) else 'x') as conffile:
|
||||
config.write(conffile)
|
||||
ext.load_config = load_config
|
||||
ext.save_config = save_config
|
||||
load_config()
|
||||
|
||||
class ServerEnhancer:
|
||||
_gamestart_parser = re.compile(r'Max ping from peers: \d+, jitter tolerance: \d+')
|
||||
_gamestart_obj = 'ServerLobby'
|
||||
_gamestartend_lvl = logging.INFO
|
||||
_gameend_parser = re.compile(r'A \d+GameProtocol protocol has been terminated.')
|
||||
_gameend_obj = 'ProtocolManager'
|
||||
|
||||
def __init__(self, server: STKServer, expiration_mins: Optional[float] = None, expiry_deletefrom: Optional[MutableMapping[str, STKServer]] = None, *args, **kwds):
|
||||
super().__init__(*args, **kwds)
|
||||
self.server = weakref.proxy(server)
|
||||
self.name = server.name
|
||||
self._finalizer = partial(_finalizer, server.name)
|
||||
self.logger: logging.Logger = server.logger
|
||||
self.player_join = AIOHandlerChain(cancellable=True)
|
||||
self.player_leave = AIOHandlerChain(cancellable=False)
|
||||
self.game_start = AIOHandlerChain(cancellable=False)
|
||||
self.game_end = AIOHandlerChain(cancellable=False)
|
||||
self.game_stop = AIOHandlerChain(cancellable=False)
|
||||
self.game_resume = AIOHandlerChain(cancellable=False)
|
||||
self.game_stopped = False # only useful for supertournament servers
|
||||
self.game_running = False # indicates whether or not the game is happening on the server
|
||||
self.players = set()
|
||||
self.valid_players = set()
|
||||
if not server.empty_server.is_set():
|
||||
self.logger.warning(f'Enhancer [{server.name}] is initialized with non-empty server. Player list is not synchronized')
|
||||
server.log_event.add_handler(self.handle_stdout)
|
||||
self.expiry_timer: Optional[asyncio.Task] = None
|
||||
self.saveonempty_task: Optional[asyncio.Task] = None
|
||||
self.expiration_seconds: Optional[float] = None
|
||||
self.expiry_deletefrom: Optional[MutableMapping[str, STKServer]] = None
|
||||
self.cfgpath = os.path.join(server.cwd, server.cfgpath)
|
||||
self.servercfg: ElementTree.Element
|
||||
self.load_serverconfig()
|
||||
self.gamemode = int(self.servercfg.find('server-mode').attrib.get('value', '3'))
|
||||
self.difficulty = int(self.servercfg.find('server-difficulty').attrib.get('value', '3'))
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
_le: AIOHandlerChain = self.server.log_event
|
||||
_le.remove_handler(self.handle_stdout)
|
||||
except (ReferenceError, KeyError, ValueError):
|
||||
pass
|
||||
for task in (self.expiry_timer, self.saveonempty_task):
|
||||
if task is not None:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
self.logger.info(f"Enhancer [{self.name}] has been finished.")
|
||||
|
||||
def cleanup(self):
|
||||
self.server.log_event.remove_handler(self.handle_stdout)
|
||||
for task in (self.expiry_timer, self.saveonempty_task):
|
||||
if task is not None:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
def load_serverconfig(self):
|
||||
try:
|
||||
self.logger.info(f"Enhancer [{self.server.name}] loading server config \"{self.cfgpath}\"")
|
||||
if os.path.isfile(self.cfgpath):
|
||||
try:
|
||||
with open(self.cfgpath, 'r') as file:
|
||||
self.servercfg = dElementTree.fromstring(file.read())
|
||||
except ElementTree.ParseError:
|
||||
self.logger.exception(f"Enhancer [{self.server.name}] failed to parse server config")
|
||||
self.logger.info(f"Enhancer [{self.server.name}] server config loaded")
|
||||
else:
|
||||
with open(ext.stkdefaultxml_path, 'r') as file:
|
||||
self.servercfg = dElementTree.fromstring(file)
|
||||
if not self.server.active:
|
||||
self.save_serverconfig()
|
||||
except Exception:
|
||||
self.logger.exception('load_serverconfig')
|
||||
raise
|
||||
|
||||
def save_serverconfig(self, later=False):
|
||||
self.logger.info(f"Enhancer [{self.server.name}] saving server config")
|
||||
# make sure server is not running
|
||||
if self.server.active and not later:
|
||||
raise RuntimeError('server is running, cannot modify config')
|
||||
elif later and self.saveonempty_task is not None:
|
||||
if not self.saveonempty_task.done():
|
||||
return
|
||||
if later:
|
||||
_tsk = asyncio.create_task(self._save_on_empty())
|
||||
self.saveonempty_task = _tsk
|
||||
ext.tasks[_tsk.get_name()] = _tsk
|
||||
return
|
||||
_root = ElementTree.ElementTree(self.servercfg)
|
||||
with open(self.cfgpath, 'wb' if os.path.isfile(self.cfgpath) else 'xb') as file:
|
||||
_root.write(file)
|
||||
|
||||
async def _save_on_empty(self):
|
||||
await self.server.empty_server.wait()
|
||||
self.server.restart = False
|
||||
await self.server.stop()
|
||||
if self.server.active:
|
||||
self.logger.info(f'_save_on_empty({self.server.name}) server is active')
|
||||
await asyncio.sleep(0)
|
||||
self.save_serverconfig()
|
||||
self.logger.info(f'Config modified for {self.server.name}')
|
||||
await self.server.launch()
|
||||
|
||||
async def kick(self, username: str, noblock=False):
|
||||
await self.chat(f"/kick {username}", noblock=noblock, allow_cmd=True)
|
||||
|
||||
async def stuff_noblock(self, cmdline: str):
|
||||
_b = cmdline.encode() + b'\n'
|
||||
_process: asyncio.subprocess.Process = self.server.process
|
||||
_process.stdin.write(_b)
|
||||
await _process.stdin.drain()
|
||||
|
||||
async def chat(self, message: str, noblock=False, allow_cmd=False):
|
||||
"""Execute chat command. Prevents from executing lobby commands, if allow_cmd is False (default)
|
||||
Use noblock=True if this command executed in a log handler"""
|
||||
_data = f"chat {' ' if message.startswith('/') and not allow_cmd else ''}{message}"
|
||||
if noblock:
|
||||
_process: asyncio.subprocess.Process = self.server.process
|
||||
_process.stdin.write(_data.encode() + b'\n')
|
||||
await _process.stdin.drain()
|
||||
else:
|
||||
await self.server.stuff(_data)
|
||||
|
||||
async def handle_stdout(self, event: AIOHandlerChain, message: str, *args, level: int, objectname: str, **kwargs):
|
||||
if objectname == joinleave_logobject and level == joinleave_loglevel:
|
||||
_joinmatch = joinmsg_parser.fullmatch(message)
|
||||
_validatematch = validatemsg_parser.fullmatch(message)
|
||||
_leavematch = leavemsg_parser.fullmatch(message)
|
||||
if _joinmatch:
|
||||
username = _joinmatch.group('username')
|
||||
if username not in self.players:
|
||||
if await self.player_join.emit(username, _match=_joinmatch):
|
||||
self.players.add(username)
|
||||
else:
|
||||
await self.kick(username, True)
|
||||
elif _validatematch:
|
||||
username = _validatematch.group('username')
|
||||
self.valid_players.add(username)
|
||||
elif _leavematch:
|
||||
username = _leavematch.group('username')
|
||||
if username in self.players:
|
||||
if await self.player_leave.emit(username, _match=_leavematch):
|
||||
try:
|
||||
self.players.remove(username)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
self.valid_players.remove(username)
|
||||
except ValueError:
|
||||
pass
|
||||
if objectname == self._gamestart_obj and level == self._gamestartend_lvl:
|
||||
_match = self._gamestart_parser.fullmatch(message)
|
||||
if _match and not self.game_running:
|
||||
await self.game_start.emit(self)
|
||||
self.game_stopped = False
|
||||
self.game_running = True
|
||||
if objectname == self._gameend_obj and level == self._gamestartend_lvl:
|
||||
_match = self._gameend_parser.fullmatch(message)
|
||||
if _match:
|
||||
await self.game_end.emit(self)
|
||||
self.game_stopped = False
|
||||
self.game_running = False
|
||||
if objectname == modediff_obj and level == modediff_level:
|
||||
_modediff_match = modediff_parser.fullmatch(message)
|
||||
if _modediff_match:
|
||||
self.gamemode = int(_modediff_match.group('mode'))
|
||||
self.difficulty = int(_modediff_match.group('difficulty'))
|
||||
if objectname == gamestopped_obj and level == gamestopped_lvl and message == gamestopped_l:
|
||||
await self.game_stop.emit(self)
|
||||
self.game_stopped = True
|
||||
if objectname == gameresumed_obj and level == gameresumed_lvl and message == gameresumed_l:
|
||||
await self.game_resume.emit(self)
|
||||
self.game_stopped = False
|
||||
|
||||
async def _expiry_timer(self):
|
||||
await asyncio.sleep(self.expiration_seconds)
|
||||
self.logger.info(f'[{self.server.name}] Server expired. Shutting down...')
|
||||
self.server.restart = False
|
||||
await self.server.stop()
|
||||
if self.expiry_deletefrom is not None:
|
||||
del self.expiry_deletefrom[self.server.name]
|
||||
|
||||
def expire_at(self, at: datetime.datetime, utc=False):
|
||||
if utc:
|
||||
_now = datetime.datetime.utcnow()
|
||||
else:
|
||||
_now = datetime.datetime.now()
|
||||
_td = (at - _now)
|
||||
_seconds = _td.total_seconds()
|
||||
self.logger.info(f'[{self.server.name}] Server expires at {at.ctime()} or {_seconds / 60} minutes')
|
||||
self.expiration_seconds = _seconds
|
||||
if self.expiry_timer is not None:
|
||||
if not self.expiry_timer.done():
|
||||
self.expiry_timer.cancel()
|
||||
self.expiry_timer = asyncio.create_task(self._expiry_timer())
|
||||
ext.tasks[self.expiry_timer.get_name()] = self.expiry_timer
|
||||
|
||||
def expire_in(self, seconds: float):
|
||||
self.expiration_seconds = seconds
|
||||
self.logger.info(f'[{self.server.name}] Server expires in {seconds / 60} minutes')
|
||||
if self.expiry_timer is not None:
|
||||
if not self.expiry_timer.done():
|
||||
self.expiry_timer.cancel()
|
||||
self.expiry_timer = asyncio.create_task(self._expiry_timer())
|
||||
ext.tasks[self.expiry_timer.get_name()] = self.expiry_timer
|
||||
ext.ServerEnhancer = ServerEnhancer
|
||||
|
||||
class STKSoccer(ServerEnhancer):
|
||||
def __init__(self, server: STKServer, no_nice=False, no_brde=False, *args, **kwds):
|
||||
super().__init__(*args, server, **kwds)
|
||||
# event argument is player name
|
||||
self.goal = AIOHandlerChain(cancellable=False)
|
||||
self.resetScore()
|
||||
self.game_start.add_handler(self.resetScore)
|
||||
# im too young to ####### so pls dont say anything about 69
|
||||
self.no_nice = no_nice
|
||||
# i for some reason hate [message delete by moderator]
|
||||
self.no_brde = no_brde
|
||||
|
||||
def cleanup(self):
|
||||
super().cleanup()
|
||||
self.game_start.remove_handler(self.resetScore)
|
||||
|
||||
def resetScore(self, *args, **kwargs):
|
||||
# don't forget to reset it when necessary
|
||||
self.score_red = 0
|
||||
self.score_blue = 0
|
||||
|
||||
async def handle_stdout(self, event: AIOHandlerChain, message: str, *args, level: int, objectname: str, **kwargs):
|
||||
await super().handle_stdout(event, message, objectname=objectname, level=level)
|
||||
if objectname == soccergoal_logobject and level == soccergoal_loglevel and not self.game_stopped:
|
||||
_match_red = soccergoal_red.fullmatch(message)
|
||||
_match_blue = soccergoal_blue.fullmatch(message)
|
||||
if _match_red:
|
||||
if await self.goal.emit(_match_red.group(2), blue=False, own=bool(_match_red.group(1))):
|
||||
self.score_red += 1
|
||||
if _match_blue:
|
||||
if await self.goal.emit(_match_blue.group(2), blue=True, own=bool(_match_blue.group(1))):
|
||||
self.score_blue += 1
|
||||
if not self.no_nice:
|
||||
if self.score_red == 6 and self.score_blue == 9:
|
||||
self.logger.info(f'Enhancer [{self.server.name}] 6-9 nice!')
|
||||
await self.chat('nice', noblock=True)
|
||||
if not self.no_brde:
|
||||
if (self.score_red == 1 and self.score_blue == 7) or (self.score_red == 7 and self.score_blue == 1):
|
||||
self.logger.info(f'Enhancer [{self.server.name}] {self.score_red}-{self.score_blue} brazil and germany be like:')
|
||||
await self.chat(emoji.emojize(':Brazil: :Germany:'), noblock=True)
|
||||
ext.STKSoccer = STKSoccer
|
||||
|
||||
def enhance_server(name: str, cls=ServerEnhancer) -> ServerEnhancer:
|
||||
return cls(ext.ace.servers[name])
|
||||
|
||||
ext.server_enhancers: Union[defaultdict, MutableMapping[str, ServerEnhancer]] = defaultdict(enhance_server)
|
||||
|
||||
async def stk_enhance(cmd: AdminCommandExecutor, name: str, class_=ServerEnhancer):
|
||||
if name not in ext.ace.servers:
|
||||
cmd.error('Server doesn\'t exist', log=False)
|
||||
return
|
||||
if name in ext.server_enhancers:
|
||||
cmd.error('Server already enhanced', log=False)
|
||||
return
|
||||
ext.server_enhancers[name] = class_(ext.ace.servers[name])
|
||||
cmd.print('Server enhanced')
|
||||
ext.add_command(partial(stk_enhance, class_=ServerEnhancer), 'stk-enhance', ((str, 'name'), ),
|
||||
description='Registers the server in the enhancer to handle stuff',
|
||||
atabcomplete=stkserver_tab)
|
||||
ext.add_command(partial(stk_enhance, class_=STKSoccer), 'stk-ensoccer', ((str, 'name'), ),
|
||||
description='Registers the soccer server in the enhancer to track goals',
|
||||
atabcomplete=stkserver_tab)
|
||||
|
||||
async def stk_enhancers(cmd: AdminCommandExecutor, cpage=1):
|
||||
_len = len(ext.server_enhancers)
|
||||
_maxpage, _start, _end = paginate_range(_len, 10, cpage)
|
||||
cmd.print(f'Enhancers (page {cpage} of {_maxpage}):')
|
||||
_enhancers = tuple(ext.server_enhancers.values())
|
||||
cmd.print(*(f'#{i}. Server "{enhancer.server.name}", python class "{type(enhancer).__name__}"' for i, enhancer in ((i, _enhancers[i]) for i in range(_start, _end))), sep='\n')
|
||||
ext.add_command(stk_enhancers, 'stk-enhancers', optargs=((int, 'page'), ), description='Shows the list of enhancers')
|
||||
|
||||
async def stk_unenhance(cmd: AdminCommandExecutor, name: str, force=False):
|
||||
if name not in ext.server_enhancers:
|
||||
cmd.error('Server is not enhanced', log=False)
|
||||
return
|
||||
_enhancer: ServerEnhancer = ext.server_enhancers[name]
|
||||
if _enhancer.saveonempty_task is not None and not force:
|
||||
if not _enhancer.saveonempty_task.done():
|
||||
cmd.error('This server has pending save task, which means that it has unsaved changes in the config.\n'
|
||||
'To forcefully unenhance the server, specify "yes" as the second argument.', log=False)
|
||||
return
|
||||
_enhancer.cleanup()
|
||||
del ext.server_enhancers[name]
|
||||
del _enhancer
|
||||
cmd.print('Server unenhanced')
|
||||
ext.add_command(stk_unenhance, 'stk-unenhance', ((str, 'name'), ), ((bool, 'force'), ),
|
||||
'Delete an enhancer for STK server. Still may leave a trace.',
|
||||
atabcomplete=stkserver_tab)
|
||||
|
||||
async def stk_soccerscore(cmd: AdminCommandExecutor, name: str):
|
||||
if name not in ext.server_enhancers:
|
||||
cmd.error(f'Server doesn\'t exist or not enhanced. To enhance this server, do stk-ensoccer {name}', log=False)
|
||||
return
|
||||
_enhancer: STKSoccer = ext.server_enhancers[name]
|
||||
if not isinstance(_enhancer, STKSoccer):
|
||||
cmd.error(f'This enhancer is not a soccer enhancer. Re-enhance this server with stk-ensoccer {name}', log=False)
|
||||
return
|
||||
cmd.print(f'Score: {_enhancer.score_red} - {_enhancer.score_blue}{" (nice)" if _enhancer.score_red == 6 and _enhancer.score_blue == 9 else ""}')
|
||||
ext.add_command(stk_soccerscore, 'stk-score', ((str, 'name'), ),
|
||||
description='View the score of the soccer server',
|
||||
atabcomplete=stkserver_tab)
|
||||
|
||||
async def stk_modediff(cmd: AdminCommandExecutor, name: str):
|
||||
if name not in ext.server_enhancers:
|
||||
cmd.error(f'Server doesn\'t exist or not enhanced. To enhance this server, do stk-enhance {name}', log=False)
|
||||
return
|
||||
_enhancer: ServerEnhancer = ext.server_enhancers[name]
|
||||
_mode = gamemode_names[_enhancer.gamemode]
|
||||
_difficulty = difficulty_names[_enhancer.difficulty]
|
||||
cmd.print(f'Server mode: {_mode}, difficulty: {_difficulty}')
|
||||
ext.add_command(stk_modediff, 'stk-modediff', ((str, 'name'), ),
|
||||
description='Get the last known mode and difficulty for a server',
|
||||
atabcomplete=stkserver_tab)
|
||||
|
||||
async def stk_69(cmd: AdminCommandExecutor, name: str, state: Optional[bool] = None):
|
||||
if name not in ext.server_enhancers:
|
||||
cmd.error(f'Server doesn\'t exist or not enhanced. To enhance this server, do stk-enhance {name}', log=False)
|
||||
return
|
||||
_enhancer: STKSoccer = ext.server_enhancers[name]
|
||||
if not isinstance(_enhancer, STKSoccer):
|
||||
cmd.error(f'This enhancer is not a soccer enhancer. Re-enhance this server with stk-ensoccer {name}', log=False)
|
||||
return
|
||||
if state is not None:
|
||||
_enhancer.no_nice = not state
|
||||
cmd.print(f'6-9 reference is {"disabled" if _enhancer.no_nice else "enabled"}')
|
||||
ext.add_command(stk_69, 'stk-69', ((str, 'name'), ), ((bool, 'state'), ),
|
||||
description='Get, enable or disable the sex pose number reference of saying "nice"'
|
||||
' as the server when score reaches 6 - 9',
|
||||
atabcomplete=stkserver_tab)
|
||||
|
||||
ext.logmsg('Autoenhancing the servers...')
|
||||
for servername in config.get('RegularEnhancers', 'servers', fallback='').split(' '):
|
||||
if servername in ext.server_enhancers or not servername:
|
||||
continue
|
||||
ext.server_enhancers[servername] = ServerEnhancer(ext.ace.servers[servername])
|
||||
ext.logmsg(f'{servername} enhanced as a regular server')
|
||||
no_nice = not config.getboolean('SoccerEnhancers', 'sayNiceWhen69', fallback=True)
|
||||
for servername in config.get('SoccerEnhancers', 'servers', fallback='').split(' '):
|
||||
if servername in ext.server_enhancers or not servername:
|
||||
continue
|
||||
ext.server_enhancers[servername] = STKSoccer(ext.ace.servers[servername], no_nice=no_nice)
|
||||
ext.logmsg(f'{servername} enhanced as a soccer server')
|
||||
|
||||
|
||||
async def extension_cleanup(ext: AdminCommandExtension):
|
||||
for enhancer in ext.server_enhancers.values():
|
||||
enhancer.cleanup()
|
72
third_party/stkwrapper/extensions/stkw_configedit.py
vendored
Normal file
72
third_party/stkwrapper/extensions/stkw_configedit.py
vendored
Normal file
|
@ -0,0 +1,72 @@
|
|||
"""
|
||||
This extension adds config editing commands to the console
|
||||
"""
|
||||
from admin_console import AdminCommandExtension, AdminCommandExecutor
|
||||
from functools import partial
|
||||
from xml.etree.ElementTree import SubElement
|
||||
import weakref
|
||||
|
||||
|
||||
async def extension_init(ext: AdminCommandExtension):
|
||||
stkw_advanced = weakref.proxy(ext.ace.extensions['stkw_advanced'])
|
||||
stkserver_tab = stkw_advanced.module.stkserver_tab
|
||||
_startswith_predicate = stkw_advanced.module._startswith_predicate
|
||||
|
||||
async def stk_reloadcfg(cmd: AdminCommandExecutor, servername: str):
|
||||
if servername not in stkw_advanced.server_enhancers:
|
||||
cmd.error(f'Server "{servername}" not found or not enhanced.', log=False)
|
||||
return
|
||||
enhancer = stkw_advanced.server_enhancers[servername]
|
||||
enhancer.load_serverconfig()
|
||||
cmd.print(f'Reloaded config for server "{servername}"')
|
||||
ext.add_command(stk_reloadcfg, 'stk-reloadcfg', ((str, 'servername'), ),
|
||||
description='Read the server configuration to the enhancer.',
|
||||
atabcomplete=stkserver_tab)
|
||||
|
||||
async def stkserver_cfg_tab(cmd: AdminCommandExecutor, servername: str = "", cfgkey: str = "", *args, argl: str):
|
||||
if not servername and not argl:
|
||||
return await stkserver_tab(cmd, servername, argl=argl)
|
||||
elif servername and not argl and not cfgkey:
|
||||
return await stkserver_tab(cmd, servername, argl=argl)
|
||||
elif servername and argl and not cfgkey and servername in stkw_advanced.server_enhancers:
|
||||
enhancer = stkw_advanced.server_enhancers[servername]
|
||||
return list(element.tag for element in enhancer.servercfg.iter())
|
||||
elif servername not in stkw_advanced.server_enhancers:
|
||||
return
|
||||
enhancer = stkw_advanced.server_enhancers[servername]
|
||||
return list(element.tag for element in enhancer.servercfg.iter() if element.tag.startswith(cfgkey))
|
||||
|
||||
|
||||
async def stk_getcfg(cmd: AdminCommandExecutor, servername: str, cfgkey: str):
|
||||
if servername not in stkw_advanced.server_enhancers:
|
||||
cmd.error(f'Server "{servername}" not found or not enhanced.', log=False)
|
||||
return
|
||||
enhancer = stkw_advanced.server_enhancers[servername]
|
||||
element = enhancer.servercfg.find(cfgkey)
|
||||
if element is None:
|
||||
cmd.error(f'Element "{cfgkey}" not found.', log=False)
|
||||
return
|
||||
cfgvalue = element.attrib['value']
|
||||
cmd.print(f'{cfgkey} = {cfgvalue}')
|
||||
ext.add_command(stk_getcfg, 'stk-getcfg', ((str, 'servername'), (str, 'cfgkey')),
|
||||
description='Shows the configuration value of specific STK server',
|
||||
atabcomplete=stkserver_cfg_tab)
|
||||
|
||||
async def stk_setcfg(cmd: AdminCommandExecutor, servername: str, cfgkey: str, value: str):
|
||||
if servername not in stkw_advanced.server_enhancers:
|
||||
cmd.error(f'Server "{servername}" not found or not enhanced.', log=False)
|
||||
return
|
||||
enhancer = stkw_advanced.server_enhancers[servername]
|
||||
element = enhancer.servercfg.find(cfgkey)
|
||||
if element is None:
|
||||
element = SubElement(enhancer.servercfg, cfgkey)
|
||||
element.attrib['value'] = value
|
||||
enhancer.save_serverconfig(later=enhancer.server.active)
|
||||
cmd.print(f'Set config value "{cfgkey}" = "{value}"')
|
||||
ext.add_command(stk_setcfg, 'stk-setcfg', ((str, 'servername'), (str, 'key'), (None, 'value')),
|
||||
description='Sets the configuration value and schedules the server restart if server is active.',
|
||||
atabcomplete=stkserver_cfg_tab)
|
||||
|
||||
|
||||
async def extension_cleanup(ext: AdminCommandExtension):
|
||||
pass
|
4
third_party/stkwrapper/requirements.txt
vendored
Normal file
4
third_party/stkwrapper/requirements.txt
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
git+https://github.com/NobWow/admin-console-python.git
|
||||
defusedxml
|
||||
packaging
|
||||
emoji
|
1319
third_party/stkwrapper/stkserver_wrapper.py
vendored
Normal file
1319
third_party/stkwrapper/stkserver_wrapper.py
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue