├── requirements.txt ├── unit_test ├── tests │ ├── start_test.bat │ ├── general_test.py │ ├── inventory_test.py │ ├── user_test.py │ └── character_test.py └── SQLScripts │ └── create_tester_accounts.sql ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── docs.yml ├── lazuli ├── __init__.py ├── jobs.py ├── utility.py ├── jobs.yaml ├── inventory.py ├── account.py ├── database.py └── character.py ├── contributor_requirements.txt ├── pyproject.toml ├── setup.bat ├── .gitignore ├── README.md ├── CHANGELOG.md ├── pylintrc └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | mysql-connector-python==8.0.30 2 | protobuf==3.20.1 3 | ruamel.yaml==0.17.21 4 | ruamel.yaml.clib==0.2.6 5 | -------------------------------------------------------------------------------- /unit_test/tests/start_test.bat: -------------------------------------------------------------------------------- 1 | :: This script allows the user to launch the tests without needing use command line 2 | @ECHO off 3 | echo "This script will start Project Lazuli's test sequence." 4 | echo "Note: The PyTest module is required for this sequence." 5 | echo "Note: Test files must be put in the repository root, for this sequence." 6 | echo "Starting up virtual environment..." 7 | call pypi\scripts\activate.bat 8 | echo "Attempting to test the capabilities of Lazuli..." 9 | py.test 10 | echo "Test concluded." 11 | pause -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /lazuli/__init__.py: -------------------------------------------------------------------------------- 1 | """This is the init file for the package - Compiled by KOOKIIE 2 | 3 | Lazuli is a Python library for interacting with Odin-like MapleStory private 4 | server databases. Lazuli contains wrappers around MySQL Connector, and is 5 | meant to complement the Azure v316 MapleStory private server emulator source 6 | code. 7 | 8 | *Copyright 2022 TEAM SPIRIT. All rights reserved. 9 | Use of this source code is governed by a AGPL-style license that can be found 10 | in the LICENSE file.* 11 | 12 | ### Word of Caution 13 | Please note that database changes will not take effect if the server is running. 14 | Please make sure to only use the setter/adders when the server is not running 15 | or, better yet, avoid these mutators altogether. 16 | 17 | .. include:: ../README.md 18 | """ 19 | __docformat__ = "google" 20 | -------------------------------------------------------------------------------- /contributor_requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==22.1.0 2 | bleach==5.0.1 3 | build==0.8.0 4 | certifi==2022.6.15 5 | charset-normalizer==2.1.1 6 | colorama==0.4.5 7 | commonmark==0.9.1 8 | docutils==0.19 9 | idna==3.3 10 | importlib-metadata==4.12.0 11 | iniconfig==1.1.1 12 | jaraco.classes==3.2.2 13 | Jinja2==3.1.2 14 | keyring==23.9.0 15 | MarkupSafe==2.1.1 16 | more-itertools==8.14.0 17 | mysql-connector-python==8.0.30 18 | packaging==21.3 19 | pdoc==12.1.0 20 | pep517==0.13.0 21 | pkginfo==1.8.3 22 | pluggy==1.0.0 23 | protobuf==3.20.1 24 | py==1.11.0 25 | Pygments==2.13.0 26 | pyparsing==3.0.9 27 | pytest==7.1.3 28 | pywin32-ctypes==0.2.0 29 | readme-renderer==37.0 30 | requests==2.28.1 31 | requests-toolbelt==0.9.1 32 | rfc3986==2.0.0 33 | rich==12.5.1 34 | ruamel.yaml==0.17.21 35 | ruamel.yaml.clib==0.2.6 36 | six==1.16.0 37 | tomli==2.0.1 38 | twine==4.0.1 39 | urllib3==1.26.12 40 | webencodings==0.5.1 41 | zipp==3.8.1 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: website 2 | 3 | # build the documentation whenever there are new releases/pre-releases published 4 | on: 5 | release: 6 | types: [published] 7 | 8 | # security: restrict permissions for CI jobs. 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | # Build the documentation and upload the static HTML files as an artifact. 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v3 19 | 20 | - run: pip install -r contributor_requirements.txt 21 | - run: pip install -e . 22 | - run: pdoc lazuli -o docs/ 23 | 24 | - uses: actions/upload-pages-artifact@v1 25 | with: 26 | path: docs/ 27 | 28 | # Deploy the artifact to GitHub pages. 29 | # This is a separate job so that only actions/deploy-pages has the necessary permissions. 30 | deploy: 31 | needs: build 32 | runs-on: ubuntu-latest 33 | permissions: 34 | pages: write 35 | id-token: write 36 | environment: 37 | name: github-pages 38 | url: ${{ steps.deployment.outputs.page_url }} 39 | steps: 40 | - id: deployment 41 | uses: actions/deploy-pages@v1 42 | -------------------------------------------------------------------------------- /lazuli/jobs.py: -------------------------------------------------------------------------------- 1 | """Contains the mapping for Job IDs to canonical names 2 | 3 | This module looks for a `jobs.yaml` file in the package, and parses it into a 4 | Python dictionary named `JOBS`. This can be thought of as a non-lazy instantiated singleton. 5 | Note that if this file cannot be located, an exception is raised. The YAML 6 | file used is the same as the one maintained by Team SPIRIT: 7 | https://github.com/TEAM-SPIRIT-Productions/MapleStoryJobIDs 8 | 9 | See the parser docs here: 10 | https://yaml.readthedocs.io/en/latest/index.html 11 | """ 12 | from importlib.abc import Traversable 13 | import importlib.resources 14 | from pathlib import Path 15 | 16 | from ruamel.yaml import YAML 17 | 18 | 19 | def get_yaml_file(file_name: str) -> Traversable: 20 | # Note: `importlib.resources.files` will only work when lazuli is imported as a package 21 | package_files = importlib.resources.files("lazuli") 22 | yaml_file = package_files.joinpath(file_name) 23 | if yaml_file.is_file(): 24 | return yaml_file 25 | raise FileNotFoundError(f"[{file_name}] should be placed in the root of the package!") 26 | 27 | 28 | def parse_yaml_file(file_name: str) -> dict: 29 | # Create YAML parser object 30 | yaml = YAML(typ="safe", pure=True) 31 | 32 | with open(get_yaml_file(file_name), "r", encoding="utf-8") as yaml_file: 33 | contents = yaml.load(yaml_file) 34 | if contents is None: 35 | raise ValueError(f"[{file_name}] contents could not be read!") 36 | return contents 37 | 38 | 39 | YAML_FILE_NAME = "jobs.yaml" 40 | JOBS: dict[str, str] = parse_yaml_file(YAML_FILE_NAME) 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "lazuli" 7 | version = "3.0.2" 8 | description = "A Python-based tool for interacting with AzureMSv316-based databases." 9 | readme = "README.md" 10 | requires-python = ">=3.10.0" 11 | license = { text = "AGPL-3.0 license" } 12 | authors = [ 13 | { name="Amos Chua" }, 14 | ] 15 | maintainers = [ 16 | { name="Brandon Phu" } 17 | ] 18 | keywords = [ 19 | "database", 20 | "maplestory", 21 | ] 22 | classifiers = [ 23 | "Development Status :: 5 - Production/Stable", 24 | "Intended Audience :: Developers", 25 | "Intended Audience :: Education", 26 | "Operating System :: OS Independent", 27 | "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", 28 | "Natural Language :: English", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Topic :: Games/Entertainment :: Side-Scrolling/Arcade Games", 32 | ] 33 | dependencies = [ 34 | "mysql-connector-python >= 8.0.30", 35 | "protobuf >= 3.2.*, < 4.0.*", 36 | "ruamel.yaml >= 0.17.21, < 18.0.0", 37 | ] 38 | # Don't list test/packaging optional dependencies, since these are Git-side only 39 | 40 | [project.urls] 41 | "Homepage" = "https://github.com/TEAM-SPIRIT-Productions/Lazuli" 42 | "Bug Tracker" = "https://github.com/TEAM-SPIRIT-Productions/Lazuli/issues" 43 | "Wiki" = "https://github.com/TEAM-SPIRIT-Productions/Lazuli/wiki" 44 | "API Docs" = "https://team-spirit-productions.github.io/Lazuli/" 45 | 46 | # Setuptools-specific configuration: 47 | [tool.setuptools] 48 | packages = ["lazuli"] 49 | 50 | [tool.setuptools.package-data] 51 | mypkg = ["jobs.yaml"] -------------------------------------------------------------------------------- /setup.bat: -------------------------------------------------------------------------------- 1 | echo off 2 | 3 | rem ---------------------------------------------------------- 4 | rem Allow user to choose which virtual environment to set up 5 | setlocal 6 | 7 | echo This script will set up your virtual environment. 8 | echo Do not close this window until you are told to do so! 9 | echo Please select which virtual environment to set up: 10 | echo A: Environment for using Lazuli 11 | echo B: Environment for testing/distributing Lazuli 12 | choice /c AB /t 10 /d A /m "What is your choice" 13 | if errorlevel 2 call :distrubute 14 | if errorlevel 1 call :use 15 | 16 | pause 17 | endlocal 18 | rem ---------------------------------------------------------- 19 | 20 | :: function to run from choice A 21 | :use 22 | echo You have selected A: Environment for *USING* Lazuli 23 | 24 | echo Generating venv folder... 25 | rem Generate VENV in project dir 26 | Python -m venv %~dp0venv 27 | 28 | echo Installing dependencies... 29 | rem Activate the VENV 30 | call venv\scripts\activate.bat 31 | 32 | rem Install requirements 33 | pip install wheel 34 | pip install -r requirements.txt 35 | echo Sequence completed! You may now close this window: 36 | pause 37 | 38 | goto :eof 39 | 40 | rem ---------------------------------------------------------- 41 | 42 | :: function to run from choice B 43 | :distrubute 44 | echo You have selected B: Environment for testing/distributing Lazuli 45 | 46 | echo Generating pypi folder... 47 | rem Generate VENV in project dir 48 | Python -m venv %~dp0pypi 49 | 50 | echo Installing dependencies... 51 | rem Activate the VENV 52 | call pypi\scripts\activate.bat 53 | 54 | rem Install requirements 55 | pip install wheel 56 | pip install -r contributor_requirements.txt 57 | echo Sequence completed! You may now close this window: 58 | pause 59 | 60 | EXIT -------------------------------------------------------------------------------- /unit_test/tests/general_test.py: -------------------------------------------------------------------------------- 1 | """This is a unit test for checking basic general handling functionality 2 | 3 | NOTE: PLACE THE UNIT TEST FILES IN THE ROOT OF THE REPOSITORY! 4 | Kindly set up the DB for use; refer to the AzureMS repository on how to set up 5 | an Azure-based DB. Then, use the script in the unit_test/SQLScripts folder of this 6 | project to create a tester account. Once it's been successfully run, you can 7 | use this script to test the functionality of Lazuli's APIs. 8 | Note that you may re-run the SQL script to reset all tester accounts 9 | and characters to their baseline values, if desired. 10 | Copyright KOOKIIE Studios 2020. All rights reserved. 11 | """ 12 | import pytest 13 | from lazuli.database import Lazuli 14 | 15 | 16 | @pytest.fixture 17 | def azure(): 18 | """Returns a database instance""" 19 | # Import DB 20 | try: 21 | database_object = Lazuli() # Use defaults - these should be the same as Azure v316 repository defaults 22 | except Exception as e: 23 | raise SystemExit(f"Error has occurred whist attempting to load DB: \n{e}") 24 | return database_object 25 | 26 | 27 | # General functional testing ------------------------------------------------------------------------------- 28 | @pytest.mark.parametrize("expected", [1]) 29 | def test_fetch_online_count(azure, expected): 30 | assert azure.get_online_count() == expected, \ 31 | f"Online count test failed! Count: {azure.get_online_count()}; Type: {type(azure.get_online_count())}" 32 | 33 | 34 | @pytest.mark.parametrize("expected", [["tester0x01"]]) 35 | def test_fetch_online_players(azure, expected): 36 | assert azure.get_online_players() == expected, \ 37 | f"Online count test failed! Players: {azure.get_online_players()}; Type: {type(azure.get_online_players())}" 38 | 39 | 40 | @pytest.mark.parametrize("expected_1st, expected_2nd", [("tester0x01", "tester0x00")]) 41 | def test_level_ranking(azure, expected_1st, expected_2nd): 42 | assert azure.get_level_ranking()[0][0] == expected_1st, \ 43 | f"Level Ranking test failed! Player: {azure.get_level_ranking()[0]}; Type: {type(azure.get_level_ranking()[0][0])}" 44 | assert azure.get_level_ranking()[1][0] == expected_2nd, \ 45 | f"Level Ranking test failed! Player: {azure.get_level_ranking()[1]}; Type: {type(azure.get_level_ranking()[1][0])}" 46 | 47 | # Other general methods omitted for being the exact same logic in the engine 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | pypi/ 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # IntelliJ 133 | .idea 134 | 135 | # Lint results 136 | lint.txt -------------------------------------------------------------------------------- /unit_test/SQLScripts/create_tester_accounts.sql: -------------------------------------------------------------------------------- 1 | -- Create admin tester account with Account ID of 90,001 2 | INSERT INTO `accounts` (`id`, `name`, `password`, `2ndpassword`, `using2ndpassword`, 3 | `loggedin`, `banned`,`banreason`, `gm`, `nxCash`, `mPoints`, `vpoints`, `realcash`, `chrslot`) 4 | VALUES (90001, 'tester0x00', 'test', '1111', 1, 0, 0, 'Lorem Ipsum', 0, 0, 0, 0, 0, 3) 5 | ON DUPLICATE KEY UPDATE `name`='tester0x00', `password`='test', `2ndpassword`='1111', 6 | `using2ndpassword`=1, `loggedin`=0, `banned`=0,`banreason`='Lorem Ipsum', `gm`=0, `nxCash`=0, `mPoints`=0, 7 | `vpoints`=0, `realcash`=0, `chrslot`=3; 8 | 9 | -- Create admin tester account with Account ID of 90,002 10 | INSERT INTO `accounts` (`id`, `name`, `password`, `2ndpassword`, `using2ndpassword`, 11 | `loggedin`, `banned`, `gm`, `nxCash`, `mPoints`, `vpoints`, `realcash`, `chrslot`) 12 | VALUES (90002, 'tester0x01', 'test', '1111', 1, 0, 0, 0, 0, 0, 0, 0, 3) 13 | ON DUPLICATE KEY UPDATE `name`='tester0x01', `password`='test', `2ndpassword`='1111', 14 | `using2ndpassword`=1, `loggedin`=1, `banned`=0, `gm`=0, `nxCash`=0, `mPoints`=0, 15 | `vpoints`=0, `realcash`=0, `chrslot`=3; 16 | 17 | -- Create admin tester character with Character ID of 900,001 18 | INSERT INTO `characters` (`id`, `accountid`, `name`, `level`, `str`, `dex`, `luk`, 19 | `int`, `hp`, `mp`, `maxhp`, `maxmp`, `hair`, `face`, `map`, `rankpoint`, `gp`, `soul`, `chatban`) 20 | VALUES (900001, 90001, 'tester0x00', 249, 40, 4, 4, 21 | 4, 500, 500, 500, 500, 36786, 23300, 253000000, 0, 0, 0, 'false') 22 | ON DUPLICATE KEY UPDATE `accountid`=90001, `name`='tester0x00', `level`=249, `str`=40, `dex`=4, `luk`=4, 23 | `int`=4, `hp`=500, `mp`=500, `maxhp`=500, `maxmp`=500, `hair`=36786, `face`=23300, `map`=253000000, `rankpoint`=0, `gp`=0, `soul`=0, `chatban`='false'; 24 | 25 | -- Create admin tester character with Character ID of 900,002 26 | INSERT INTO `characters` (`id`, `accountid`, `name`, `level`, `str`, `dex`, `luk`, 27 | `int`, `hp`, `mp`, `maxhp`, `maxmp`, `hair`, `face`, `map`, `rankpoint`, `gp`, `soul`, `chatban`) 28 | VALUES (900002, 90002, 'tester0x01', 250, 40, 4, 4, 29 | 4, 500, 500, 500, 500, 36786, 23300, 253000000, 0, 0, 0, 'false') 30 | ON DUPLICATE KEY UPDATE `accountid`=90002, `name`='tester0x01', `level`=250, `str`=40, `dex`=4, `luk`=4, 31 | `int`=4, `hp`=500, `mp`=500, `maxhp`=500, `maxmp`=500, `hair`=36786, `face`=23300, `map`=253000000, `rankpoint`=0, `gp`=0, `soul`=0, `chatban`='false'; 32 | 33 | -- Allocate Inventory slots for character with Character ID of 900,001 34 | INSERT INTO `inventoryslot` (`id`, `characterid`, `equip`, `use`, `setup`, `etc`, `cash`) 35 | VALUES (900001, 900001, 96, 96, 96, 96, 60) 36 | ON DUPLICATE KEY UPDATE `characterid`=900001, `equip`=96, `use`=96, `setup`=96, `etc`=96, `cash`=60; 37 | 38 | -- Create equipped, cash equipped, equip, cash equip, use, cash use, etc, setup, cash 39 | -- bagindex: -1 Cap; -5 Overalls; -7 Boots; -8 Gloves; -9 Cape; -10 Shield; -11 Weapon 40 | -- Equipped - WZ Hat 41 | INSERT INTO `inventoryitems` (`inventoryitemid`, `type`, `characterid`, `itemid`, `inventorytype`, `position`, `quantity`, `GM_Log`, `uniqueid`) 42 | VALUES (90000001, 1, 900001, 1002140, -1, -5, 1, 'KOOKIIE Tester', 90000001) 43 | ON DUPLICATE KEY UPDATE `type`=1, `characterid`=900001, `itemid`=1002140, `inventorytype`=-1, `position`=-1, `quantity`=1, `GM_Log`='KOOKIIE Tester', `uniqueid`=90000001; 44 | -- Equip - WZ Hat; slot 1 45 | INSERT INTO `inventoryitems` (`inventoryitemid`, `type`, `characterid`, `itemid`, `inventorytype`, `position`, `quantity`, `GM_Log`, `uniqueid`) 46 | VALUES (90000002, 1, 900001, 1002140, 1, 1, 1, 'KOOKIIE Tester', 90000002) 47 | ON DUPLICATE KEY UPDATE `type`=1, `characterid`=900001, `itemid`=1002140, `inventorytype`=1, `position`=1, `quantity`=1, `GM_Log`='KOOKIIE Tester', `uniqueid`=90000002; 48 | 49 | -- Equip stats for above 2 items 50 | INSERT INTO `inventoryequipment` (`inventoryequipmentid`, `inventoryitemid`, `hpR`, `mpR`) 51 | VALUES (90000001, 90000001, 0, 0) 52 | ON DUPLICATE KEY UPDATE `inventoryitemid`=90000001, `hpR`=0, `mpR`=0; 53 | INSERT INTO `inventoryequipment` (`inventoryequipmentid`, `inventoryitemid`, `hpR`, `mpR`) 54 | VALUES (90000002, 90000002, 0, 0) 55 | ON DUPLICATE KEY UPDATE `inventoryitemid`=90000002, `hpR`=0, `mpR`=0; 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lazuli 2 | ![](https://i.imgur.com/o25Tqra.png) 3 | [![Downloads](https://static.pepy.tech/personalized-badge/lazuli?period=total&units=international_system&left_color=black&right_color=blue&left_text=Total%20Downloads)](https://pepy.tech/project/lazuli) [![Downloads](https://static.pepy.tech/personalized-badge/lazuli?period=month&units=international_system&left_color=black&right_color=blue&left_text=Monthly%20Downloads)](https://pepy.tech/project/lazuli) [![Downloads](https://static.pepy.tech/personalized-badge/lazuli?period=week&units=international_system&left_color=black&right_color=blue&left_text=Weekly%20Downloads)](https://pepy.tech/project/lazuli) [![HitCount](http://hits.dwyl.com/TEAM-SPIRIT-Productions/Lazuli.svg)](http://hits.dwyl.com/TEAM-SPIRIT-Productions/Lazuli) 4 | *Stats courtesy of PePy and [dwyl](https://github.com/dwyl)* 5 | 6 | Lazuli is a pip-compatible, Python-based package for interacting with [AzureMSv316](https://github.com/SoulGirlJP/AzureV316)-based databases, such as [ElectronMS](https://github.com/Bratah123/ElectronMS). 7 | Lazuli is inspired by and based on the [SwordieDB](https://github.com/Bratah123/SwordieDB) project. 8 | 9 | Lazuli allows access to character and inventory attributes in Odin-like databases. 10 | This makes it possible to produce not only feature-rich Discord bots, but also integrated websites. 11 | 12 | ***Perks:*** 13 | - Easy to set-up - *simply install with pip!* 14 | - Lovingly commented 15 | - API docs and example code provided 16 | - IDE hints available when importing after pip-installtion 17 | - Already used in [Lapis](https://github.com/TEAM-SPIRIT-Productions/Lapis) 18 | 19 | **Current Status: Now Available on [PyPi](https://pypi.org/project/lazuli/) (See [changelog](https://github.com/TEAM-SPIRIT-Productions/Lazuli/blob/main/CHANGELOG.md))** 20 | *Note: Lazuli v3 includes several dependency removals.* 21 | *We suggest you delete your `venv` folder and re-creating the virtual environment when upgrading from v2 to v3.* 22 | 23 | --- 24 | ### Quick Start 25 | Installation via PyPi/Pip: 26 | 1. Run `pip install lazuli` inside of your venv (or global, if desired) 27 | - see [wiki](https://github.com/TEAM-SPIRIT-Productions/Lazuli/wiki/Technical-Details) for how to generate venv 28 | 2. Import the module in your project 29 | - `from lazuli.database import Lazuli` 30 | 3. Create an Azure database object using the Lazuli class constructor 31 | - `azure = Lazuli()` 32 | - See the [Wiki](https://github.com/TEAM-SPIRIT-Productions/Lazuli/wiki/Sample-Code-Fragments#loading-a-database) for full examples 33 | - See the [API Docs](https://team-spirit-productions.github.io/Lazuli/) for more in-depth technical documentation 34 | 4. Query 35 | - E.g. `number_of_players_online = azure.get_online_count()` 36 | - gives number (int) of accounts currently connected to the server 37 | 38 | ## Documentation: 39 | Kindly refer to the [Project Wiki](https://github.com/TEAM-SPIRIT-Productions/Lazuli/wiki) and [API Docs](https://team-spirit-productions.github.io/Lazuli/) for detailed documentation. 40 | The [Discussions Page](https://github.com/TEAM-SPIRIT-Productions/Lazuli/discussions) is currently open for any questions! 41 | Please report any [issues](https://github.com/TEAM-SPIRIT-Productions/Lazuli/issues)! 42 | 43 | ## Acknowledgements: 44 | 1. The [SwordieDB](https://github.com/Bratah123/SwordieDB) project by [Bratah123](https://github.com/Bratah123) 45 | - This project is inspired by and based on SwordieDB 46 | 2. [MapleStory:IO](https://maplestory.io/) by [Senpai#1337](https://discord.gg/3SyrbAs) 47 | - The character sprite generation makes use of MapleStory.IO APIs 48 | 49 | ### Disclaimer: 50 | *Lazuli is an open-source third-party implementation of APIs for a particular MapleStory server emulation project ([AzureMSv316](https://github.com/SoulGirlJP/AzureV316)). Lazuli is non-monetised, provided as is, and is unaffiliated with NEXON. Every effort has been taken to ensure correctness and reliability of Lazuli. We will not be liable for any special, direct, indirect, or consequential damages or any damages whatsoever resulting from loss of use, data or profits, whether in an action if contract, negligence or other tortious action, arising out of or in connection with the use of Lazuli (in part or in whole).* 51 | -------------------------------------------------------------------------------- /unit_test/tests/inventory_test.py: -------------------------------------------------------------------------------- 1 | """This is a unit test for checking basic Inventory handling functionality 2 | 3 | NOTE: PLACE THE UNIT TEST FILES IN THE ROOT OF THE REPOSITORY! 4 | Kindly set up the DB for use; refer to the AzureMS repository on how to set up 5 | an Azure-based DB. Then, use the script in the unit_test/SQLScripts folder of this 6 | project to create a tester account. Once it's been successfully run, you can 7 | use this script to test the functionality of Lazuli's APIs. 8 | Note that you may re-run the SQL script to reset all tester accounts 9 | and characters to their baseline values, if desired. 10 | Copyright KOOKIIE Studios 2020. All rights reserved. 11 | """ 12 | import pytest 13 | from lazuli.database import Lazuli 14 | 15 | 16 | @pytest.fixture 17 | def inventory(): 18 | """Returns a tester Inventory instance""" 19 | # Import DB 20 | try: 21 | azure = Lazuli() # Use defaults - these should be the same as Azure v316 repository defaults 22 | except Exception as e: 23 | raise SystemExit(f"Error has occurred whist attempting to load DB: \n{e}") 24 | 25 | inventory = azure.get_inv_by_name("tester0x00") 26 | if inventory is None: 27 | raise SystemExit("CRITICAL ERROR: UNABLE TO FETCH INVENTORY BY NAME! TERMINATING...") 28 | return inventory 29 | 30 | 31 | # Inventory info fetching tests ------------------------------------------------------------------------------- 32 | # Test non-cash equipped 33 | @pytest.mark.parametrize("slot, item_id, qty", [(-1, 1002140, 1)]) 34 | def test_fetch_equipped_item(inventory, slot, item_id, qty): 35 | assert inventory.equipped_inv[slot]['itemid'] == item_id, \ 36 | f"Error encountered whilst directly fetching item ID from bagindex: \n" \ 37 | f"Expected: {item_id} ({type(item_id)}); Encountered: {inventory.equipped_inv[slot]['itemid']}, " \ 38 | f"Type: {type(inventory.equipped_inv[slot]['itemid'])}" 39 | 40 | assert inventory.equipped_inv[slot]['quantity'] == qty, \ 41 | f"Error encountered whilst directly fetching item qty from bagindex: \n" \ 42 | f"Expected: {qty} ({type(qty)}); Encountered: {inventory.equipped_inv[slot]['quantity']}, " \ 43 | f"Type: {type(inventory.equipped_inv[slot]['quantity'])}" 44 | 45 | 46 | @pytest.mark.parametrize("item_id, wrong_id , status", [(1002140, 1002141, True)]) 47 | def test_is_equipped(inventory, item_id, wrong_id, status): 48 | assert inventory.is_equipping(item_id), \ 49 | f"Error encountered whilst checking if non-cash equip is worn: \n" \ 50 | f"Expected: {status} ({type(status)}); Encountered: {inventory.is_equipping(item_id)}, " \ 51 | f"Type: {type(inventory.is_equipping(item_id))}" 52 | assert not inventory.is_equipping(wrong_id), \ 53 | f"Error encountered whilst checking for false positives, for whether non-cash equip is worn: \n" \ 54 | f"Expected: {not status} ({type(not status)}); Encountered: {inventory.is_equipping(wrong_id)}, " \ 55 | f"Type: {type(inventory.is_equipping(wrong_id))}" 56 | 57 | 58 | # Test non-cash equip 59 | @pytest.mark.parametrize("slot, item_id, qty", [(1, 1002140, 1)]) 60 | def test_fetch_equip_item(inventory, slot, item_id, qty): 61 | assert inventory.equip_inv[slot]['itemid'] == item_id, \ 62 | f"Error encountered whilst directly fetching item ID from bagindex: \n" \ 63 | f"Expected: {item_id} ({type(item_id)}); Encountered: {inventory.equip_inv[slot]['itemid']}, " \ 64 | f"Type: {type(inventory.equip_inv[slot]['itemid'])}" 65 | 66 | assert inventory.equip_inv[slot]['quantity'] == qty, \ 67 | f"Error encountered whilst directly fetching item qty from bagindex: \n" \ 68 | f"Expected: {qty} ({type(qty)}); Encountered: {inventory.equip_inv[slot]['quantity']}, " \ 69 | f"Type: {type(inventory.equip_inv[slot]['quantity'])}" 70 | 71 | 72 | @pytest.mark.parametrize("item_id, wrong_id , status", [(1002140, 1002141, True)]) 73 | def test_is_in_equip(inventory, item_id, wrong_id, status): 74 | assert inventory.has_item_in_equip(item_id), \ 75 | f"Error encountered whilst checking if non-cash equip is in inventory: \n" \ 76 | f"Expected: {status} ({type(status)}); Encountered: {inventory.has_item_in_equip(item_id)}, " \ 77 | f"Type: {type(inventory.has_item_in_equip(item_id))}" 78 | assert not inventory.has_item_in_equip(wrong_id), \ 79 | f"Error encountered whilst checking for false positives, for whether non-cash equip is in inventory: \n" \ 80 | f"Expected: {not status} ({type(not status)}); Encountered: {inventory.has_item_in_equip(wrong_id)}, " \ 81 | f"Type: {type(inventory.has_item_in_equip(wrong_id))}" 82 | 83 | # Other tabs truncated for being the exact same logic as equip tab, in the engine 84 | # No Inventory setting tests - setting inventory is out of scope! 85 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## CHANGELOG: 2 | 3 | ### v3.0.2 4 | - Fix faulty API docs links, following migration from `Portray` to `pdoc` 5 | - Fix license text contents flooding PyPI sidebar 6 | 7 | ### v3.0.1 8 | - Update GitHub Actions for GitHub Pages deployment 9 | 10 | ### v3.0.0 11 | - Remove distribution- and documentation-related dependencies from requirements 12 | - Change documentation library from `Portray` to `pdoc` 13 | - Create GitHub Actions workflow for automatic generation of API documentation 14 | - Reformat docstrings to follow Google style 15 | - Replace `JOBS` dictionary in `__init__` module with the SpiritSuite YAML document 16 | - PEP 484 Compliance: add type hints 17 | - PEP 621 Compliance: Use `pyproject.toml`, `setuptools`, and `build` for distribution 18 | - Update setup and build scripts 19 | 20 | ### v2.2.3 21 | - Update dependencies following vulnerability notices from GitHub Advisory Database 22 | - PyYaml 23 | - [CVE-2020-1747](https://github.com/advisories/GHSA-6757-jp84-gxfx) 24 | - mkdocs 25 | - [CVE-2021-40978](https://github.com/advisories/GHSA-qh9q-34h6-hcv9) 26 | - jinja2 27 | - [CVE-2020-28493](https://github.com/advisories/GHSA-g3rq-g295-4j3m) 28 | - urllib3 29 | - [CVE-2021-28363](https://github.com/advisories/GHSA-5phf-pp7p-vc2r) 30 | - [CVE-2021-33503](https://github.com/advisories/GHSA-q2q7-5pp4-w6pg) 31 | - Pygments 32 | - [CVE-2021-20270](https://github.com/advisories/GHSA-9w8r-397f-prfh) 33 | - [CVE-2021-27291](https://github.com/advisories/GHSA-pq64-v7f5-gqh8) 34 | - nltk 35 | - [CVE-2021-3828](https://github.com/advisories/GHSA-2ww3-fxvq-293j) 36 | - [CVE-2021-3842](https://github.com/advisories/GHSA-rqjh-jp2r-59cj) 37 | - [CVE-2021-43854](https://github.com/advisories/GHSA-f8m6-h2c7-8h9x) 38 | 39 | ### v2.2.2 40 | - Update dependencies following vulnerability notices from GitHub Advisory Database 41 | - Update MySQL Connector and protobuf, in view of CVE-2021-22570 42 | - Change dummy name for unit tests 43 | - Use `tester0xFF` instead of the arbitrary `KOOKIE` for account and character name-change unit tests 44 | 45 | ### v2.2.1 46 | - Add setup and build scripts 47 | - `setup.bat` automatically generates the venv folder with all dependencies 48 | - `build.bat` re-generates API Docs, build distribution archives, and uploads them to PyPi 49 | 50 | ### v2.2.0 51 | - Add `currency` property to Character 52 | - The `currency` property is a dictionary of all the currencies (e.g. NX) associated with the account that contains the character. 53 | - Dictionary keys: `mesos`, `nx`, `maplepoints`, `vp`, `dp` 54 | - Add `characters` & `free_char_slots` properties to Account 55 | - `characters` is a list of the IGNs of all the characters in the account 56 | - `free_char_slots` is an integer representing the number of free character slots in the account 57 | 58 | ### v2.1.0 59 | - Make the use of pre-underscores in variables consistent 60 | - Remove accessor for variables like _database_config 61 | - These are now always be used with the pre-underscore internally 62 | - Reason: These variables should NOT be accessed manually when using Lazuli's API 63 | - These changes are not considered breaking, since the removed properties weren't intended to be manually accessed in the first place 64 | 65 | ### v2.0.1 66 | - Fixed char image method in Character 67 | - Previously broken due to faulty refactor of Inventory instantiation 68 | 69 | ### v2.0.0 70 | - Made Inventory lazy-instantiation by: 71 | - refactoring to a method in Lazuli, instead of Character 72 | - Update unit test WRT breaking API changes 73 | 74 | ### v1.1.0 75 | - Add getter methods that return all attributes together 76 | - Made Inventory instantiation more efficient by reducing SQL calls 77 | 78 | ### v1.0.1 79 | - Perform general linting 80 | 81 | ### v1.0.0 82 | - Remove unnecessary checks 83 | - Added checks to more setters 84 | - Ready for release! 85 | 86 | ### v0.1.1 87 | - Remove unused import statements (faulty refactor) 88 | - Remove unnecessary casts causing Character.Meso and Character.EXP to fail 89 | - Fix wrong column names in prepared statements causing setters to fail 90 | - Add unit tests 91 | - Fix unit test bugs 92 | 93 | ### v0.1.0 94 | - Experimental fix for encoding issues 95 | - Tested working in CLI 96 | - To be tested for discord.py integration 97 | - Refactor DB read-write functions for more *DRY* 98 | 99 | ### v0.0.9 100 | - Fix type errors in meso and EXP setters 101 | - Fix docstring (faulty refactor) in database module 102 | - Add toggle option for GMs in ranking methods 103 | 104 | ### v0.0.8 105 | - Fix faulty refactor causing `get_char_by_name` and `get_account_by_username` to fail. 106 | - Make `get_db_first_hit` more DRY 107 | 108 | ### v0.0.7 109 | - Add utility function `extract_name_and_value` 110 | - Add arg to ranking methods, for variable no. of players 111 | - Add default value (5) for ranking methods' player count 112 | - Make ranking methods extract values like level (not just name), using `extract_name_and_value` 113 | 114 | ### v0.0.6 115 | - Migrate all **static** functions to utility module (breaking API change!) 116 | - Migrate name extraction (from list of player data) to utility module 117 | - Add ranking methods to Lazuli class 118 | - Add `get_db_all_hits` utility function, for getting all DB matches 119 | - Add `get_db_all_hits`` wrapper function in `database.Lazuli` 120 | - Made `Lazuli::get_online_list` use `Lazuli::get_db_all_hits` 121 | 122 | ### v0.0.5 123 | - Minor fix: type error for account instantiation 124 | - Generate API Docs 125 | - Feature: Fetch usernames of all players online 126 | 127 | ### v0.0.4 128 | - Open up Discussions page 129 | - Add docstrings 130 | 131 | ### v0.0.3 132 | - Add inventory model 133 | 134 | ### v0.0.2 135 | - Add character model 136 | - Add account model 137 | 138 | ### v0.0.1 139 | - Initialise project 140 | - Add database model (with placeholder for Account and Character objects) 141 | -------------------------------------------------------------------------------- /lazuli/utility.py: -------------------------------------------------------------------------------- 1 | """This module holds the utility functions and constants for the lazuli package. 2 | 3 | Copyright 2022 TEAM SPIRIT. All rights reserved. 4 | Use of this source code is governed by a AGPL-style license that can be found 5 | in the LICENSE file. 6 | Refer to `database.py` or the project wiki on GitHub for usage examples. 7 | """ 8 | from typing import Any 9 | import mysql.connector as con 10 | 11 | # CONSTANTS ------------------------------------------------------------------- 12 | # Dictionary that maps inventory tabs' names to 13 | # their corresponding index in the DB/source 14 | MAP_INV_TYPES = { 15 | 'equipped': -1, 16 | 'equip': 1, 17 | 'eqp': 1, 18 | 'use': 2, # Default name for Lazuli purposes 19 | 'consume': 2, # name in WZ 20 | 'etc': 4, 21 | 'setup': 3, # Default name for Lazuli purposes 22 | 'install': 3, # name in WZ 23 | 'cash': 5, 24 | } 25 | 26 | 27 | # UTILITY FUNCTIONS ----------------------------------------------------------- 28 | def get_key(dictionary: dict, val: Any) -> Any: 29 | """Generic function to return the key for a given value 30 | 31 | Iterates through the dictionary, comparing values to see if it matches 32 | the desired value. If so, return the corresponding key. If no matches are 33 | found by the end, return `False`. 34 | This function short-circuits (i.e. returns with the first match found). 35 | Note: OrderedDict is no longer necessary for this as of Python 3.6, 36 | as order is preserved automagically. 37 | Note2: This function does not check whether the value type provided matches 38 | with the type of the value in the provided dictionary. 39 | 40 | Args: 41 | 42 | dictionary (`dict`): Represents the dictionary to be searched 43 | val (`any`): Represents the desired/target value to search for 44 | 45 | Returns: 46 | A variable of `any` type, representing the corresponding key (if any). 47 | Defaults to `False`, if none are found. 48 | 49 | Raises: 50 | A generic error for any failures 51 | """ 52 | try: 53 | for key, value in dictionary.items(): 54 | if val == value: 55 | return key 56 | print("No corresponding key found") 57 | return False 58 | except Exception as e: 59 | print( 60 | f"Unexpected error encountered whilst attempting " 61 | f"to perform dictionary search:\n{e}" 62 | ) 63 | return False 64 | 65 | 66 | def get_db_all_hits(config: dict[str, str], query: str) -> list: 67 | """Generic function for fetching all matching data from the DB 68 | 69 | Generic top level function for fetching all matching data from DB, 70 | using the provided DB config and query 71 | 72 | Args: 73 | 74 | config (`dict`): Represents the database config attributes 75 | query (`str`): Represents the SQL query to execute 76 | 77 | Returns: 78 | A `list` of objects, representing the result of the provided SQL query, 79 | using the provided DB connection attributes 80 | 81 | Raises: 82 | SQL Error 2003: Can't connect to DB 83 | WinError 10060: No response from DB 84 | List index out of range: Wrong column name 85 | Generic error as a final catch-all 86 | """ 87 | try: 88 | database = con.connect( 89 | host=config['host'], 90 | user=config['user'], 91 | password=config['password'], 92 | database=config['schema'], 93 | port=config['port'], 94 | charset=config['charset'] 95 | ) 96 | cursor = database.cursor(dictionary=True) 97 | cursor.execute(query) 98 | data = cursor.fetchall() 99 | database.disconnect() 100 | 101 | return data 102 | 103 | except Exception as e: 104 | print( 105 | f"CRITICAL: Error encountered whilst attempting " 106 | f"to connect to the database! \n{e}" 107 | ) 108 | 109 | 110 | def get_db_first_hit(config: dict[str, str], query: str) -> Any: 111 | """Generic function for fetching the first result from DB 112 | 113 | This function grabs the first hit from `get_db_all_hits`; 114 | errors handled in `get_db_all_hits`. 115 | 116 | Args: 117 | 118 | config (`dict`): Represents the database config attributes 119 | query (`str`): Represents the SQL query to execute 120 | 121 | Returns: 122 | A variable of `any` type, representing first result 123 | """ 124 | return get_db_all_hits(config, query)[0] 125 | 126 | 127 | def get_stat_by_column(data: dict[str, Any], column: str) -> Any: 128 | """Fetches dictionary attribute by key (wrapper) 129 | 130 | Args: 131 | 132 | data (`dict`): Represents the account or character attributes 133 | column (`str`): Represents the column name in DB 134 | 135 | Returns: 136 | An `int` or `str`, representing user attribute queried 137 | 138 | Raises: 139 | A generic error on failure 140 | """ 141 | try: 142 | return data[column] 143 | except Exception as e: 144 | print( 145 | f"ERROR: Unable to extract the given column for table users.\n{e}" 146 | ) 147 | 148 | 149 | def write_to_db(config: dict[str, str], query: str) -> bool: 150 | """Performs write operations to DB using the provided DB config and query 151 | 152 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 153 | 154 | Args: 155 | 156 | config (`dict`): Represents the database config attributes 157 | query (`str`): Represents the SQL query to execute 158 | 159 | Returns: 160 | A `bool` representing whether the operation was successful 161 | 162 | Raises: 163 | SQL Error 2003: Can't connect to DB 164 | WinError 10060: No response from DB 165 | List index out of range: Wrong column name 166 | """ 167 | try: 168 | database = con.connect( 169 | host=config['host'], 170 | user=config['user'], 171 | password=config['password'], 172 | database=config['schema'], 173 | port=config['port'], 174 | charset=config['charset'] 175 | ) 176 | 177 | cursor = database.cursor(dictionary=True) 178 | cursor.execute(query) 179 | database.commit() 180 | database.disconnect() 181 | return True 182 | except Exception as e: 183 | print(f"ERROR: Unable to set stats in database.\n{e}") 184 | return False 185 | 186 | 187 | def get_inv_type_by_name(inv_string: str) -> int: 188 | """`int`: Encode an inventory type using its common name""" 189 | inv_type = MAP_INV_TYPES.get(inv_string) 190 | return inv_type 191 | 192 | 193 | def get_inv_name_by_type(inv_type: int) -> str: # Never used 194 | """`str`: Decode an inventory type using from its value""" 195 | inv_name = get_key(MAP_INV_TYPES, inv_type) 196 | return inv_name 197 | 198 | 199 | def extract_name(player_list: list[dict[str, Any]]) -> list[str]: 200 | """Extracts a `list` of players from SQL data, via the name column 201 | 202 | Args: 203 | 204 | player_list (`list[dict]`): Represents a list of all players 205 | 206 | Returns: 207 | A `list` of `str`, representing player names 208 | 209 | Raises: 210 | RuntimeError: Improperly formatted or empty player list 211 | """ 212 | if not player_list[0]['name']: # if empty or null; sanity check 213 | raise RuntimeError("No players found!") 214 | 215 | else: 216 | players = [] 217 | # player_list contains unnecessary data 218 | for player in player_list: 219 | players.append(player['name']) # Only retrieve name 220 | return players 221 | 222 | 223 | def extract_name_and_value( 224 | player_list: list[dict[str, Any]], 225 | column: str, 226 | ) -> list[tuple[str, Any]]: 227 | """Extracts a `list` of players and their corresponding attribute 228 | 229 | Extracts a `list` of players and their corresponding attribute value from 230 | SQL data, via the name column and another provided column 231 | 232 | Args: 233 | 234 | player_list (`list[dict]`): Represents a list of all players 235 | column (`str`): Represents the column name to extract 236 | 237 | Returns: 238 | A `list` of `tuple`, representing player names and 239 | their corresponding values (e.g. level) 240 | 241 | Raises: 242 | RuntimeError: Improperly formatted or empty player list 243 | """ 244 | if not player_list[0]['name']: # if empty or null; sanity check 245 | raise RuntimeError("No such players found!") 246 | 247 | else: 248 | players = [] 249 | # player_list contains unnecessary data 250 | for player in player_list: 251 | players.append((player['name'], player[column])) 252 | # Only retrieve (name, value) 253 | # List of Tuples, as per Brandon's request 254 | return players 255 | -------------------------------------------------------------------------------- /unit_test/tests/user_test.py: -------------------------------------------------------------------------------- 1 | """This is a unit test for checking basic User handling functionality 2 | 3 | NOTE: PLACE THE UNIT TEST FILES IN THE ROOT OF THE REPOSITORY! 4 | Kindly set up the DB for use; refer to the AzureMS repository on how to set up 5 | an Azure-based DB. Then, use the script in the unit_test/SQLScripts folder of this 6 | project to create a tester account. Once it's been successfully run, you can 7 | use this script to test the functionality of Lazuli's APIs. 8 | Note that you may re-run the SQL script to reset all tester accounts 9 | and characters to their baseline values, if desired. 10 | Copyright KOOKIIE Studios 2022. All rights reserved. 11 | """ 12 | import pytest 13 | from lazuli.database import Lazuli 14 | 15 | 16 | # Test one method of fetching user, and use the other as fixture 17 | def test_direct_user_fetch(): 18 | """Returns a tester Account instance""" 19 | # Import DB 20 | try: 21 | azure = Lazuli() # Use defaults - these should be the same as Azure v316 repository defaults 22 | except Exception as e: 23 | raise SystemExit(f"Error has occurred whist attempting to load DB: \n{e}") 24 | user = azure.get_account_by_username("tester0x00") 25 | # Account ID test is here: 26 | assert user.account_id == 90001,\ 27 | f"Error encountered whilst fetching Account by Username:\n" \ 28 | f"Expected: 90001 (Int); Encountered: {user.user_id}, Type: {type(user.user_id)}" 29 | 30 | 31 | @pytest.fixture 32 | def user(): 33 | """Returns a tester Account instance""" 34 | # Import DB 35 | try: 36 | azure = Lazuli() # Use defaults - these should be the same as Azure v316 repository defaults 37 | except Exception as e: 38 | raise SystemExit(f"Error has occurred whist attempting to load DB: \n{e}") 39 | 40 | char_obj = azure.get_char_by_name("tester0x00") 41 | if char_obj is None: 42 | raise SystemExit("CRITICAL ERROR: UNABLE TO FETCH CHARACTER BY NAME! TERMINATING...") 43 | 44 | user_obj = char_obj.account # Get user from Char object 45 | if user_obj is None: 46 | raise SystemExit("CRITICAL ERROR: UNABLE TO FETCH ACCOUNT FROM CHARACTER! TERMINATING...") 47 | return user_obj 48 | 49 | 50 | # User info fetching tests ------------------------------------------------------------------------------- 51 | @pytest.mark.parametrize("expected", ["tester0x00"]) 52 | def test_fetch_acc_name(user, expected): 53 | assert user.username == expected, \ 54 | f"Critical Error: Name test failed! Name: {user.username}; Type: {type(user.username)}" 55 | 56 | 57 | @pytest.mark.parametrize("expected", [0]) 58 | def test_fetch_login_status(user, expected): 59 | assert user.logged_in == expected, \ 60 | f"Login Status test failed! Status: {user.logged_in}; Type: {type(user.logged_in)}" 61 | assert user.is_online() is False, \ 62 | f"Login Status (is_online() method) test failed! Status: {user.is_online()}; Type: {type(user.is_online())}" 63 | 64 | 65 | @pytest.mark.parametrize("expected", [0]) 66 | def test_fetch_ban_status(user, expected): 67 | assert user.banned == expected, \ 68 | f"Ban Status test failed! Status: {user.banned}; Type: {type(user.banned)}" 69 | 70 | 71 | @pytest.mark.parametrize("expected", ["Lorem Ipsum"]) 72 | def test_fetch_ban_reason(user, expected): 73 | assert user.ban_reason == expected, \ 74 | f"Ban Reason test failed!: Expected: {expected} ({type(expected)});\n" \ 75 | f"Encountered: {user.ban_reason}, Type: {type(user.ban_reason)}" 76 | 77 | 78 | @pytest.mark.parametrize("expected", [0]) 79 | def test_fetch_donor_points(user, expected): 80 | assert user.dp == expected, \ 81 | f"DP test failed!\n" \ 82 | f"Expected: {expected} ({type(expected)}); Encountered: {user.dp}, Type: {type(user.dp)}" 83 | 84 | 85 | @pytest.mark.parametrize("expected", [0]) 86 | def test_fetch_maple_points(user, expected): 87 | assert user.maple_points == expected, \ 88 | f"Maple Points test failed!\n" \ 89 | f"Expected: {expected} ({type(expected)}); Encountered: {user.maple_points}, Type: {type(user.maple_points)}" 90 | 91 | 92 | @pytest.mark.parametrize("expected", [0]) 93 | def test_fetch_vote_points(user, expected): 94 | assert user.vp == expected, \ 95 | f"Vote Points test failed!\n" \ 96 | f"Expected: {expected} ({type(expected)}); Encountered: {user.vp}, Type: {type(user.vp)}" 97 | 98 | 99 | @pytest.mark.parametrize("expected", [0]) 100 | def test_fetch_nx(user, expected): 101 | assert user.nx == expected, \ 102 | f"NX test failed!\n" \ 103 | f"Expected: {expected} ({type(expected)}); Encountered: {user.nx}, Type: {type(user.nx)}" 104 | 105 | 106 | @pytest.mark.parametrize("expected", [3]) 107 | def test_fetch_char_slots(user, expected): 108 | assert user.char_slots == expected, \ 109 | f"Character Slot test failed!\nExpected: {expected} ({type(expected)});\n" \ 110 | f"Encountered: {user.char_slots}, Type: {type(user.char_slots)}" 111 | 112 | 113 | # User info setting tests ------------------------------------------------------------------------------- 114 | @pytest.mark.parametrize("before, expected", [ 115 | ("tester0x00", "tester0xFF"), 116 | ]) 117 | def test_name_changes(user, before, expected): 118 | user.username = expected 119 | assert user.username == expected, \ 120 | f"Name setting test failed!\n" \ 121 | f"Expected: {expected} ({type(expected)}); Encountered: {user.username}, Type: {type(user.username)}" 122 | user.username = before # reset to baseline 123 | 124 | 125 | @pytest.mark.parametrize("before, expected", [ 126 | (0, 1), 127 | ]) 128 | def test_unstuck(user, before, expected): 129 | user.logged_in = expected 130 | assert user.logged_in == expected, \ 131 | f"Login status setting test failed!\n" \ 132 | f"Expected: {expected} ({type(expected)}); Encountered: {user.logged_in}, Type: {type(user.logged_in)}" 133 | user.unstuck() 134 | assert user.logged_in == before, \ 135 | f"Unstuck test failed!\n" \ 136 | f"Expected: {before} ({type(before)}); Encountered: {user.logged_in}, Type: {type(user.logged_in)}" 137 | user.logged_in = before # reset to baseline, just in case 138 | 139 | 140 | @pytest.mark.parametrize("before, expected", [ 141 | (0, 1), 142 | ]) 143 | def test_ban_changes(user, before, expected): 144 | user.banned = expected 145 | assert user.banned == expected, \ 146 | f"Ban status setting test failed!\n" \ 147 | f"Expected: {expected} ({type(expected)}); Encountered: {user.banned}, Type: {type(user.banned)}" 148 | user.banned = before # reset to baseline 149 | 150 | 151 | @pytest.mark.parametrize("before, expected", [ 152 | ("Lorem Ipsum", "dolor sit amet"), 153 | ]) 154 | def test_ban_reason_changes(user, before, expected): 155 | user.ban_reason = expected 156 | assert user.ban_reason == expected, \ 157 | f"Ban Reason setting test failed!\n" \ 158 | f"Expected: {expected} ({type(expected)}); Encountered: {user.ban_reason}, Type: {type(user.ban_reason)}" 159 | user.ban_reason = before # reset to baseline 160 | 161 | 162 | # Password change function omitted from checks - insecure function! Deprecated! 163 | 164 | 165 | @pytest.mark.parametrize("before, delta, expected", [ 166 | (314159, 2827433, 3141592), 167 | ]) 168 | def test_dp_changes(user, before, delta, expected): 169 | user.dp = before 170 | assert user.dp == before, \ 171 | f"DP setting test failed!\n" \ 172 | f"Expected: {before} ({type(before)}); Encountered: {user.dp}, Type: {type(user.dp)}" 173 | user.add_dp(delta) 174 | assert user.dp == expected, \ 175 | f"DP count adding test failed!\n" \ 176 | f"Expected: {expected} ({type(expected)}); Encountered: {user.dp}, Type: {type(user.dp)}" 177 | user.dp = 0 # reset to baseline 178 | 179 | 180 | @pytest.mark.parametrize("before, delta, expected", [ 181 | (314159, 2827433, 3141592), 182 | ]) 183 | def test_maple_points_changes(user, before, delta, expected): 184 | user.maple_points = before 185 | assert user.maple_points == before, \ 186 | f"Maple Points setting test failed!\n" \ 187 | f"Expected: {before} ({type(before)}); Encountered: {user.maple_points}, Type: {type(user.maple_points)}" 188 | user.add_maple_points(delta) 189 | assert user.maple_points == expected, \ 190 | f"Maple Points adding test failed!\n" \ 191 | f"Expected: {expected} ({type(expected)}); Encountered: {user.maple_points}, Type: {type(user.maple_points)}" 192 | user.maple_points = 0 # reset to baseline 193 | 194 | 195 | @pytest.mark.parametrize("before, delta, expected", [ 196 | (314159, 2827433, 3141592), 197 | ]) 198 | def test_vp_changes(user, before, delta, expected): 199 | user.vp = before 200 | assert user.vp == before, \ 201 | f"VP setting test failed!\n" \ 202 | f"Expected: {before} ({type(before)}); Encountered: {user.vp}, Type: {type(user.vp)}" 203 | user.add_vp(delta) 204 | assert user.vp == expected, \ 205 | f"VP adding test failed!\n" \ 206 | f"Expected: {expected} ({type(expected)}); Encountered: {user.vp}, Type: {type(user.vp)}" 207 | user.vp = 0 # reset to baseline 208 | 209 | 210 | @pytest.mark.parametrize("before, delta, expected", [ 211 | (314159, 2827433, 3141592), 212 | ]) 213 | def test_nx_changes(user, before, delta, expected): 214 | user.nx = before 215 | assert user.nx == before, \ 216 | f"VP setting test failed!\n" \ 217 | f"Expected: {before} ({type(before)}); Encountered: {user.nx}, Type: {type(user.nx)}" 218 | user.add_nx(delta) 219 | assert user.nx == expected, \ 220 | f"VP adding test failed!\n" \ 221 | f"Expected: {expected} ({type(expected)}); Encountered: {user.nx}, Type: {type(user.nx)}" 222 | user.nx = 0 # reset to baseline 223 | 224 | 225 | @pytest.mark.parametrize("before, delta, expected", [ 226 | (10, 21, 31), 227 | ]) 228 | def test_char_slot_changes(user, before, delta, expected): 229 | user.char_slots = before 230 | assert user.char_slots == before, \ 231 | f"Character Slot setting test failed!\n" \ 232 | f"Expected: {before} ({type(before)}); Encountered: {user.char_slots}, Type: {type(user.char_slots)}" 233 | user.add_char_slots(delta) 234 | assert user.char_slots == expected, \ 235 | f"Character Slot adding test failed!\n" \ 236 | f"Expected: {expected} ({type(expected)}); Encountered: {user.char_slots}, Type: {type(user.char_slots)}" 237 | user.char_slots = 3 # reset to baseline 238 | -------------------------------------------------------------------------------- /lazuli/jobs.yaml: -------------------------------------------------------------------------------- 1 | # This YAML file stores all job IDs known to TEAM SPIRIT - Compiled by KOOKIIE 2 | # Copyright 2020 - 2021 TEAM SPIRIT. All rights reserved. 3 | # Use of this source code is governed by a AGPL-style license that can be found in the LICENSE file. 4 | # This module provides a dictionary mapping all Job IDs to their respective Job names. 5 | # All efforts have been made to trace Job IDs in both GMS and KMS accurately. 6 | 7 | # Explorer Classes (Aventurer in MSEA) 8 | # 0 <= Job ID < 1000 9 | '0': 'Beginner' 10 | 11 | # Explorer Warrior 12 | '100': 'Warrior' # Explorer Warrior 1 (Common) 13 | '110': 'Fighter' 14 | '111': 'Crusader' 15 | '112': 'Hero' 16 | 17 | '120': 'Page' 18 | '121': 'White Knight' 19 | '122': 'Paladin' 20 | 21 | '130': 'Spearman' 22 | '131': 'Dragon Knight' 23 | '132': 'Dark Knight' 24 | 25 | # Explorer Mage 26 | '200': 'Magician' # Explorer Mage 1 (Common) 27 | '210': 'Fire Poison Wizard' 28 | '211': 'Fire Poison Mage' 29 | '212': 'Fire Poison Archmage' 30 | 31 | '220': 'Ice Lightning Wizard' 32 | '221': 'Ice Lightning Mage' 33 | '222': 'Ice Lightning Archmage' 34 | 35 | '230': 'Cleric' 36 | '231': 'Priest' 37 | '232': 'Bishop' 38 | 39 | # Explorer Bowmen 40 | '300': 'Archer' # Explorer Bowman 1 (Common) 41 | '310': 'Hunter' 42 | '311': 'Ranger' 43 | '312': 'Bowmaster' 44 | 45 | '320': 'Cross Bowman' 46 | '321': 'Sniper' 47 | '322': 'Marksman' 48 | 49 | # Special Explorer: Pathfinder 50 | '301': 'Pathfinder' # PF 1 - Not sure why they broke their own conventions 51 | '330': 'Pathfinder' # PF 2 52 | '331': 'Pathfinder' # PF 3 53 | '332': 'Pathfinder' # PF 4 54 | 55 | # Explorer Thieves 56 | '400': 'Rogue' # Explorer Thieves 1 (Common) 57 | '410': 'Assassin' 58 | '411': 'Hermit' 59 | '412': 'Night Lord' 60 | 61 | '420': 'Bandit' 62 | '421': 'Chief Bandit' 63 | '422': 'Shadower' 64 | 65 | # Special Explorer: Dual Blades 66 | '430': 'Blade Recruit' 67 | '431': 'Blade Acolyte' 68 | '432': 'Blade Specialist' 69 | '433': 'Blade Lord' 70 | '434': 'Blade Master' 71 | 72 | # Explorer Pirates 73 | '500': 'Pirate' # Explorer Pirates 1 (Common) 74 | '510': 'Brawler' 75 | '511': 'Marauder' 76 | '512': 'Buccaneer' # (aka Viper in MSEA/KMS) 77 | 78 | '520': 'Gunslinger' 79 | '521': 'Outlaw' 80 | '522': 'Corsair' 81 | 82 | # Special Explorer: Canonneer 83 | '501': 'Cannon Shooter' 84 | '530': 'Cannoneer' 85 | '531': 'Cannon Trooper' 86 | '532': 'Cannon Master' 87 | 88 | # Special Explorer: Jett 89 | '508': 'Jett' # Jett 1 - Not sure why they broke their own conventions 90 | '570': 'Jett' # Jett 2 91 | '571': 'Jett' # Jett 3 92 | '572': 'Jett' # Jett 4 93 | 94 | 95 | # KoC Classes 96 | # 1000 <= Job ID < 2000, 5000 <= Job ID < 6000 (Mihile) 97 | '1000': 'Noblesse' # KoC Beginner 98 | 99 | # Soul Master in MSEA/KMS 100 | '1100': 'Dawn Warrior' # DW 1 101 | '1110': 'Dawn Warrior' # DW 2 102 | '1111': 'Dawn Warrior' # DW 3 103 | '1112': 'Dawn Warrior' # DW 4 104 | 105 | # Flame Wizard in MSEA/KMS 106 | '1200': 'Blaze Wizard' # BW 1 107 | '1210': 'Blaze Wizard' # BW 2 108 | '1211': 'Blaze Wizard' # BW 3 109 | '1212': 'Blaze Wizard' # BW 4 110 | 111 | # Wind Breaker in MSEA/KMS 112 | '1300': 'Wind Archer' # WA 1 113 | '1310': 'Wind Archer' # WA 2 114 | '1311': 'Wind Archer' # WA 3 115 | '1312': 'Wind Archer' # WA 4 116 | 117 | '1400': 'Night Walker' # NW 1 118 | '1410': 'Night Walker' # NW 2 119 | '1411': 'Night Walker' # NW 3 120 | '1412': 'Night Walker' # NW 4 121 | 122 | # Striker in MSEA/KMS 123 | '1500': 'Thunder Breaker' # TB 1 124 | '1510': 'Thunder Breaker' # TB 2 125 | '1511': 'Thunder Breaker' # TB 3 126 | '1512': 'Thunder Breaker' # TB 4 127 | 128 | 129 | # Heroes of Maple/Legends Classes 130 | # The 6 Hero classes (M, A, P, L, E, S) have 200X beginner job IDs 131 | # 2000 <= Job ID < 3000 132 | '2000': 'Aran' # Aran Beginner (aka Legend) 133 | '2100': 'Aran' # Aran 1 134 | '2110': 'Aran' # Aran 2 135 | '2111': 'Aran' # Aran 3 136 | '2112': 'Aran' # Aran 4 137 | 138 | '2001': 'Evan' # Evan Beginner 139 | '2200': 'Evan' # Evan 1 140 | '2210': 'Evan' # Evan 2 141 | '2211': 'Evan' # Evan 3 142 | '2212': 'Evan' # Evan 4 143 | '2213': 'Evan' # Evan 5 144 | '2214': 'Evan' # Evan 6 145 | '2215': 'Evan' # Evan 7 146 | '2216': 'Evan' # Evan 8 147 | '2217': 'Evan' # Evan 9 148 | '2218': 'Evan' # Evan 10 149 | 150 | '2002': 'Mercedes' # Mercedes Beginner 151 | '2300': 'Mercedes' # Mercedes 1 152 | '2310': 'Mercedes' # Mercedes 2 153 | '2311': 'Mercedes' # Mercedes 3 154 | '2312': 'Mercedes' # Mercedes 4 155 | 156 | '2003': 'Phantom' # Phantom Beginner 157 | '2400': 'Phantom' # Phantom 1 158 | '2410': 'Phantom' # Phantom 2 159 | '2411': 'Phantom' # Phantom 3 160 | '2412': 'Phantom' # Phantom 4 161 | 162 | # Eunwol in MSEA/KMS 163 | '2005': 'Shade' # Shade Beginner 164 | '2500': 'Shade' # Shade 1 165 | '2510': 'Shade' # Shade 2 166 | '2511': 'Shade' # Shade 3 167 | '2512': 'Shade' # Shade 4 168 | 169 | '2004': 'Luminous' # Luminous Beginner 170 | '2700': 'Luminous' # Luminous 1 171 | '2710': 'Luminous' # Luminous 2 172 | '2711': 'Luminous' # Luminous 3 173 | '2712': 'Luminous' # Luminous 4 174 | 175 | 176 | # Resistance Classes 177 | # 3000 <= Job ID < 4000 178 | '3000': 'Citizen' # Non-Demon/Xenon Resistance 179 | 180 | # Resistance classes have 300X beginner job IDs 181 | '3001': 'Demon' # Demon classes Beginner (Demons have their own beginner classes) 182 | '3100': 'Demon Slayer' # DS 1 183 | '3110': 'Demon Slayer' # DS 2 184 | '3111': 'Demon Slayer' # DS 3 185 | '3112': 'Demon Slayer' # DS 4 186 | 187 | '3101': 'Demon Avenger' # DA 1 188 | '3120': 'Demon Avenger' # DA 2 189 | '3121': 'Demon Avenger' # DA 3 190 | '3122': 'Demon Avenger' # DA 4 191 | 192 | '3200': 'Battle Mage' # BaM 1 193 | '3210': 'Battle Mage' # BaM 2 194 | '3211': 'Battle Mage' # BaM 3 195 | '3212': 'Battle Mage' # BaM 4 196 | 197 | '3300': 'Wild Hunter' # WH 1 198 | '3310': 'Wild Hunter' # WH 2 199 | '3311': 'Wild Hunter' # WH 3 200 | '3312': 'Wild Hunter' # WH 4 201 | 202 | '3500': 'Mechanic' # Mech 1 203 | '3510': 'Mechanic' # Mech 2 204 | '3511': 'Mechanic' # Mech 3 205 | '3512': 'Mechanic' # Mech 4 206 | 207 | '3002': 'Xenon' # Xenon Beginner (Xenons have their own beginner class) 208 | '3600': 'Xenon' # Xenon 1 209 | '3610': 'Xenon' # Xenon 2 210 | '3611': 'Xenon' # Xenon 3 211 | '3612': 'Xenon' # Xenon 4 212 | 213 | '3700': 'Blaster' # Blaster 1 214 | '3710': 'Blaster' # Blaster 1 215 | '3711': 'Blaster' # Blaster 1 216 | '3712': 'Blaster' # Blaster 1 217 | 218 | 219 | # Sengoku Classes 220 | # Sengoku classes have 400X beginner job IDs 221 | # 4000 <= Job ID < 5000 222 | '4001': 'Hayato' # Hayato Beginner 223 | '4100': 'Hayato' # Hayato 1 224 | '4110': 'Hayato' # Hayato 2 225 | '4111': 'Hayato' # Hayato 3 226 | '4112': 'Hayato' # Hayato 4 227 | 228 | '4002': 'Kanna' # Kanna Beginner 229 | '4200': 'Kanna' # Kanna 1 230 | '4210': 'Kanna' # Kanna 2 231 | '4211': 'Kanna' # Kanna 3 232 | '4212': 'Kanna' # Kanna 4 233 | 234 | 235 | # Special KoC 236 | # 5000 <= Job ID < 6000 237 | '5000': 'Mihile' # Mihile Beginner (aka Nameless Warden) 238 | '5100': 'Mihile' # Mihile 1 239 | '5110': 'Mihile' # Mihile 2 240 | '5111': 'Mihile' # Mihile 3 241 | '5112': 'Mihile' # Mihile 4 242 | 243 | 244 | # Nova Classes 245 | # 5600 <= Job ID < 7000 246 | '6000': 'Kaiser' # Kaiser Beginner 247 | '6100': 'Kaiser' # Kaiser 1 248 | '6110': 'Kaiser' # Kaiser 2 249 | '6111': 'Kaiser' # Kaiser 3 250 | '6112': 'Kaiser' # Kaiser 4 251 | 252 | '6001': 'Angelic Buster' # AB Beginner 253 | '6500': 'Angelic Buster' # AB 1 254 | '6510': 'Angelic Buster' # AB 2 255 | '6511': 'Angelic Buster' # AB 3 256 | '6512': 'Angelic Buster' # AB 4 257 | 258 | '6002': 'Cadena' # Cadena Beginner 259 | '6400': 'Cadena' # Cadena 1 260 | '6410': 'Cadena' # Cadena 2 261 | '6411': 'Cadena' # Cadena 3 262 | '6412': 'Cadena' # Cadena 4 263 | 264 | '6003': 'Kain' # Kain Beginner 265 | '6300': 'Kain' # Kain 1 266 | '6310': 'Kain' # Kain 2 267 | '6311': 'Kain' # Kain 3 268 | '6312': 'Kain' # Kain 4 269 | 270 | 271 | # Child of God Classes 272 | # 10000 <= Job ID < 11000 273 | '10000': 'Zero' # Zero Beginner 274 | '10100': 'Zero' # Zero 1 275 | '10110': 'Zero' # Zero 2 276 | '10111': 'Zero' # Zero 3 277 | '10112': 'Zero' # Zero 4 278 | 279 | 280 | # Child of Furry Classes 281 | # 11000 <= Job ID < 12000 282 | '11000': 'Beast Tamer' # Beast Tamer Beginner 283 | '11200': 'Beast Tamer' # Beast Tamer 1 284 | '11210': 'Beast Tamer' # Beast Tamer 2 285 | '11211': 'Beast Tamer' # Beast Tamer 3 286 | '11212': 'Beast Tamer' # Beast Tamer 4 287 | 288 | 289 | # Special: Kinesis 290 | # 14000 <= Job ID < 15000 291 | '14000': 'Kinesis' # Kinesis Beginner 292 | '14200': 'Kinesis' # Kinesis 1 293 | '14210': 'Kinesis' # Kinesis 2 294 | '14211': 'Kinesis' # Kinesis 3 295 | '14212': 'Kinesis' # Kinesis 4 296 | 297 | 298 | # Flora Classes 299 | # 15000 <= Job ID < 16000 300 | '15000': 'Illium' # Illium Beginner 301 | '15200': 'Illium' # Illium 1 302 | '15210': 'Illium' # Illium 2 303 | '15211': 'Illium' # Illium 3 304 | '15212': 'Illium' # Illium 4 305 | 306 | '15001': 'Ark' # Ark Beginner 307 | '15500': 'Ark' # Ark 1 308 | '15510': 'Ark' # Ark 2 309 | '15511': 'Ark' # Ark 3 310 | '15512': 'Ark' # Ark 4 311 | 312 | '15002': 'Adele' # Adele Beginner 313 | '15100': 'Adele' # Adele 1 314 | '15110': 'Adele' # Adele 2 315 | '15111': 'Adele' # Adele 3 316 | '15112': 'Adele' # Adele 4 317 | 318 | 319 | # Anima Classes 320 | # 16000 <= Job ID < 17000 321 | '16000': 'Hoyoung' # Hoyoung Beginner 322 | '16400': 'Hoyoung' # Hoyoung 1 323 | '16410': 'Hoyoung' # Hoyoung 2 324 | '16411': 'Hoyoung' # Hoyoung 3 325 | '16412': 'Hoyoung' # Hoyoung 4 326 | 327 | '16001': 'Lara' # Lara Beginner 328 | '16200': 'Lara' # Lara 1 329 | '16210': 'Lara' # Lara 2 330 | '16211': 'Lara' # Lara 3 331 | '16212': 'Lara' # Lara 4 332 | 333 | 334 | # Special jobs 335 | # Misc Job IDs that are either deprecated or used for other things 336 | '800': 'Manager' 337 | '900': 'GM' 338 | '910': 'Super GM' 339 | '8000': 'Riding Skills' 340 | '9000': 'Additional Skills' 341 | '40000': 'V-Skills' 342 | 343 | 344 | # Special: Event Classes 345 | # 13000 <= Job ID < 14000 346 | '13000': 'Pink Bean' # Pink Bean Beginner 347 | '13100': 'Pink Bean' # Pink Bean 1 348 | 349 | '13001': 'Yeti' # Yeti Beginner 350 | '13500': 'Yeti' # Yeti 1 351 | -------------------------------------------------------------------------------- /lazuli/inventory.py: -------------------------------------------------------------------------------- 1 | """This module holds the Inventory class for the lazuli package. 2 | 3 | Copyright 2022 TEAM SPIRIT. All rights reserved. 4 | Use of this source code is governed by a AGPL-style license that can be found 5 | in the LICENSE file. 6 | Refer to database.py or the project wiki on GitHub for usage examples. 7 | """ 8 | from typing import Any, Optional 9 | import mysql.connector as con 10 | import lazuli.utility as utils 11 | 12 | 13 | class Inventory: 14 | """`Inventory` object; quasi-models AzureMS inventories. 15 | 16 | The instance method `Lazuli::get_char_by_name(name)` creates a `Character` 17 | object; as part of lazuli `Character` object instantiation, an `Inventory` 18 | object instance containing inventory attributes of the character with 19 | IGN `name` in the connected AzureMS-based database is created. 20 | This class contains the appropriate getter methods for said attributes. 21 | As a consequence of the inherent complexity of MapleStory's item system, 22 | for safety reasons, this module offers NO inventory-write operations 23 | (aka setter methods). 24 | """ 25 | 26 | def __init__(self, character_id: int, db_config: dict[str, str]) -> None: 27 | """`Inventory` object; quasi-models AzureMS inventories. 28 | 29 | Modelled after SwordieDB project's `Inventory` class init method. 30 | This `Inventory` object attempts to model attributes of all 6 of 31 | AzureMS's inventory types, using a custom object. 32 | Every inventory attribute is a `dict` of `dict`, 33 | the latter of which models the contents of the `inventoryitems` table 34 | in a AzureMS-based database. 35 | 36 | Args: 37 | 38 | character_id (`int`): Represents the foreign key 39 | db_config (`dict`): Represents the protected attributes from a `Lazuli` object 40 | """ 41 | self._character_id = character_id 42 | self._database_config = db_config 43 | 44 | # `list[`dict`]`: Represents all inventory/equipped items 45 | self._all_items = self.fetch_all_inv_items() 46 | 47 | self._equip_inv = self.init_equip_items() 48 | self._use_inv = self.init_use_inv() 49 | self._etc_inv = self.init_etc_inv() 50 | self._cash_inv = self.init_cash_inv() 51 | self._install_inv = self.init_install_inv() 52 | 53 | self._equipped_inv = self.init_equipped_inv() 54 | 55 | 56 | @staticmethod 57 | def has_item_in_inv_type( 58 | inv_type: dict[int, dict[str, Optional[int]]], 59 | item_id: int, 60 | ) -> bool: 61 | """Checks whether the particular tab of the inventory has an item 62 | 63 | Generic method used by `Inventory::has_item_in_XXX()` methods, 64 | and the `Inventory::is_equipping()` method. Iterates through the dictionary 65 | of items associated with the specified tab, and check if 66 | the provided item ID can be found as a value. 67 | 68 | Args: 69 | 70 | inv_type (`dict`): Represents the inventory tab to search 71 | item_id (`int`): Represents the ID of the item to search for 72 | 73 | Returns: 74 | A `bool`, representing whether the specified item was found 75 | """ 76 | for bag_index in inv_type: 77 | if inv_type[bag_index]['itemid'] == item_id: 78 | return True 79 | return False 80 | 81 | @property 82 | def equip_inv(self) -> dict[int, dict[str, Optional[int]]]: 83 | """`dict` of `dict`: Represents the in-game items contained within the EQUIP tab 84 | 85 | The key is the position of the item in the inventory tab, and the value 86 | contains the item attributes. 87 | """ 88 | return self._equip_inv 89 | 90 | @property 91 | def consume_inv(self) -> dict[int, dict[str, Optional[int]]]: 92 | """`dict` of `dict`: Represents the in-game items contained within the USE tab 93 | 94 | The key is the position of the item in the inventory tab, and the value 95 | contains the item attributes. 96 | """ 97 | return self._use_inv 98 | 99 | @property 100 | def etc_inv(self) -> dict[int, dict[str, Optional[int]]]: 101 | """`dict` of `dict`: Represents the in-game items contained within the ETC tab 102 | 103 | The key is the position of the item in the inventory tab, and the value 104 | contains the item attributes. 105 | """ 106 | return self._etc_inv 107 | 108 | @property 109 | def cash_inv(self) -> dict[int, dict[str, Optional[int]]]: 110 | """`dict` of `dict`: Represents the in-game items contained within the CASH tab 111 | 112 | The key is the position of the item in the inventory tab, and the value 113 | contains the item attributes. 114 | """ 115 | return self._cash_inv 116 | 117 | @property 118 | def install_inv(self) -> dict[int, dict[str, Optional[int]]]: 119 | """`dict` of `dict`: Represents the in-game items contained within the SETUP tab 120 | 121 | The key is the position of the item in the inventory tab, and the value 122 | contains the item attributes. 123 | """ 124 | return self._install_inv 125 | 126 | @property 127 | def equipped_inv(self) -> dict[int, dict[str, Optional[int]]]: 128 | """`dict` of `dict`: Represents the in-game items currently equipped by the character 129 | 130 | The key is the position of the item in the inventory tab, and the value 131 | contains the item attributes. 132 | """ 133 | return self._equipped_inv 134 | 135 | def fetch_all_inv_items(self) -> list[dict[str, Any]]: 136 | """Fetch all items associated with the character 137 | 138 | Returns: 139 | A `list` of `dict` representing all inventory/equipped items 140 | """ 141 | try: 142 | database = con.connect( 143 | host=self._database_config['host'], 144 | user=self._database_config['user'], 145 | password=self._database_config['password'], 146 | database=self._database_config['schema'], 147 | port=self._database_config['port'], 148 | charset=self._database_config['charset'] 149 | ) 150 | cursor = database.cursor(dictionary=True) 151 | cursor.execute( 152 | f"SELECT * FROM `inventoryitems` WHERE `characterid` = " 153 | f"'{self._character_id}'" 154 | ) 155 | inventory = cursor.fetchall() 156 | return inventory 157 | except Exception as e: 158 | print(f"ERROR: Unable to fetch inventory items\n{e}") 159 | 160 | def load_inv(self, inv_type: int) -> dict[int, dict[str, Optional[int]]]: 161 | """Given an inventory type, fetch every item associated with it 162 | 163 | Examples of inventory types: `-1`, `1`, `2`, `3`, `4`, `5` 164 | 165 | Args: 166 | 167 | inv_type (`int`): Representation of the inventory type encoded in the database 168 | 169 | Returns: 170 | A `dict` of `dict`, representing all the in-game items 171 | that are in the specified inventory type 172 | 173 | Raises: 174 | A generic error on failure 175 | """ 176 | try: 177 | all_items = self._all_items 178 | inventory = [] 179 | # Extract the relevant inventory for the type 180 | for item in all_items: 181 | if item.get("inventorytype") == inv_type: 182 | inventory.append(item) 183 | 184 | inv = {} 185 | 186 | for items in inventory: 187 | # More to add if needed. 188 | bag_index = items["position"] 189 | item_id = items["itemid"] 190 | quantity = items["quantity"] 191 | is_cash = items["isCash"] 192 | inventory_type = items["inventorytype"] 193 | item_stats = { 194 | "itemid": item_id, 195 | "quantity": quantity, # Never used 196 | "inventorytype": inventory_type, 197 | "iscash": is_cash # Never used 198 | } 199 | inv[bag_index] = item_stats 200 | # Use the bag index (i.e. position of the item in the inventory) 201 | # as the key for the dictionary 202 | return inv 203 | except Exception as e: 204 | print(f"ERROR: Unable to load inventory type {inv_type}\n{e}") 205 | 206 | def init_equip_items(self) -> dict[int, dict[str, Optional[int]]]: 207 | """Extract items belonging to the EQUIP tab from the full list of items""" 208 | return self.load_inv(utils.get_inv_type_by_name("equip")) 209 | 210 | def init_use_inv(self) -> dict[int, dict[str, Optional[int]]]: 211 | """Extract items belonging to the USE tab from the full list of items""" 212 | return self.load_inv(utils.get_inv_type_by_name("use")) 213 | 214 | def init_etc_inv(self) -> dict[int, dict[str, Optional[int]]]: 215 | """Extract items belonging to the ETC tab from the full list of items""" 216 | return self.load_inv(utils.get_inv_type_by_name("etc'")) 217 | 218 | def init_cash_inv(self) -> dict[int, dict[str, Optional[int]]]: 219 | """Extract items belonging to the CASH tab from the full list of items""" 220 | return self.load_inv(utils.get_inv_type_by_name("cash")) 221 | 222 | def init_equipped_inv(self) -> dict[int, dict[str, Optional[int]]]: 223 | """Extract items that are currently equipped from the full list of items""" 224 | return self.load_inv(utils.get_inv_type_by_name("equipped")) 225 | 226 | def init_install_inv(self) -> dict[int, dict[str, Optional[int]]]: 227 | """Extract items belonging to the SETUP tab from the full list of items""" 228 | return self.load_inv(utils.get_inv_type_by_name("setup")) 229 | 230 | def has_item_in_equip(self, item_id: int) -> bool: 231 | """Checks whether the EQUIP tab of the inventory has an item 232 | 233 | Uses `Inventory::has_item_in_inv_type()` 234 | 235 | Args: 236 | 237 | item_id (`int`): Item ID of the item to check for 238 | 239 | Returns: 240 | A `bool`, representing whether the specified item was found 241 | """ 242 | return self.has_item_in_inv_type(self.equip_inv, item_id) 243 | 244 | def has_item_in_consume(self, item_id: int) -> bool: 245 | """Checks whether the USE tab of the inventory has an item 246 | 247 | Uses `Inventory::has_item_in_inv_type()` 248 | 249 | Args: 250 | 251 | item_id (`int`): Item ID of the item to check for 252 | 253 | Returns: 254 | A `bool`, representing whether the specified item was found 255 | """ 256 | return self.has_item_in_inv_type(self.consume_inv, item_id) 257 | 258 | def has_item_in_etc(self, item_id: int) -> bool: 259 | """Checks whether the ETC tab of the inventory has an item 260 | 261 | Uses `Inventory::has_item_in_inv_type()` 262 | 263 | Args: 264 | 265 | item_id (`int`): Item ID of the item to check for 266 | 267 | Returns: 268 | A `bool`, representing whether the specified item was found 269 | """ 270 | return self.has_item_in_inv_type(self.etc_inv, item_id) 271 | 272 | def has_item_in_install(self, item_id: int) -> bool: 273 | """Checks whether the SETUP tab of the inventory has an item 274 | 275 | Uses `Inventory::has_item_in_inv_type()` 276 | 277 | Args: 278 | 279 | item_id (`int`): Item ID of the item to check for 280 | 281 | Returns: 282 | A `bool`, representing whether the specified item was found 283 | """ 284 | return self.has_item_in_inv_type(self.install_inv, item_id) 285 | 286 | def has_item_in_cash(self, item_id: int) -> bool: 287 | """Checks whether the CASH tab of the inventory has an item 288 | 289 | Uses `Inventory::has_item_in_inv_type()` 290 | 291 | Args: 292 | 293 | item_id (`int`): Item ID of the item to check for 294 | 295 | Returns: 296 | A `bool`, representing whether the specified item was found 297 | """ 298 | return self.has_item_in_inv_type(self.cash_inv, item_id) 299 | 300 | def is_equipping(self, item_id: int) -> bool: 301 | """Checks whether an item is currently equipped 302 | 303 | Uses `Inventory::has_item_in_inv_type()` to check whether the 304 | EQUIP window (i.e. Hotkey "E") has an item (i.e. item is equipped) 305 | 306 | Args: 307 | 308 | item_id (`int`): Item ID of the item to check for 309 | 310 | Returns: 311 | A `bool`, representing whether the specified item was found 312 | """ 313 | return self.has_item_in_inv_type(self.equipped_inv, item_id) 314 | -------------------------------------------------------------------------------- /lazuli/account.py: -------------------------------------------------------------------------------- 1 | """This module holds the Account class for the lazuli package. 2 | 3 | Copyright 2022 TEAM SPIRIT. All rights reserved. 4 | Use of this source code is governed by a AGPL-style license that can be found 5 | in the LICENSE file. 6 | Refer to `database.py` or the project wiki on GitHub for usage examples. 7 | """ 8 | 9 | from typing import Any 10 | import lazuli.utility as utils 11 | 12 | 13 | class Account: 14 | """`Account` object; models AzureMS accounts. 15 | 16 | Using instance method `Lazuli::get_account_by_username(username)` or 17 | the `Lazuli::get_char_by_name(name).account` getter will create an 18 | Account object instance with attributes identical to the account 19 | with username `username` (or IGN `name` for the latter) in 20 | the connected AzureMS-based database. This class contains the 21 | appropriate getter and setter methods for said attributes. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | account_info: dict[str, Any], 27 | database_config: dict[str, str], 28 | ) -> None: 29 | """Emulates how the `Account` object is handled by a game server 30 | 31 | Args: 32 | 33 | account_info (`dict`): Represents user attributes, formatted in AzureMS style 34 | database_config (`dict`): Represents the of protected attributes from a `Lazuli` object 35 | """ 36 | 37 | self._account_info = account_info 38 | self._database_config = database_config 39 | 40 | self._account_id: int = 0 # Primary Key - Do NOT set 41 | self._username: str = "" # varchar(64) 42 | self._logged_in: int = 0 # int(1) and not bool for some reason 43 | self._banned: int = 0 # int(1) and not bool for some reason 44 | self._ban_reason: str = "" # text 45 | # The GM attribute has nothing to do with 46 | # in-game GM command level - excluded for now 47 | # self._gm: int = 0 48 | self._nx: int = 0 49 | self._maple_points: int = 0 50 | self._vp: int = 0 51 | self._dp: int = 0 52 | self._char_slots: int = 0 53 | 54 | self.init_account_stats() 55 | 56 | def init_account_stats(self) -> None: 57 | """Initialises `Account` instance attributes' values. 58 | 59 | Runs near the end of `Account::__init__(account_info, database_config)`. 60 | Assign values contained in `account_info` (a dictionary of 61 | account-related attributes from AzureMS's DB) to the `Account` object's 62 | corresponding attributes. 63 | """ 64 | self._account_id = self._account_info['id'] 65 | self._username = self._account_info['name'] 66 | self._logged_in = self._account_info['loggedin'] 67 | self._banned = self._account_info['banned'] 68 | self._ban_reason = self._account_info['banreason'] 69 | # self._gm = self._account_info['gm'] # See __innit__ 70 | self._nx = self._account_info['nxCash'] 71 | self._maple_points = self._account_info['mPoints'] 72 | self._vp = self._account_info['vpoints'] 73 | # 'realcash' corresponding to DP is a guess - Might be wrong! 74 | self._dp = self._account_info['realcash'] 75 | self._char_slots = self._account_info['chrslot'] 76 | 77 | @property 78 | def account_id(self) -> int: 79 | """`int`: Represents Primary Key for account - Do **NOT** set manually""" 80 | return self._account_id # Primary Key; DO NOT set 81 | 82 | @property 83 | def username(self) -> str: 84 | """`str`: Represents account username 85 | 86 | This is a `varchar(64)` in the database. 87 | 88 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 89 | """ 90 | return self._username 91 | 92 | @username.setter 93 | def username(self, new_name: str) -> None: 94 | # Check for length: 95 | if len(str(new_name)) > 64: 96 | # Message to be passed along on failure: 97 | raise ValueError("That name is too long!") 98 | else: 99 | # Check for clashes 100 | data = utils.get_db_all_hits( 101 | self._database_config, 102 | f"SELECT * FROM `accounts` WHERE `name` = '{new_name}'" 103 | ) 104 | if not data: 105 | # if the list of accounts with clashing names is not empty 106 | self.set_stat_by_column("name", new_name) # set name in DB 107 | # then refresh instance variable in memory: 108 | self._username = new_name 109 | else: 110 | # Message to be passed along on failure: 111 | raise ValueError("That name is already taken!") 112 | 113 | @property 114 | def logged_in(self) -> int: 115 | """`int`: Represents the login status of the account 116 | 117 | Note that in the database, this is a `int(1)`, 118 | and not `bool`/`bit`/`char` for some reason. 119 | 120 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 121 | """ 122 | return self._logged_in 123 | 124 | @logged_in.setter 125 | def logged_in(self, value: int) -> None: 126 | if value > 127: # DB only accepts 1-byte int 127 | raise ValueError( 128 | "That `logged_in` value is too large! " 129 | "Stick to either 0 or 1!") 130 | 131 | else: 132 | self.set_stat_by_column("loggedin", value) # Use with caution! 133 | self._logged_in = value 134 | 135 | @property 136 | def banned(self) -> int: 137 | """`int`: Represents the ban status of the account 138 | 139 | Note that in the database, this is a `int(1)`, 140 | and not `bool`/`bit`/`char` for some reason. 141 | 142 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 143 | """ 144 | return self._banned 145 | 146 | @banned.setter 147 | def banned(self, value: int) -> None: 148 | if value > 127: # DB only accepts 1-byte int 149 | raise ValueError( 150 | "That `banned` value is too large! " 151 | "Stick to either 0 or 1!") 152 | else: 153 | self.set_stat_by_column("banned", value) # Use with caution! 154 | self._banned = value 155 | 156 | @property 157 | def ban_reason(self) -> str: 158 | """`str`: Represents the account ban reason 159 | 160 | This is a `text` in the database. 161 | 162 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 163 | """ 164 | return self._ban_reason 165 | 166 | @ban_reason.setter 167 | def ban_reason(self, value: str) -> None: 168 | self.set_stat_by_column("banreason", value) # type `text`; 65k chars 169 | self._ban_reason = value 170 | 171 | @property 172 | def nx(self) -> int: 173 | """`int`: Represents the amount of NX Prepaid the user has 174 | 175 | Note that the setter does not allow `int` values larger than 32-bit 176 | signed `int`. 177 | 178 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 179 | """ 180 | return self._nx 181 | 182 | @nx.setter 183 | def nx(self, value: int) -> None: 184 | if value > 2147483647: 185 | raise ValueError("Invalid input! Please keep NX within 2.1b!") 186 | else: 187 | self.set_stat_by_column("nxCash", value) 188 | self._nx = value 189 | 190 | def add_nx(self, amount: int) -> None: 191 | """Adds the specified amount to the current NX pool 192 | 193 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 194 | 195 | Args: 196 | 197 | amount (`int`): Represents the amount of NX to be added to the NX pool 198 | """ 199 | new_nx = int(amount) + self.nx 200 | self.nx = new_nx 201 | 202 | @property 203 | def maple_points(self) -> int: 204 | """`int`: Represents the amount of Maple Points the user has 205 | 206 | Note that the setter does not allow `int` values larger than 32-bit 207 | signed `int`. 208 | 209 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 210 | """ 211 | return self._maple_points 212 | 213 | @maple_points.setter 214 | def maple_points(self, value: int) -> None: 215 | if value > 2147483647: 216 | raise ValueError( 217 | "Invalid input! " 218 | "Please keep Maple Points within 2.1b!") 219 | else: 220 | self.set_stat_by_column("mPoints", value) 221 | self._maple_points = value 222 | 223 | def add_maple_points(self, amount: int) -> None: 224 | """Adds the specified amount to the current Maple Points pool 225 | 226 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 227 | 228 | Args: 229 | 230 | amount (`int`): Represents the number of Maple Points to be added to the current pool 231 | """ 232 | new_maple_points = int(amount) + self.maple_points 233 | self.maple_points = new_maple_points 234 | 235 | @property 236 | def vp(self) -> int: 237 | """`int`: Represents the amount of Vote Points the user has 238 | 239 | Note that the setter does not allow `int` values larger than 32-bit 240 | signed `int`. 241 | 242 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 243 | """ 244 | return self._vp 245 | 246 | @vp.setter 247 | def vp(self, value: int) -> None: 248 | if value > 2147483647: 249 | raise ValueError( 250 | "Invalid input! " 251 | "Please keep Vote Points within 2.1b!") 252 | else: 253 | self.set_stat_by_column("vpoints", value) 254 | self._vp = value 255 | 256 | def add_vp(self, amount: int) -> None: 257 | """Adds the specified amount to the current VP count 258 | 259 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 260 | 261 | Args: 262 | 263 | amount (`int`): Represents the number of vote points (VP) to be added to the current VP count 264 | """ 265 | new_vp = int(amount) + self.vp 266 | self.vp = new_vp 267 | 268 | @property 269 | def dp(self) -> int: 270 | """`int`: Represents the amount of Donation Points the user has 271 | 272 | Note that the setter does not allow `int` values larger than 32-bit 273 | signed `int`. 274 | 275 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 276 | """ 277 | return self._dp 278 | 279 | @dp.setter 280 | def dp(self, value: int) -> None: 281 | if value > 2147483647: 282 | raise ValueError("Invalid input! Please keep DPs within 2.1b!") 283 | else: 284 | self.set_stat_by_column("realcash", value) 285 | self._dp = value 286 | 287 | def add_dp(self, amount: int) -> None: 288 | """Adds the specified amount to the current DP count 289 | 290 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 291 | 292 | Args: 293 | 294 | amount (`int`): Represents the number of DPs to be added to the current DP count 295 | """ 296 | new_dp = int(amount) + self.dp 297 | self.dp = new_dp 298 | 299 | @property 300 | def char_slots(self) -> int: 301 | """`int`: Represents the number of character slots the user has 302 | 303 | Note that the setter does not allow `int` values larger than `52`. 304 | 305 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 306 | """ 307 | return self._char_slots 308 | 309 | @char_slots.setter 310 | def char_slots(self, value: int) -> None: 311 | if value > 52: 312 | raise ValueError( 313 | "Invalid input! " 314 | "Please keep Character Slots within 52!") 315 | else: 316 | self.set_stat_by_column("chrslot", value) 317 | self._char_slots = value 318 | 319 | def add_char_slots(self, amount: int) -> None: 320 | """Adds the specified amount to the current character slot count 321 | 322 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 323 | 324 | Args: 325 | 326 | amount (`int`): Represents the number of slots to be added to the current count 327 | """ 328 | new_count = int(amount) + self.char_slots 329 | self.char_slots = new_count 330 | 331 | def _get_char_list(self) -> list[dict[str, Any]]: 332 | """Fetch all rows with the same account ID, from DB 333 | 334 | Returns: 335 | `list`, representing all characters in the same account. 336 | Defaults to False in the event of an error during execution 337 | 338 | Raises: 339 | A generic error on failure 340 | """ 341 | data = utils.get_db_all_hits( 342 | self._database_config, 343 | f"SELECT * FROM `characters` WHERE `accountid` = {self.account_id}" 344 | ) 345 | return data 346 | 347 | @property 348 | def characters(self) -> list[str]: 349 | """`list[str]`: Represents the IGN of all characters in the same account 350 | 351 | Returns: 352 | A `list`, containing character names of all characters 353 | in the same account. 354 | Defaults to False in the event of an error during execution 355 | 356 | Raises: 357 | A generic error on failure 358 | """ 359 | char_data = self._get_char_list() 360 | if not char_data: # empty list 361 | return char_data 362 | return utils.extract_name(char_data) 363 | 364 | @property 365 | def free_char_slots(self) -> int: 366 | """`int`: Represents the number of free character slots the user has""" 367 | total_slots = self._char_slots 368 | used_slots = len(self._get_char_list()) # count the number of chars 369 | return total_slots - used_slots 370 | 371 | def is_online(self) -> bool: 372 | """Checks if the `loggedin` column is greater than `0` 373 | 374 | Returns: 375 | A `bool`, representing the online status of the account 376 | """ 377 | if int(self.logged_in) > 0: 378 | return True 379 | return False 380 | 381 | def unstuck(self) -> None: 382 | """Sets `loggedin` column in database to `0` 383 | 384 | This un-stucks the account, since server checks the `loggedin` value 385 | to decided whether they are "logged in". 386 | 387 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 388 | """ 389 | self.logged_in = 0 390 | 391 | def change_password(self, new_pass: str) -> None: 392 | """Changes the current password to the given one. 393 | 394 | - **WARNING**: DEPRECATED! 395 | - **WARNING**: INHERENTLY UNSAFE! 396 | 397 | Azure316 now hashes passwords, as of [5dc6d6e](https://github.com/SoulGirlJP/AzureV316/commit/5dc6d6e2439195618337d02593512c515ab5de58). 398 | 399 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 400 | 401 | Args: 402 | 403 | new_pass (`str`): Represents the new password 404 | """ 405 | self.set_stat_by_column("password", new_pass) 406 | 407 | def get_deep_copy(self) -> list[str]: 408 | """Returns all known info about the `Account` as a list""" 409 | attributes = [ 410 | f"Username {self.username}'s attributes:\n", 411 | f"Account ID: {self.account_id}, ", 412 | f"Login Status: {self.is_online()}, ", 413 | f"Ban Status: {self.banned}, ", 414 | f"Ban Reason: {self.ban_reason}, ", 415 | f"Total Character Slots: {self.char_slots}, ", 416 | f"Free Character Slots: {self.free_char_slots}, ", 417 | f"DP: {self.dp}, ", 418 | f"VP: {self.vp}, ", 419 | f"NX: {self.nx}, ", 420 | f"Maple Points: {self.maple_points}", 421 | ] 422 | return attributes 423 | 424 | def get_stat_by_column(self, column:str) -> Any: 425 | """Fetches account attribute by column name 426 | 427 | Args: 428 | 429 | column (`str`): Represents column name in the DB 430 | 431 | Returns: 432 | `Any` type (likely `str`, `int`, or `datetime`), representing user attribute queried 433 | 434 | Raises: 435 | A generic error on failure, handled by `utils.get_stat_by_column` 436 | """ 437 | return utils.get_stat_by_column(self._account_info, column) 438 | 439 | def set_stat_by_column(self, column: str, value: Any) -> bool: 440 | """Sets an account's attributes by column name in database 441 | 442 | ### ONLY WORKS WHEN SERVER IS OFF! 443 | 444 | Grabs the database attributes provided through the class constructor. 445 | Uses these attributes to attempt a database connection through 446 | `utility.write_to_db`. Attempts to update the field represented by 447 | the provided column in the accounts table, with the provided value. 448 | **NOT** recommended to use this method on its own, as it will not update 449 | the account instance variables (in memory) post-change. 450 | 451 | Args: 452 | 453 | value (`int` or `str`): Represents the value to be set in the database 454 | column (`str`): Represents the column in the database that is to be updated 455 | 456 | Returns: 457 | A `bool` representing whether the operation was successful. 458 | 459 | Raises: 460 | A generic error, handled in `utility.write_to_db` 461 | """ 462 | status = utils.write_to_db( 463 | self._database_config, 464 | f"UPDATE `accounts` SET {column} = '{value}' " 465 | f"WHERE `id` = '{self.account_id}'" 466 | ) 467 | if status: 468 | print( 469 | f"Successfully updated {column} value " 470 | f"for user id: {self.account_id}.") 471 | # Update the stats in the dictionary: 472 | self._account_info[column] = value 473 | return status 474 | -------------------------------------------------------------------------------- /lazuli/database.py: -------------------------------------------------------------------------------- 1 | """This module contains the main class that users would instantiate 2 | 3 | *Copyright 2022 TEAM SPIRIT. All rights reserved. 4 | Use of this source code is governed by a AGPL-style license that can be found 5 | in the LICENSE file.* Lazuli is designed for use in development of 6 | AzureMSv316-based MapleStory private server tools (e.g. Discord bots). 7 | Users can use this class to fetch and manipulate information from the database. 8 | Refer to the project wiki on GitHub for more in-depth examples. 9 | 10 | Typical usage example: 11 | 12 | lazuli = Lazuli() # Instantiate DB object 13 | char = lazuli.get_char_by_name("KOOKIIE") # Instantiate Character object 14 | meso = char.money # Use of Character methods to fetch data from DB 15 | char.money = 123456789 # Use of Character methods to write data to DB 16 | """ 17 | from typing import Any, Union 18 | from lazuli.character import Character 19 | from lazuli.account import Account 20 | from lazuli.inventory import Inventory 21 | import lazuli.utility as utils 22 | 23 | 24 | class Lazuli: 25 | """`Database` object; models the AzureMS DB. 26 | 27 | Use this class to create instances of AzureMS characters, accounts, or 28 | inventories, complete with their respective data from the connected 29 | AzureMS-based database. Using the instance method 30 | `Lazuli::get_char_by_name("name")` will create a `Character` object 31 | (see `character.py`) instance that has attributes identical to the 32 | character with IGN`name` in the connected AzureMS-based database. 33 | Azure server codebase technically uses `win-949` encoding, but `cp949` is not a 34 | supported charset by MySQL/MariaDB, so this module shall default to the 35 | `euckr` charset (used in the DB) instead. Note that AzureMS uses a mixture 36 | of `utf8`, `latin1`, and `euckr` in its database - YMMV when attempting 37 | to expand attribute handling features. 38 | 39 | Attributes: 40 | 41 | host (`str`): Optional; IP address of the database. Defaults to `localhost` 42 | schema (`str`): Optional; Name of the schema of the database. Defaults to `kms_316` 43 | user (`str`): Optional; Username for access to the database. Defaults to `root` 44 | password (`str`): Optional; Password for access to the database. Defaults to empty string. 45 | port (`int`): Optional; Port with which to access the database. Defaults to `3306` 46 | charset (`str`): Optional; Encoding. Defaults to `euckr` 47 | """ 48 | 49 | def __init__( 50 | self, 51 | host: str="localhost", 52 | schema: str="kms_316", 53 | user: str="root", 54 | password: str="", 55 | port: str=3306, 56 | charset: str="euckr" 57 | ) -> None: 58 | self._host = host 59 | self._schema = schema 60 | self._user = user 61 | self._password = password 62 | self._port = port 63 | self._charset = charset 64 | 65 | self._database_config = { 66 | 'host': self._host, 67 | 'user': self._user, 68 | 'password': self._password, 69 | 'schema': self._schema, 70 | 'port': self._port, 71 | 'charset': self._charset 72 | } 73 | 74 | def get_db_all_hits(self, query: str) -> list: 75 | """Fetch all matching data from DB using the provided query 76 | 77 | Wrapper function. Uses the DB config from `Lazuli` attributes for 78 | DB connection. Feeds the config values into `utility.get_db_all_hits()`. 79 | Added here for explicit API-use purposes (discouraged). 80 | 81 | Args: 82 | 83 | query (`str`): Represents the SQL query to be executed 84 | 85 | Returns: 86 | A `list` of objects, representing the result of the provided SQL query, 87 | using the provided DB connection attributes 88 | 89 | Raises: 90 | A generic error on failure - handled by the 91 | `utility.get_db_all_hits()` method 92 | 93 | """ 94 | data = utils.get_db_all_hits(self._database_config, query) 95 | return data 96 | 97 | def get_db_first_hit(self, query: str) -> Any: 98 | """Fetch data (first result) from DB using the provided query 99 | 100 | This function grabs the first result from `get_db_all_hits. 101 | Added here for explicit API-use purposes (discouraged). 102 | 103 | Args: 104 | 105 | query (`str`): Represents the SQL query to be executed 106 | 107 | Returns: 108 | An object, representing first result 109 | 110 | Raises: 111 | A generic error on failure - handled by the 112 | `utility.get_db_all_hits()` method 113 | """ 114 | return self.get_db_all_hits(query)[0] 115 | 116 | def get_char_by_name(self, char_name: str) -> Character: 117 | """Create a `Character` instance from the given character name 118 | 119 | Uses the class constructor of the `Character` class to create a new 120 | instance, with the corresponding character data and database attributes 121 | from the connected database. 122 | 123 | Args: 124 | 125 | char_name (`str`): Represents the character name (aka IGN) 126 | 127 | Returns: 128 | A `Character` object instantiated with corresponding data from the 129 | connected database. 130 | Defaults to `None` if the operation fails. 131 | 132 | Raises: 133 | A generic error on failure - handled by the `get_db_first_hit()` method 134 | """ 135 | # Fetch first result because there should only be one character 136 | # with that name 137 | character_stats: dict[str, Any] = self.get_db_first_hit( 138 | f"SELECT * FROM `characters` WHERE `name` ='{char_name}'" 139 | ) 140 | 141 | character = Character(character_stats, self._database_config) 142 | return character 143 | 144 | def get_inv_by_name(self, char_name: str) -> Inventory: 145 | """Create an `Inventory` instance from the given character name 146 | 147 | Uses the Character ID associated with the character name, and the 148 | `Inventory` class constructor to create a new `Inventory` object instance, 149 | with the relevant inventory attributes from the database. 150 | 151 | Args: 152 | char_name (`str`): Represents the character name (aka IGN) 153 | 154 | Returns: 155 | An `Inventory` object instantiated with corresponding data from the 156 | connected database. 157 | Defaults to `None` if the operation fails. 158 | 159 | Raises: 160 | A generic error on failure - handled by the `get_db_first_hit()` method 161 | """ 162 | # Fetch first result because there should only be one character 163 | # with that name 164 | char_id: int = self.get_db_first_hit( 165 | f"SELECT * FROM `characters` WHERE `name` = '{char_name}'" 166 | )['id'] 167 | 168 | inventory = Inventory(char_id, self._database_config) 169 | return inventory 170 | 171 | def get_account_by_username(self, username: str) -> Account: 172 | """Given a username (NOT IGN), create a new `Account` object instance 173 | 174 | Fetches the user account attributes from the database by querying for 175 | username. Uses the `User` class constructor to create a new `User` object 176 | instance, with the said attributes. 177 | Useful for getting account information from accounts with no characters. 178 | 179 | Args: 180 | 181 | username (`str`): Represents the username used for logging the user into game 182 | 183 | Returns: 184 | An `Account` object with attributes identical to its corresponding 185 | entry in the database 186 | 187 | Raises: 188 | A generic error on failure - handled by the `get_db_first_hit()` method 189 | """ 190 | # Fetch first result because there should only be one character 191 | # with that name 192 | account_info: dict[str, Any] = self.get_db_first_hit( 193 | f"SELECT * FROM `accounts` WHERE `name` = '{username}'" 194 | ) 195 | 196 | account = Account(account_info, self._database_config) 197 | return account 198 | 199 | def set_char_stat(self, name: str, column: str, value: Union[str, int]) -> bool: 200 | """Set the given value for the given name and column 201 | 202 | Given a character name and column name, change its value in the 203 | database using `utility.write_to_db()` 204 | 205 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 206 | 207 | Args: 208 | 209 | name (`str`): Represents the character name in the database 210 | column (`str`): Represents the column in the database 211 | value (`str | int`): Represents the value that is to be updated in the corresponding field 212 | 213 | Returns: 214 | A `bool`, representing whether the operation completed successfully 215 | 216 | Raises: 217 | A generic error, handled in `utility.write_to_db()` 218 | """ 219 | status = utils.write_to_db( 220 | self._database_config, 221 | f"UPDATE `characters` SET {column} = '{value}' " 222 | f"WHERE `name` = '{name}'" 223 | ) 224 | if status: 225 | print(f"Successfully set {name}'s stats in database.") 226 | return status 227 | 228 | def get_online_list(self) -> list[dict[str, Any]]: 229 | """Fetch the list of players' data for all players currently online 230 | 231 | AzureMS stores login state in the DB, in the `accounts` table, 232 | `loggedin` column. `Lazuli::get_online_list` queries for a list of all 233 | accounts that are logged in, using the `Lazuli::get_db_all_hits` method. 234 | 235 | Returns: 236 | A `list`, representing the rows in the database, corresponding to all online players. 237 | Defaults to `False` in the event of an error during execution 238 | 239 | Raises: 240 | Generic error on failure, handled by `utility.get_db_all_hits()` 241 | """ 242 | data = self.get_db_all_hits( 243 | "SELECT * FROM `accounts` WHERE `loggedin` > 0" 244 | ) 245 | return data # List of online players 246 | 247 | def get_online_count(self) -> int: 248 | """Fetch the number of players currently online 249 | 250 | Uses the `Lazuli::get_online_list` method to fetch the list of all 251 | players online. Counts the length of said list, to obtain number of 252 | players online. 253 | 254 | Returns: 255 | An `int`, representing number of players online. 256 | Defaults to `False` in the event of an error during execution 257 | 258 | Raises: 259 | Generic error on failure, handled by `utility.get_db_all_hits()` 260 | """ 261 | players = self.get_online_list() 262 | return len(players) 263 | 264 | def get_online_players(self) -> list[str]: 265 | """Fetch usernames of all players currently online 266 | 267 | Uses the `Lazuli::get_online_list` method to fetch the list of all 268 | players online. Extract the usernames from the said list. 269 | 270 | Returns: 271 | A `list`, representing all players online. 272 | Defaults to `False` in the event of an error during execution 273 | 274 | Raises: 275 | Generic error on failure, handled by `utility.get_db_all_hits()` 276 | """ 277 | player_data = self.get_online_list() 278 | if not player_data: # empty list 279 | return player_data 280 | return utils.extract_name(player_data) 281 | 282 | def get_level_ranking( 283 | self, 284 | number_of_players: int=5, 285 | show_gm: bool=False, 286 | ) -> list[tuple[str, int]]: 287 | """Fetches the top ranking players in terms of level 288 | 289 | Uses `Lazuli::get_db_all_hits` to query, and 290 | `utility.extract_name_and_value` to process the data. 291 | 292 | Args: 293 | 294 | number_of_players (`int`): Optional; Number of players to show, e.g. Top 5 Ranking (default), Top 10 Ranking, etc. 295 | show_gm (`bool`): Optional; Whether to add GMs (Game Masters) to the list of rankings 296 | 297 | Returns: 298 | A `list` of `tuple`, representing player names and their 299 | corresponding level 300 | """ 301 | if show_gm: 302 | prepared_statement = ( 303 | f"SELECT * FROM `characters` ORDER BY `level` DESC " 304 | f"LIMIT {number_of_players}" 305 | ) 306 | else: 307 | prepared_statement = ( 308 | f"SELECT * FROM `characters` WHERE `gm` < 1 ORDER BY `level` " 309 | f"DESC LIMIT {number_of_players}" 310 | ) 311 | 312 | player_data = self.get_db_all_hits(prepared_statement) 313 | return utils.extract_name_and_value(player_data, "level") 314 | 315 | def get_meso_ranking( 316 | self, 317 | number_of_players: int=5, 318 | show_gm: bool=False, 319 | ) -> list[tuple[str, int]]: 320 | """Fetches the top ranking players in terms of mesos 321 | 322 | Uses `Lazuli::get_db_all_hits` to query, and 323 | `utility.extract_name_and_value` to process the data. 324 | 325 | Args: 326 | 327 | number_of_players (`int`): Optional; Number of players to show, e.g. Top 5 Ranking (default), Top 10 Ranking, etc. 328 | show_gm (`bool`): Optional; Whether to add GMs (Game Masters) to the list of rankings 329 | 330 | Returns: 331 | A `list` of `tuple`, representing player names and their 332 | corresponding mesos 333 | """ 334 | if show_gm: 335 | prepared_statement = ( 336 | f"SELECT * FROM `characters` ORDER BY `meso` DESC " 337 | f"LIMIT {number_of_players}" 338 | ) 339 | else: 340 | prepared_statement = ( 341 | f"SELECT * FROM `characters` WHERE `gm` < 1 ORDER BY `meso` " 342 | f"DESC LIMIT {number_of_players}" 343 | ) 344 | 345 | player_data = self.get_db_all_hits(prepared_statement) 346 | return utils.extract_name_and_value(player_data, "meso") 347 | 348 | def get_fame_ranking( 349 | self, 350 | number_of_players: int=5, 351 | show_gm: bool=False, 352 | ) -> list[tuple[str, int]]: 353 | """Fetches the top ranking players in terms of fame 354 | 355 | Uses `Lazuli::get_db_all_hits` to query, and 356 | `utility.extract_name_and_value` to process the data. 357 | 358 | Args: 359 | 360 | number_of_players (`int`): Optional; Number of players to show, e.g. Top 5 Ranking (default), Top 10 Ranking, etc. 361 | show_gm (`bool`): Optional; Whether to add GMs (Game Masters) to the list of rankings 362 | 363 | Returns: 364 | A `list` of `tuple`, representing player names and their 365 | corresponding fame 366 | """ 367 | if show_gm: 368 | prepared_statement = ( 369 | f"SELECT * FROM `characters` ORDER BY `fame` DESC " 370 | f"LIMIT {number_of_players}" 371 | ) 372 | else: 373 | prepared_statement = ( 374 | f"SELECT * FROM `characters` WHERE `gm` < 1 ORDER BY `fame` " 375 | f"DESC LIMIT {number_of_players}" 376 | ) 377 | 378 | player_data = self.get_db_all_hits(prepared_statement) 379 | return utils.extract_name_and_value(player_data, "fame") 380 | 381 | def get_rebirth_ranking( 382 | self, 383 | number_of_players: int=5, 384 | show_gm: bool=False, 385 | ) -> list[tuple[str, int]]: 386 | """Fetches the top ranking players in terms of rebirths 387 | 388 | Uses `Lazuli::get_db_all_hits` to query, and 389 | `utility.extract_name_and_value` to process the data. 390 | 391 | Args: 392 | 393 | number_of_players (`int`): Optional; Number of players to show, e.g. Top 5 Ranking (default), Top 10 Ranking, etc. 394 | show_gm (`bool`): Optional; Whether to add GMs (Game Masters) to the list of rankings 395 | 396 | Returns: 397 | A `list` of `tuple`, representing player names and their 398 | corresponding rebirths 399 | """ 400 | if show_gm: 401 | prepared_statement = ( 402 | f"SELECT * FROM `characters` ORDER BY `reborns` DESC " 403 | f"LIMIT {number_of_players}" 404 | ) 405 | else: 406 | prepared_statement = ( 407 | f"SELECT * FROM `characters` WHERE `gm` < 1 ORDER BY `reborns` " 408 | f"DESC LIMIT {number_of_players}" 409 | ) 410 | 411 | player_data = self.get_db_all_hits(prepared_statement) 412 | return utils.extract_name_and_value(player_data, "reborns") 413 | 414 | def get_rebirth_ranking_by_job_id( 415 | self, 416 | job_id: Union[int, str], 417 | number_of_players: int=5, 418 | show_gm: bool=False, 419 | ) -> list[tuple[str, int]]: 420 | """Fetches the top ranking players (by class) in terms of rebirths 421 | 422 | Uses `Lazuli::get_db_all_hits` to query, and 423 | `utility.extract_name_and_value` to process the data. 424 | Searches based on specific job IDs. 425 | 426 | Args: 427 | 428 | job_id (`int | str`): Represents the specific Job ID to query 429 | number_of_players (`int`): Optional; Number of players to show, e.g. Top 5 Ranking (default), Top 10 Ranking, etc. 430 | show_gm (`bool`): Optional; Whether to add GMs (Game Masters) to the list of rankings 431 | 432 | Returns: 433 | A `list` of `tuple`, representing player names and their 434 | corresponding rebirths 435 | """ 436 | if show_gm: 437 | prepared_statement = ( 438 | f"SELECT * FROM `characters` WHERE `job`={job_id} ORDER BY " 439 | f"`reborns`DESC LIMIT {number_of_players}" 440 | ) 441 | else: 442 | prepared_statement = ( 443 | f"SELECT * FROM `characters` WHERE `job`={job_id} AND `gm` < 1 " 444 | f"ORDER BY `reborns` DESC LIMIT {number_of_players}" 445 | ) 446 | 447 | player_data = self.get_db_all_hits(prepared_statement) 448 | return utils.extract_name_and_value(player_data, "reborns") 449 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # This Pylint rcfile contains a best-effort configuration to uphold the 2 | # best-practices and style described in the Google Python style guide: 3 | # https://google.github.io/styleguide/pyguide.html 4 | # 5 | # Its canonical open-source location is: 6 | # https://google.github.io/styleguide/pylintrc 7 | 8 | [MASTER] 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=third_party 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns= 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=no 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=4 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Enable the message, report, category or checker with the given id(s). You can 45 | # either give multiple identifier separated by comma (,) or put this option 46 | # multiple time (only on the command line, not in the configuration file where 47 | # it should appear only once). See also the "--disable" option for examples. 48 | #enable= 49 | 50 | # Disable the message, report, category or checker with the given id(s). You 51 | # can either give multiple identifiers separated by comma (,) or put this 52 | # option multiple times (only on the command line, not in the configuration 53 | # file where it should appear only once).You can also use "--disable=all" to 54 | # disable everything first and then reenable specific checks. For example, if 55 | # you want to run only the similarities checker, you can use "--disable=all 56 | # --enable=similarities". If you want to run only the classes checker, but have 57 | # no Warning level messages displayed, use"--disable=all --enable=classes 58 | # --disable=W" 59 | disable=abstract-method, 60 | apply-builtin, 61 | arguments-differ, 62 | attribute-defined-outside-init, 63 | backtick, 64 | bad-option-value, 65 | basestring-builtin, 66 | buffer-builtin, 67 | c-extension-no-member, 68 | consider-using-enumerate, 69 | cmp-builtin, 70 | cmp-method, 71 | coerce-builtin, 72 | coerce-method, 73 | delslice-method, 74 | div-method, 75 | duplicate-code, 76 | eq-without-hash, 77 | execfile-builtin, 78 | file-builtin, 79 | filter-builtin-not-iterating, 80 | fixme, 81 | getslice-method, 82 | global-statement, 83 | hex-method, 84 | idiv-method, 85 | implicit-str-concat-in-sequence, 86 | import-error, 87 | import-self, 88 | import-star-module-level, 89 | inconsistent-return-statements, 90 | input-builtin, 91 | intern-builtin, 92 | invalid-str-codec, 93 | locally-disabled, 94 | long-builtin, 95 | long-suffix, 96 | map-builtin-not-iterating, 97 | misplaced-comparison-constant, 98 | missing-function-docstring, 99 | metaclass-assignment, 100 | next-method-called, 101 | next-method-defined, 102 | no-absolute-import, 103 | no-else-break, 104 | no-else-continue, 105 | no-else-raise, 106 | no-else-return, 107 | no-init, # added 108 | no-member, 109 | no-name-in-module, 110 | no-self-use, 111 | nonzero-method, 112 | oct-method, 113 | old-division, 114 | old-ne-operator, 115 | old-octal-literal, 116 | old-raise-syntax, 117 | parameter-unpacking, 118 | print-statement, 119 | raising-string, 120 | range-builtin-not-iterating, 121 | raw_input-builtin, 122 | rdiv-method, 123 | reduce-builtin, 124 | relative-import, 125 | reload-builtin, 126 | round-builtin, 127 | setslice-method, 128 | signature-differs, 129 | standarderror-builtin, 130 | suppressed-message, 131 | sys-max-int, 132 | too-few-public-methods, 133 | too-many-ancestors, 134 | too-many-arguments, 135 | too-many-boolean-expressions, 136 | too-many-branches, 137 | too-many-instance-attributes, 138 | too-many-locals, 139 | too-many-nested-blocks, 140 | too-many-public-methods, 141 | too-many-return-statements, 142 | too-many-statements, 143 | trailing-newlines, 144 | unichr-builtin, 145 | unicode-builtin, 146 | unnecessary-pass, 147 | unpacking-in-except, 148 | useless-else-on-loop, 149 | useless-object-inheritance, 150 | useless-suppression, 151 | using-cmp-argument, 152 | wrong-import-order, 153 | xrange-builtin, 154 | zip-builtin-not-iterating, 155 | 156 | 157 | [REPORTS] 158 | 159 | # Set the output format. Available formats are text, parseable, colorized, msvs 160 | # (visual studio) and html. You can also give a reporter class, eg 161 | # mypackage.mymodule.MyReporterClass. 162 | output-format=text 163 | 164 | # Put messages in a separate file for each module / package specified on the 165 | # command line instead of printing them on stdout. Reports (if any) will be 166 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 167 | # and it will be removed in Pylint 2.0. 168 | files-output=no 169 | 170 | # Tells whether to display a full report or only the messages 171 | reports=no 172 | 173 | # Python expression which should return a note less than 10 (10 is the highest 174 | # note). You have access to the variables errors warning, statement which 175 | # respectively contain the number of errors / warnings messages and the total 176 | # number of statements analyzed. This is used by the global evaluation report 177 | # (RP0004). 178 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 179 | 180 | # Template used to display messages. This is a python new-style format string 181 | # used to format the message information. See doc for all details 182 | #msg-template= 183 | 184 | 185 | [BASIC] 186 | 187 | # Good variable names which should always be accepted, separated by a comma 188 | good-names=main,_ 189 | 190 | # Bad variable names which should always be refused, separated by a comma 191 | bad-names= 192 | 193 | # Colon-delimited sets of names that determine each other's naming style when 194 | # the name regexes allow several styles. 195 | name-group= 196 | 197 | # Include a hint for the correct naming format with invalid-name 198 | include-naming-hint=no 199 | 200 | # List of decorators that produce properties, such as abc.abstractproperty. Add 201 | # to this list to register other decorators that produce valid properties. 202 | property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl 203 | 204 | # Regular expression matching correct function names 205 | function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ 206 | 207 | # Regular expression matching correct variable names 208 | variable-rgx=^[a-z][a-z0-9_]*$ 209 | 210 | # Regular expression matching correct constant names 211 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 212 | 213 | # Regular expression matching correct attribute names 214 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ 215 | 216 | # Regular expression matching correct argument names 217 | argument-rgx=^[a-z][a-z0-9_]*$ 218 | 219 | # Regular expression matching correct class attribute names 220 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 221 | 222 | # Regular expression matching correct inline iteration names 223 | inlinevar-rgx=^[a-z][a-z0-9_]*$ 224 | 225 | # Regular expression matching correct class names 226 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$ 227 | 228 | # Regular expression matching correct module names 229 | module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ 230 | 231 | # Regular expression matching correct method names 232 | method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ 233 | 234 | # Regular expression which should only match function or class names that do 235 | # not require a docstring. 236 | no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ 237 | 238 | # Minimum line length for functions/classes that require docstrings, shorter 239 | # ones are exempt. 240 | docstring-min-length=10 241 | 242 | 243 | [TYPECHECK] 244 | 245 | # List of decorators that produce context managers, such as 246 | # contextlib.contextmanager. Add to this list to register other decorators that 247 | # produce valid context managers. 248 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager 249 | 250 | # Tells whether missing members accessed in mixin class should be ignored. A 251 | # mixin class is detected if its name ends with "mixin" (case insensitive). 252 | ignore-mixin-members=yes 253 | 254 | # List of module names for which member attributes should not be checked 255 | # (useful for modules/projects where namespaces are manipulated during runtime 256 | # and thus existing member attributes cannot be deduced by static analysis. It 257 | # supports qualified module names, as well as Unix pattern matching. 258 | ignored-modules= 259 | 260 | # List of class names for which member attributes should not be checked (useful 261 | # for classes with dynamically set attributes). This supports the use of 262 | # qualified names. 263 | ignored-classes=optparse.Values,thread._local,_thread._local 264 | 265 | # List of members which are set dynamically and missed by pylint inference 266 | # system, and so shouldn't trigger E1101 when accessed. Python regular 267 | # expressions are accepted. 268 | generated-members= 269 | 270 | 271 | [FORMAT] 272 | 273 | # Maximum number of characters on a single line. 274 | max-line-length=80 275 | 276 | # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt 277 | # lines made too long by directives to pytype. 278 | 279 | # Regexp for a line that is allowed to be longer than the limit. 280 | ignore-long-lines=(?x)( 281 | ^\s*(\#\ )??$| 282 | ^\s*(from\s+\S+\s+)?import\s+.+$) 283 | 284 | # Allow the body of an if to be on the same line as the test if there is no 285 | # else. 286 | single-line-if-stmt=yes 287 | 288 | # List of optional constructs for which whitespace checking is disabled. `dict- 289 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 290 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 291 | # `empty-line` allows space-only lines. 292 | no-space-check= 293 | 294 | # Maximum number of lines in a module 295 | max-module-lines=99999 296 | 297 | # String used as indentation unit. The internal Google style guide mandates 2 298 | # spaces. Google's externaly-published style guide says 4, consistent with 299 | # PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google 300 | # projects (like TensorFlow). 301 | # indent-string=' ' 302 | indent-string=\t 303 | # Team SPIRIT Convention: Tab of width 4 304 | 305 | # Number of spaces of indent required inside a hanging or continued line. 306 | indent-after-paren=4 307 | 308 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 309 | expected-line-ending-format= 310 | 311 | 312 | [MISCELLANEOUS] 313 | 314 | # List of note tags to take in consideration, separated by a comma. 315 | notes=TODO 316 | 317 | 318 | [STRING] 319 | 320 | # This flag controls whether inconsistent-quotes generates a warning when the 321 | # character used as a quote delimiter is used inconsistently within a module. 322 | check-quote-consistency=no 323 | # Team SPIRIT convention: Double Quote for String literals, and Single for key 324 | 325 | 326 | [VARIABLES] 327 | 328 | # Tells whether we should check for unused import in __init__ files. 329 | init-import=no 330 | 331 | # A regular expression matching the name of dummy variables (i.e. expectedly 332 | # not used). 333 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) 334 | 335 | # List of additional names supposed to be defined in builtins. Remember that 336 | # you should avoid to define new builtins when possible. 337 | additional-builtins= 338 | 339 | # List of strings which can identify a callback function by name. A callback 340 | # name must start or end with one of those strings. 341 | callbacks=cb_,_cb 342 | 343 | # List of qualified module names which can have objects that can redefine 344 | # builtins. 345 | redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools 346 | 347 | 348 | [LOGGING] 349 | 350 | # Logging modules to check that the string format arguments are in logging 351 | # function parameter format 352 | logging-modules=logging,absl.logging,tensorflow.io.logging 353 | 354 | 355 | [SIMILARITIES] 356 | 357 | # Minimum lines number of a similarity. 358 | min-similarity-lines=4 359 | 360 | # Ignore comments when computing similarities. 361 | ignore-comments=yes 362 | 363 | # Ignore docstrings when computing similarities. 364 | ignore-docstrings=yes 365 | 366 | # Ignore imports when computing similarities. 367 | ignore-imports=no 368 | 369 | 370 | [SPELLING] 371 | 372 | # Spelling dictionary name. Available dictionaries: none. To make it working 373 | # install python-enchant package. 374 | spelling-dict= 375 | 376 | # List of comma separated words that should not be checked. 377 | spelling-ignore-words= 378 | 379 | # A path to a file that contains private dictionary; one word per line. 380 | spelling-private-dict-file= 381 | 382 | # Tells whether to store unknown words to indicated private dictionary in 383 | # --spelling-private-dict-file option instead of raising a message. 384 | spelling-store-unknown-words=no 385 | 386 | 387 | [IMPORTS] 388 | 389 | # Deprecated modules which should not be used, separated by a comma 390 | deprecated-modules=regsub, 391 | TERMIOS, 392 | Bastion, 393 | rexec, 394 | sets 395 | 396 | # Create a graph of every (i.e. internal and external) dependencies in the 397 | # given file (report RP0402 must not be disabled) 398 | import-graph= 399 | 400 | # Create a graph of external dependencies in the given file (report RP0402 must 401 | # not be disabled) 402 | ext-import-graph= 403 | 404 | # Create a graph of internal dependencies in the given file (report RP0402 must 405 | # not be disabled) 406 | int-import-graph= 407 | 408 | # Force import order to recognize a module as part of the standard 409 | # compatibility libraries. 410 | known-standard-library= 411 | 412 | # Force import order to recognize a module as part of a third party library. 413 | known-third-party=enchant, absl 414 | 415 | # Analyse import fallback blocks. This can be used to support both Python 2 and 416 | # 3 compatible code, which means that the block might have code that exists 417 | # only in one or another interpreter, leading to false positives when analysed. 418 | analyse-fallback-blocks=no 419 | 420 | 421 | [CLASSES] 422 | 423 | # List of method names used to declare (i.e. assign) instance attributes. 424 | defining-attr-methods=__init__, 425 | __new__, 426 | setUp 427 | 428 | # List of member names, which should be excluded from the protected access 429 | # warning. 430 | exclude-protected=_asdict, 431 | _fields, 432 | _replace, 433 | _source, 434 | _make 435 | 436 | # List of valid names for the first argument in a class method. 437 | valid-classmethod-first-arg=cls, 438 | class_ 439 | 440 | # List of valid names for the first argument in a metaclass class method. 441 | valid-metaclass-classmethod-first-arg=mcs 442 | 443 | 444 | [EXCEPTIONS] 445 | 446 | # Exceptions that will emit a warning when being caught. Defaults to 447 | # "Exception" 448 | overgeneral-exceptions=StandardError, 449 | Exception, 450 | BaseException 451 | -------------------------------------------------------------------------------- /unit_test/tests/character_test.py: -------------------------------------------------------------------------------- 1 | """This is a unit test for checking basic Character handling functionality 2 | 3 | NOTE: PLACE THE UNIT TEST FILES IN THE ROOT OF THE REPOSITORY! 4 | Kindly set up the DB for use; refer to the AzureMS repository on how to set up 5 | an Azure-based DB. Then, use the script in the unit_test/SQLScripts folder of this 6 | project to create a tester account. Once it's been successfully run, you can 7 | use this script to test the functionality of Lazuli's APIs. 8 | Note that you may re-run the SQL script to reset all tester accounts 9 | and characters to their baseline values, if desired. 10 | Copyright KOOKIIE Studios 2022. All rights reserved. 11 | """ 12 | import pytest 13 | from lazuli.database import Lazuli 14 | 15 | 16 | @pytest.fixture 17 | def char(): 18 | """Returns a tester Character instance""" 19 | # Import DB 20 | try: 21 | azure = Lazuli() # Use defaults - these should be the same as Azure v316 repository defaults 22 | except Exception as e: 23 | raise SystemExit(f"Error has occurred whist attempting to load DB: \n{e}") 24 | character = azure.get_char_by_name("tester0x00") 25 | if character is None: 26 | raise SystemExit("CRITICAL ERROR: UNABLE TO FETCH CHARACTER BY NAME! TERMINATING...") 27 | return character 28 | 29 | 30 | # Character info fetching tests ------------------------------------------------------------------------------- 31 | @pytest.mark.parametrize("expected", ["tester0x00"]) 32 | def test_fetch_char_name(char, expected): 33 | assert char.name == expected, \ 34 | f"Critical Error: Name test failed! Name: {char.name}; Type: {type(char.name)}" 35 | 36 | 37 | @pytest.mark.parametrize("expected", [900001]) 38 | def test_fetch_char_id(char, expected): 39 | assert char.character_id == expected, \ 40 | f"Critical Error: Character ID test failed! ID: {char.character_id}; Type: {type(char.character_id)}" 41 | 42 | 43 | @pytest.mark.parametrize("expected", [90001]) 44 | def test_fetch_acc_id(char, expected): 45 | assert char.account_id == expected, \ 46 | f"Critical Error: Account ID test failed! ID: {char.account_id}; Type: {type(char.account_id)}" 47 | 48 | 49 | @pytest.mark.parametrize("expected", [0]) 50 | def test_fetch_char_mesos(char, expected): 51 | assert char.meso == expected, \ 52 | f"Meso test failed! Meso count: {char.meso}; Type: {type(char.meso)}" 53 | 54 | 55 | @pytest.mark.parametrize("expected", [0]) 56 | def test_fetch_char_fame(char, expected): 57 | assert char.fame == expected, \ 58 | f"Fame test failed! Fame count: {char.fame}; Type: {type(char.fame)}" 59 | 60 | 61 | @pytest.mark.parametrize("expected", ["Beginner"]) 62 | def test_fetch_char_class_name(char, expected): 63 | job = char.get_job_name() # return job name from ID via Hashmap; String 64 | assert job == expected, \ 65 | f"Job name test failed! Job name: {job}; Type: {type(job)}" 66 | 67 | 68 | @pytest.mark.parametrize("expected", [0]) 69 | def test_fetch_char_class_id(char, expected): 70 | assert char.job == expected, \ 71 | f"Job ID test failed! Job ID: {char.job}; Type: {type(char.job)}" 72 | 73 | 74 | @pytest.mark.parametrize("expected", [249]) 75 | def test_fetch_char_level(char, expected): 76 | assert char.level == expected, \ 77 | f"Character level test failed! Level count: {char.level}; Type: {type(char.level)}" 78 | 79 | 80 | @pytest.mark.parametrize("expected", [0]) 81 | def test_fetch_char_honour(char, expected): 82 | assert char.honour == expected, \ 83 | f"Honour EXP test failed! Honour count: {char.honour}; Type: {type(char.honour)}" 84 | 85 | 86 | @pytest.mark.parametrize("expected", [253000000]) 87 | def test_fetch_char_map(char, expected): 88 | assert char.map == expected, \ 89 | f"Map ID test failed! Map ID: {char.map}; Type: {type(char.map)}" 90 | 91 | 92 | @pytest.mark.parametrize("expected", [23300]) 93 | def test_fetch_char_face(char, expected): 94 | assert char.face == expected, \ 95 | f"Face ID test failed! Face ID: {char.face}; Type: {type(char.face)}" 96 | 97 | 98 | @pytest.mark.parametrize("expected", [36786]) 99 | def test_fetch_char_hair(char, expected): 100 | assert char.hair == expected, \ 101 | f"Hair ID test failed! Hair ID: {char.hair}; Type: {type(char.hair)}" 102 | 103 | 104 | @pytest.mark.parametrize("expected", [0]) 105 | def test_fetch_char_skin(char, expected): 106 | assert char.skin == expected, \ 107 | f"Skin ID test failed! Skin ID: {char.skin}; Type: {type(char.skin)}" 108 | 109 | 110 | @pytest.mark.parametrize("expected", [0]) 111 | def test_fetch_char_exp(char, expected): 112 | assert char.exp == expected, \ 113 | f"EXP test failed! EXP amount: {char.exp}; Type: {type(char.exp)}" 114 | 115 | 116 | @pytest.mark.parametrize("expected", [40]) 117 | def test_fetch_char_str(char, expected): 118 | assert char.strength == expected, \ 119 | f"STR test failed! STR amount: {char.strength}; Type: {type(char.strength)}" 120 | 121 | 122 | @pytest.mark.parametrize("expected", [4]) 123 | def test_fetch_char_dex(char, expected): 124 | assert char.dex == expected, \ 125 | f"DEX test failed! DEX amount: {char.dex}; Type: {type(char.dex)}" 126 | 127 | 128 | @pytest.mark.parametrize("expected", [4]) 129 | def test_fetch_char_int(char, expected): 130 | assert char.inte == expected, \ 131 | f"INT test failed! INT amount: {char.inte}; Type: {type(char.inte)}" 132 | 133 | 134 | @pytest.mark.parametrize("expected", [4]) 135 | def test_fetch_char_luk(char, expected): 136 | assert char.luk == expected, \ 137 | f"LUK test failed! LUK amount: {char.luk}; Type: {type(char.luk)}" 138 | 139 | 140 | @pytest.mark.parametrize("expected", [{'str': 40, 'dex': 4, 'int': 4, 'luk': 4}]) 141 | def test_fetch_char_pri_stats(char, expected): 142 | primary_stats = char.get_primary_stats() # returns a dictionary of the 4 main stats; dictionary 143 | assert primary_stats == expected, \ 144 | f"Primary Stats test failed! \nExpected: expected \nEncountered: {primary_stats}" 145 | 146 | 147 | @pytest.mark.parametrize("expected", [500]) 148 | def test_fetch_char_hp(char, expected): 149 | assert char.max_hp == expected, \ 150 | f"HP test failed! HP amount: {char.max_hp}; Type: {type(char.max_hp)}" 151 | 152 | 153 | @pytest.mark.parametrize("expected", [500]) 154 | def test_fetch_char_mp(char, expected): 155 | assert char.max_mp == expected, \ 156 | f"MP test failed! MP amount: {char.max_mp}; Type: {type(char.max_mp)}" 157 | 158 | 159 | @pytest.mark.parametrize("expected", [0]) 160 | def test_fetch_char_ap(char, expected): 161 | assert char.ap == expected, \ 162 | f"AP test failed! AP amount: {char.ap}; Type: {type(char.ap)}" 163 | 164 | 165 | @pytest.mark.parametrize("expected", [25]) 166 | def test_fetch_bl_slots(char, expected): 167 | assert char.bl_slots == expected, \ 168 | f"Buddy List slots test failed! BL count: {char.bl_slots}; Type: {type(char.bl_slots)}" 169 | 170 | 171 | @pytest.mark.parametrize("expected", [0]) 172 | def test_fetch_rbs(char, expected): 173 | assert char.rebirths == expected, \ 174 | f"Rebirths test failed! RB count: {char.rebirths}; Type: {type(char.rebirths)}" 175 | 176 | 177 | @pytest.mark.parametrize("expected", ["false"]) 178 | def test_fetch_mute(char, expected): 179 | assert char.mute == expected, \ 180 | f"Mute test failed! Mute status: {char.mute}; Type: {type(char.mute)}" 181 | 182 | 183 | @pytest.mark.parametrize("expected", [{ 184 | 'ambition': 0, 185 | 'insight': 0, 186 | 'willpower': 0, 187 | 'diligence': 0, 188 | 'empathy': 0, 189 | 'charm': 0, 190 | }]) 191 | def test_fetch_personality(char, expected): 192 | personality = { 193 | 'ambition': char.ambition, 194 | 'insight': char.insight, 195 | 'willpower': char.willpower, 196 | 'diligence': char.diligence, 197 | 'empathy': char.empathy, 198 | 'charm': char.charm, 199 | } # There's no getter for the whole set right now; manually fetching 200 | assert personality == expected, \ 201 | f"Personality trait test failed! Traits: {personality}; Type: {type(personality)}" 202 | 203 | 204 | # Character info setting tests ------------------------------------------------------------------------------- 205 | @pytest.mark.parametrize("before, delta, expected", [ 206 | (314159, 2827433, 3141592), 207 | ]) 208 | def test_meso_changes(char, before, delta, expected): 209 | char.meso = before # Sets money to 314,159 mesos in the database 210 | assert char.meso == before, \ 211 | f"Meso setting test failed! Expected: {before}; Meso count: {char.meso}; Type: {type(char.meso)}" 212 | char.add_mesos(delta) # Adds 2,827,433 to the current meso count, and saves to DB 213 | # character now has 3,141,592 mesos 214 | assert char.meso == expected, \ 215 | f"Meso adding test failed! Expected: {expected}; Meso count: {char.meso}; Type: {type(char.meso)}" 216 | char.meso = 0 # reset to baseline 217 | 218 | 219 | @pytest.mark.parametrize("before, delta, expected", [ 220 | (3, 28, 31), 221 | ]) 222 | def test_fame_changes(char, before, delta, expected): 223 | char.fame = before # Sets money to 314,159 mesos in the database 224 | assert char.fame == before, \ 225 | f"Fame setting test failed! Expected: {before}; Fame count: {char.fame}; Type: {type(char.fame)}" 226 | char.add_fame(delta) # Adds 28 fame to the existing count and saves to database 227 | # character fame is now 31 228 | assert char.fame == expected, \ 229 | f"Fame adding test failed! Expected: {expected}; Fame count: {char.fame}; Type: {type(char.fame)}" 230 | char.fame = 0 # reset to baseline 231 | 232 | 233 | @pytest.mark.parametrize("before, delta, expected", [ 234 | (11, 20, 31), 235 | ]) 236 | def test_level_changes(char, before, delta, expected): 237 | char.level = before 238 | assert char.level == before, \ 239 | f"Character level setting test failed! Expected: {before}; Level count: {char.level}; Type: {type(char.level)}" 240 | char.add_level(delta) # Adds 21 to the existing count and saves to database 241 | # character is now level 31 242 | assert char.level == expected, \ 243 | f"Character level adding test failed! Expected: {expected}; Level count: {char.level}; Type: {type(char.level)}" 244 | char.level = 249 # reset to baseline 245 | 246 | 247 | @pytest.mark.parametrize("before, expected", [ 248 | (0, 100), 249 | ]) 250 | def test_job_changes(char, before, expected): 251 | char.job = expected # set job ID to warrior 252 | assert char.job == expected, \ 253 | f"Job ID setting test failed! Expected: {expected}; Job ID: {char.job}; Type: {type(char.job)}" 254 | char.job = before # reset job ID to beginner 255 | 256 | 257 | @pytest.mark.parametrize("before, expected", [ 258 | ("tester0x00", "tester0xFF"), 259 | ]) 260 | def test_name_changes(char, before, expected): 261 | char.name = expected 262 | assert char.name == expected, \ 263 | f"Name setting test failed! Expected: {expected}; Name: {char.name}; Type: {type(char.name)}" 264 | char.name = before # reset to baseline 265 | 266 | 267 | @pytest.mark.parametrize("before, expected", [ 268 | (253000000, 100000000), 269 | ]) 270 | def test_map_changes(char, before, expected): 271 | char.map = expected 272 | assert char.map == expected, \ 273 | f"Map ID setting test failed! Expected: {expected},{type(expected)}; Map ID: {char.map}; Type: {type(char.map)}" 274 | char.map = before # reset to baseline 275 | 276 | 277 | @pytest.mark.parametrize("before, expected", [ 278 | (23300, 20010), 279 | ]) 280 | def test_face_changes(char, before, expected): 281 | char.face = expected 282 | assert char.face == expected, \ 283 | f"Face ID setting test failed! Expected: {expected}; Face ID: {char.face}; Type: {type(char.face)}" 284 | char.face = before # reset to baseline 285 | 286 | 287 | @pytest.mark.parametrize("before, expected", [ 288 | (36786, 30027), 289 | ]) 290 | def test_hair_changes(char, before, expected): 291 | char.hair = expected 292 | assert char.hair == expected, \ 293 | f"Hair ID setting test failed! Expected: {expected}; Hair ID: {char.hair}; Type: {type(char.hair)}" 294 | char.hair = before # reset to baseline 295 | 296 | 297 | @pytest.mark.parametrize("before, expected", [ 298 | (0, 2), 299 | ]) 300 | def test_skin_changes(char, before, expected): 301 | char.skin = expected 302 | assert char.skin == expected, \ 303 | f"Skin ID setting test failed! Expected: {expected}; Skin ID: {char.skin}; Type: {type(char.skin)}" 304 | char.skin = before # reset to baseline 305 | 306 | 307 | @pytest.mark.parametrize("before, delta, expected", [ 308 | (314159, 2827433, 3141592), 309 | ]) 310 | def test_exp_changes(char, before, delta, expected): 311 | char.exp = before 312 | assert char.exp == before, \ 313 | f"EXP test setting failed! Expected: {before}; EXP amount: {char.exp}; Type: {type(char.exp)}" 314 | char.add_exp(delta) 315 | assert char.exp == expected, \ 316 | f"EXP test adding failed! Expected: {expected}; EXP amount: {char.exp}; Type: {type(char.exp)}" 317 | char.exp = 0 # reset to baseline 318 | 319 | 320 | @pytest.mark.parametrize("before, delta, expected", [ 321 | (31, 1, 32), 322 | ]) 323 | def test_str_changes(char, before, delta, expected): 324 | char.strength = before 325 | assert char.strength == before, \ 326 | f"STR setting test failed! Expected: {before}; STR amount: {char.strength}; Type: {type(char.strength)}" 327 | char.add_str(delta) 328 | assert char.strength == expected, \ 329 | f"STR adding test failed! Expected: {expected}; STR amount: {char.strength}; Type: {type(char.strength)}" 330 | char.strength = 40 # reset to baseline 331 | 332 | 333 | @pytest.mark.parametrize("before, delta, expected", [ 334 | (31, 1, 32), 335 | ]) 336 | def test_dex_changes(char, before, delta, expected): 337 | char.dex = before 338 | assert char.dex == before, \ 339 | f"DEX setting test failed! Expected: {before}; DEX amount: {char.dex}; Type: {type(char.dex)}" 340 | char.add_dex(delta) 341 | assert char.dex == expected, \ 342 | f"DEX adding test failed! Expected: {expected}; DEX amount: {char.dex}; Type: {type(char.dex)}" 343 | char.dex = 4 # reset to baseline 344 | 345 | 346 | @pytest.mark.parametrize("before, delta, expected", [ 347 | (31, 1, 32), 348 | ]) 349 | def test_int_changes(char, before, delta, expected): 350 | char.inte = before 351 | assert char.inte == before, \ 352 | f"INT setting test failed! Expected: {before}; INT amount: {char.inte}; Type: {type(char.inte)}" 353 | char.add_inte(delta) 354 | assert char.inte == expected, \ 355 | f"INT adding test failed! Expected: {expected}; INT amount: {char.inte}; Type: {type(char.inte)}" 356 | char.inte = 4 # reset to baseline 357 | 358 | 359 | @pytest.mark.parametrize("before, delta, expected", [ 360 | (31, 1, 32), 361 | ]) 362 | def test_luk_changes(char, before, delta, expected): 363 | char.luk = before 364 | assert char.luk == before, \ 365 | f"LUK setting test failed! Expected: {before}; LUK amount: {char.luk}; Type: {type(char.luk)}" 366 | char.add_luk(delta) 367 | assert char.luk == expected, \ 368 | f"LUK adding test failed! Expected: {expected}; LUK amount: {char.luk}; Type: {type(char.luk)}" 369 | char.luk = 4 # reset to baseline 370 | 371 | 372 | @pytest.mark.parametrize("before, delta, expected", [ 373 | (31, 1, 32), 374 | ]) 375 | def test_hp_changes(char, before, delta, expected): 376 | char.max_hp = before 377 | assert char.max_hp == before, \ 378 | f"HP setting test failed! Expected: {before}; HP amount: {char.max_hp}; Type: {type(char.max_hp)}" 379 | char.add_max_hp(delta) 380 | assert char.max_hp == expected, \ 381 | f"HP adding test failed! Expected: {expected}; HP amount: {char.max_hp}; Type: {type(char.max_hp)}" 382 | char.max_hp = 500 # reset to baseline 383 | 384 | 385 | @pytest.mark.parametrize("before, delta, expected", [ 386 | (31, 1, 32), 387 | ]) 388 | def test_mp_changes(char, before, delta, expected): 389 | char.max_mp = before 390 | assert char.max_mp == before, \ 391 | f"MP setting test failed! Expected: {before}; MP amount: {char.max_mp}; Type: {type(char.max_mp)}" 392 | char.add_max_mp(delta) 393 | assert char.max_mp == expected, \ 394 | f"MP adding test failed! Expected: {expected}; MP amount: {char.max_mp}; Type: {type(char.max_mp)}" 395 | char.max_mp = 500 # reset to baseline 396 | 397 | 398 | @pytest.mark.parametrize("before, delta, expected", [ 399 | (31, 1, 32), 400 | ]) 401 | def test_ap_changes(char, before, delta, expected): 402 | char.ap = before 403 | assert char.ap == before, \ 404 | f"AP setting test failed! Expected: {before}; AP amount: {char.ap}; Type: {type(char.ap)}" 405 | char.add_ap(delta) 406 | assert char.ap == expected, \ 407 | f"AP adding test failed! Expected: {expected}; AP amount: {char.ap}; Type: {type(char.ap)}" 408 | char.ap = 0 # reset to baseline 409 | 410 | 411 | @pytest.mark.parametrize("before, delta, expected", [ 412 | (31, 1, 32), 413 | ]) 414 | def test_bl_slots_changes(char, before, delta, expected): 415 | char.bl_slots = before 416 | assert char.bl_slots == before, \ 417 | f"BL Slots setting test failed! Expected: {before}; BL count: {char.bl_slots}; Type: {type(char.bl_slots)}" 418 | char.add_bl_slots(delta) 419 | assert char.bl_slots == expected, \ 420 | f"BL Slots adding test failed! Expected: {expected}; BL count: {char.bl_slots}; Type: {type(char.bl_slots)}" 421 | char.bl_slots = 25 # reset to baseline 422 | 423 | 424 | @pytest.mark.parametrize("before, delta, expected", [ 425 | (31, 1, 32), 426 | ]) 427 | def test_rb_changes(char, before, delta, expected): 428 | char.rebirths = before 429 | assert char.rebirths == before, \ 430 | f"Rebirths setting test failed! Expected: {before}; RB count: {char.rebirths}; Type: {type(char.rebirths)}" 431 | char.add_rebirths(delta) 432 | assert char.rebirths == expected, \ 433 | f"Rebirths adding test failed! Expected: {expected}; RB count: {char.rebirths}; Type: {type(char.rebirths)}" 434 | char.rebirths = 0 # reset to baseline 435 | 436 | 437 | @pytest.mark.parametrize("before, delta, expected", [( 438 | { 439 | 'ambition': 10, 440 | 'insight': 10, 441 | 'willpower': 10, 442 | 'diligence': 10, 443 | 'empathy': 10, 444 | 'charm': 10, 445 | }, 446 | 21, 447 | { 448 | 'ambition': 31, 449 | 'insight': 31, 450 | 'willpower': 31, 451 | 'diligence': 31, 452 | 'empathy': 31, 453 | 'charm': 31, 454 | }, 455 | )]) 456 | def test_personality_changes(char, before, delta, expected): 457 | char.ambition = before['ambition'] 458 | char.insight = before['insight'] 459 | char.willpower = before['willpower'] 460 | char.diligence = before['diligence'] 461 | char.empathy = before['empathy'] 462 | char.charm = before['charm'] 463 | personality = { 464 | 'ambition': char.ambition, 465 | 'insight': char.insight, 466 | 'willpower': char.willpower, 467 | 'diligence': char.diligence, 468 | 'empathy': char.empathy, 469 | 'charm': char.charm, 470 | } # There's no getter for the whole set right now; manually fetching 471 | assert personality == before, \ 472 | f"Personality trait setting test failed!\nExpected: {personality};\n" \ 473 | f"Traits: {personality}; Type: {type(personality)}" 474 | char.add_ambition(delta) 475 | char.add_insight(delta) 476 | char.add_willpower(delta) 477 | char.add_diligence(delta) 478 | char.add_empathy(delta) 479 | char.add_charm(delta) 480 | personality = { 481 | 'ambition': char.ambition, 482 | 'insight': char.insight, 483 | 'willpower': char.willpower, 484 | 'diligence': char.diligence, 485 | 'empathy': char.empathy, 486 | 'charm': char.charm, 487 | } # Because: grabbing by value and not by reference 488 | assert personality == expected, \ 489 | f"Personality trait adding test failed!\nExpected: {personality};\n" \ 490 | f"Traits: {personality}; Type: {type(personality)}" 491 | # reset to baseline: 492 | char.ambition = 0 493 | char.insight = 0 494 | char.willpower = 0 495 | char.diligence = 0 496 | char.empathy = 0 497 | char.charm = 0 498 | 499 | 500 | @pytest.mark.parametrize("before, expected", [ 501 | ("false", "true"), 502 | ]) 503 | def test_mute_changes(char, before, expected): 504 | char.mute = expected 505 | assert char.mute == expected, \ 506 | f"Mute setting test failed! Expected: {expected}; Mute status: {char.mute}; Type: {type(char.mute)}" 507 | char.mute = before # reset to baseline 508 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /lazuli/character.py: -------------------------------------------------------------------------------- 1 | """This module holds the Character class for the lazuli package. 2 | 3 | Copyright 2022 TEAM SPIRIT. All rights reserved. 4 | Use of this source code is governed by a AGPL-style license that can be found 5 | in the LICENSE file. 6 | Refer to `database.py` or the project wiki on GitHub for usage examples. 7 | """ 8 | 9 | from typing import Any 10 | from lazuli.account import Account 11 | from lazuli.inventory import Inventory 12 | from lazuli.jobs import JOBS 13 | import lazuli.utility as utils 14 | 15 | 16 | class Character: 17 | """`Character` object; models AzureMS characters. 18 | 19 | Using instance method `Lazuli::get_char_by_name(name)` will create a 20 | `Character` object instance with attributes identical to the character with 21 | IGN `name` in the connected AzureMS-based database. This class contains 22 | the appropriate getter and setter methods for said attributes. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | char_stats: dict[str, Any], 28 | database_config: dict[str, str], 29 | ) -> None: 30 | """Emulates how the `Character` object is handled by a game server 31 | 32 | Not all character attributes are inherited, as the database 33 | table design in AzureMS is quite verbose 34 | 35 | Note that AzureMS uses a mixture of `utf8`, `latin1`, and `euckr` in its 36 | database - YMMV when attempting to expand attribute handling features. 37 | 38 | Args: 39 | 40 | char_stats (`dict`): Represents character stats, formatted in AzureMS style 41 | database_config (`dict`): Represents the protected attributes from a `Lazuli` object 42 | """ 43 | self._stats = char_stats 44 | self._database_config = database_config 45 | 46 | self._character_id: int = 0 47 | self._account_id: int = 0 48 | self._name: str = "" # varchar 13 49 | self._level: int = 0 50 | self._exp: int = 0 51 | self._strength: int = 0 52 | self._dex: int = 0 53 | self._luk: int = 0 54 | self._inte: int = 0 55 | self._max_hp: int = 0 56 | self._max_mp: int = 0 57 | self._meso: int = 0 58 | self._job: int = 0 59 | self._skin: int = 0 60 | self._gender: int = 0 61 | self._fame: int = 0 62 | self._hair: int = 0 63 | self._face: int = 0 64 | self._ap: int = 0 65 | self._map: int = 0 66 | self._bl_slots: int = 0 67 | self._rebirths: int = 0 68 | self._ambition: int = 0 69 | self._insight: int = 0 70 | self._willpower: int = 0 71 | self._diligence: int = 0 72 | self._empathy: int = 0 73 | self._charm: int = 0 74 | self._honour: int = 0 75 | self._mute: str = "" # Takes lower case String "true"/"false" but 76 | # is a varchar (45) and not a Bool 77 | 78 | self.init_stats() # Assign instance variables 79 | 80 | # Create Account object instance via class constructor, 81 | # using details from Character object instance 82 | self._account = self.init_account() 83 | 84 | # fill with attributes from init 85 | def init_stats(self) -> None: 86 | """Initialises `Character` instance attributes' values. 87 | 88 | Runs near the end of `Character::__init__(char_stats, database_config)`. 89 | Assign values contained in `char_stats` (a dictionary of 90 | character-related attributes from AzureMS's DB) to the `Character` 91 | object's corresponding attributes. 92 | """ 93 | self._character_id = self._stats['id'] 94 | self._account_id = self._stats['accountid'] 95 | self._name = self._stats['name'] 96 | self._level = self._stats['level'] 97 | self._exp = self._stats['exp'] 98 | self._strength = self._stats['str'] 99 | self._dex = self._stats['dex'] 100 | self._luk = self._stats['luk'] 101 | self._inte = self._stats['int'] 102 | self._max_hp = self._stats['maxhp'] 103 | self._max_mp = self._stats['maxmp'] 104 | self._meso = self._stats['meso'] 105 | self._job = self._stats['job'] 106 | self._skin = self._stats['skincolor'] 107 | self._gender = self._stats['gender'] 108 | self._fame = self._stats['fame'] 109 | self._hair = self._stats['hair'] 110 | self._face = self._stats['face'] 111 | self._ap = self._stats['ap'] 112 | self._map = self._stats['map'] 113 | self._bl_slots = self._stats['buddyCapacity'] 114 | # Defaults to 0/false for non-Azure Odin-like DBs that don't have these: 115 | self._rebirths = self._stats.get("reborns", 0) 116 | self._ambition = self._stats.get("ambition", 0) 117 | self._insight = self._stats.get("insight", 0) 118 | self._willpower = self._stats.get("willpower", 0) 119 | self._diligence = self._stats.get("diligence", 0) 120 | self._empathy = self._stats.get("empathy", 0) 121 | self._charm = self._stats.get("charm", 0) 122 | self._honour = self._stats.get("innerExp", 0) # Best guess - might be wrong! 123 | self._mute = self._stats.get("chatban", "false") 124 | 125 | def init_account(self) -> Account: 126 | """Instantiate an `Account` object corresponding to the character 127 | 128 | Runs at the end of `Character::__init__(char_stats, database_config)`. 129 | Fetch the account ID associated with the `Character` instance; use the 130 | account ID to fetch the account attributes as a dictionary. 131 | Then use the `Account` class constructor to create a new `Account` object 132 | instance, with the relevant attributes from the database. 133 | 134 | Returns: 135 | 136 | `Account` object with attributes identical to its 137 | corresponding entry in the database 138 | Raises: 139 | Generic error on failure - handled by the 140 | `utility.get_db_first_hit()` method 141 | """ 142 | account_id: int = utils.get_db_first_hit( 143 | self._database_config, 144 | f"SELECT * FROM `characters` WHERE `id` = '{self.character_id}'" 145 | ).get("accountid") 146 | # get_db() returns a Dictionary, so get() is used 147 | # to fetch only the account ID 148 | # The row index will always be 0 because there should be no characters 149 | # with the same character ID (Primary Key) 150 | 151 | account_info: dict[str, Any] = utils.get_db_first_hit( 152 | self._database_config, 153 | f"SELECT * FROM `accounts` WHERE `id` = '{account_id}'" 154 | ) # The row index will always be 0 because there should be no 155 | # accounts with the same account ID (Primary Key) 156 | 157 | account = Account(account_info, self._database_config) 158 | return account 159 | 160 | @property 161 | def character_id(self) -> int: 162 | """`int`: Represents Primary Key for Character - Do **NOT** set manually 163 | 164 | This is an `int(11)` in the database. 165 | """ 166 | return self._character_id 167 | # Only getter, no setter; Primary Key must not be set manually! 168 | 169 | @property 170 | def account_id(self) -> int: 171 | """`int`: Represents Primary Key for Account (FK) - Do **NOT** set manually 172 | 173 | This is an `int(11)` in the database. 174 | """ 175 | return self._account_id 176 | # Only getter, no setter; Primary Key must not be set manually! 177 | 178 | @property 179 | def level(self) -> int: 180 | """`int`: Represents Character level 181 | 182 | Note that the setter does not allow `int` values outside `1` to `275`. 183 | YMMV if you're attempting to use Lazuli for older Odin-like servers 184 | that only support a level cap of 255, or for newer ones that support 185 | higher level caps. 186 | 187 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 188 | """ 189 | return self._level 190 | 191 | @level.setter 192 | def level(self, x: int) -> None: 193 | if x > 275: 194 | raise ValueError("Level should not exceed 275!") 195 | elif x < 1: 196 | raise ValueError("Level should not be lower than 1!") 197 | else: 198 | self.set_stat_by_column("level", x) 199 | self._level = x 200 | 201 | def add_level(self, amount: int) -> None: 202 | """Adds the specified amount to the current level count 203 | 204 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 205 | 206 | Args: 207 | 208 | amount (`int`): Represents the number of levels to be added to the current count 209 | """ 210 | new_level = int(self.level) + amount 211 | self.level = new_level 212 | 213 | @property 214 | def job(self) -> int: 215 | """`int`: Represents the Job ID of the character 216 | 217 | Note that the setter does not allow arbitrary Job IDs not documented 218 | in SpiritSuite. 219 | 220 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 221 | """ 222 | return self._job 223 | 224 | @job.setter 225 | def job(self, job_id: int) -> None: 226 | if str(job_id) not in JOBS: 227 | raise ValueError("Invalid Job ID!") 228 | else: 229 | self.set_stat_by_column("job", job_id) 230 | self._job = job_id 231 | 232 | def get_job_name(self) -> str: 233 | """Returns the actual name of the job from job id 234 | 235 | Returns: 236 | A string representing the job name corresponding to a job ID 237 | """ 238 | return JOBS[str(self.job)] 239 | 240 | @property 241 | def name(self) -> str: 242 | """`str`: Represents the character's IGN 243 | 244 | This is an `varchar(13)` in the database. 245 | The setter only accepts names that are up to 13 characters long, and 246 | does not check for names with special symbols, due to 247 | its use downstream being intended to be restricted to admin/staff only. 248 | Note that you may encounter encoding issues if you're attempting to set 249 | non-latin names, as Korean character support is not a priority for this 250 | module. 251 | 252 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 253 | """ 254 | return self._name 255 | 256 | @name.setter 257 | def name(self, new_name: str) -> None: 258 | # Check length against max length in Azure DB 259 | length = len(str(new_name)) 260 | if not length or length > 13: 261 | raise ValueError("Character names can only be 1 - 13 characters long!") 262 | # Check clashes 263 | data = utils.get_db_all_hits( 264 | self._database_config, 265 | f"SELECT * FROM `characters` WHERE `name` = '{new_name}'" 266 | ) 267 | if not data: # if the list of accounts with clashing names is not empty 268 | self.set_stat_by_column("name", new_name) # set IGN in DB 269 | # Refresh character instance attributes in memory: 270 | self._name = new_name 271 | else: 272 | # Message to be passed along on failure: 273 | raise ValueError("That name is already taken!") 274 | 275 | @property 276 | def meso(self) -> int: 277 | """`int`: Represents the character's wealth (aka Meso count) 278 | 279 | The setter only accepts values from 0 to 10 billion. YMMV if you're 280 | attempting to use Lazuli for older Odin-like servers that only supports 281 | 32-bit signed `int` values for mesos. 282 | 283 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 284 | """ 285 | return self._meso 286 | 287 | @meso.setter 288 | def meso(self, amount: int) -> None: 289 | if amount > 10000000000: 290 | raise ValueError("You should not try to set meso to more than 10b!") 291 | elif amount < 0: 292 | raise ValueError("You should not try to set meso to less than 0!") 293 | else: 294 | self.set_stat_by_column("meso", amount) 295 | self._meso = amount 296 | 297 | def add_mesos(self, amount: int) -> None: 298 | """Adds the specified amount to the current meso count 299 | 300 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 301 | 302 | Args: 303 | 304 | amount (`int`): Represents the amount of mesos to be added to the current count 305 | """ 306 | new_amount = int(self.meso) + amount 307 | self.meso = new_amount 308 | 309 | @property 310 | def fame(self) -> int: 311 | """`int`: Represents the character's fame count 312 | 313 | The setter only accepts values that can be held using Java's signed 314 | `shorts` (-32768 - 32767). 315 | 316 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 317 | """ 318 | return self._fame 319 | 320 | @fame.setter 321 | def fame(self, amount: int) -> None: 322 | if amount > 32767: 323 | raise ValueError("You should not try to set fame to more than 32k!") 324 | elif amount < -32768: 325 | raise ValueError("You should not try to set fame to less than -32k!") 326 | else: 327 | self.set_stat_by_column("fame", amount) 328 | self._fame = amount 329 | 330 | def add_fame(self, amount: int) -> None: 331 | """Adds the specified amount to the current fame count 332 | 333 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 334 | 335 | Args: 336 | 337 | amount (`int`): Represents the number of fames to be added to the current count 338 | """ 339 | new_fame = int(self.fame) + amount 340 | self.fame = new_fame 341 | 342 | @property 343 | def map(self) -> None: 344 | """`int`: Represents the Map ID of the map that the character is in 345 | 346 | The setter only accepts map IDs ranging from 100,000,000 to 999,999,999. 347 | 348 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 349 | """ 350 | return self._map 351 | 352 | @map.setter 353 | def map(self, map_id: int) -> None: 354 | # Best guess for map ID limits - might be wrong! 355 | if map_id < 100000000 or map_id > 999999999: 356 | raise ValueError("Wrong map ID!") 357 | else: 358 | self.set_stat_by_column("map", map_id) 359 | self._map = map_id 360 | 361 | @property 362 | def face(self) -> int: 363 | """`int`: Represents the Face ID of the character 364 | 365 | The setter only accepts face IDs ranging from 20,000 to 29,999. 366 | 367 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 368 | """ 369 | return self._face 370 | 371 | @face.setter 372 | def face(self, face_id: int) -> None: 373 | # Best guess for face ID limits - might be wrong! 374 | if face_id < 20000 or face_id > 29999: 375 | raise ValueError("Wrong face ID!") 376 | else: 377 | self.set_stat_by_column("face", face_id) 378 | self._face = face_id 379 | 380 | @property 381 | def hair(self) -> int: 382 | """`int`: Represents the Hair ID of the character 383 | 384 | The setter only accepts hair IDs ranging from 30,000 to 49,999. 385 | 386 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 387 | """ 388 | return self._hair 389 | 390 | @hair.setter 391 | def hair(self, hair_id: int) -> None: 392 | # Best guess for hair ID limits - might be wrong! 393 | if hair_id < 30000 or hair_id > 49999: 394 | raise ValueError("Wrong hair ID!") 395 | else: 396 | self.set_stat_by_column("hair", hair_id) 397 | self._hair = hair_id 398 | 399 | @property 400 | def skin(self) -> int: 401 | """`int`: Represents the Skin ID of the character 402 | 403 | The setter only accepts skin IDs ranging from 0 to 16. 404 | 405 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 406 | """ 407 | return self._skin 408 | 409 | @skin.setter 410 | def skin(self, skin_id: int) -> None: 411 | # Best guess for skin ID limits - might be wrong! 412 | if skin_id < 0 or skin_id > 16: 413 | raise ValueError("Wrong skin colour ID!") 414 | else: 415 | self.set_stat_by_column("skincolor", skin_id) 416 | self._skin = skin_id 417 | 418 | @property 419 | def gender(self) -> int: 420 | """`int`: Represents the Gender ID of the character 421 | 422 | The setter only accepts skin IDs ranging from -1 to 1. 423 | 424 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 425 | """ 426 | return self._gender 427 | 428 | @gender.setter 429 | def gender(self, gender_id: int) -> None: 430 | # Best guess for gender ID limits - might be wrong! 431 | if gender_id < -1 or gender_id > 1: 432 | raise ValueError("Wrong gender ID!") 433 | else: 434 | self.set_stat_by_column("gender", gender_id) 435 | self._gender = gender_id 436 | 437 | @property 438 | def exp(self) -> int: 439 | """`int`: Represents the character's EXP 440 | 441 | This is a `bigint(20)` in the DB. 442 | The setter allows values that can be held in a `bigint`, but YMMV with 443 | large EXP values, subject to what the server can handle without 444 | overflows. The upper limits have not been tested. Negative values are 445 | allowed, for convenience of developers' testing. 446 | 447 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 448 | """ 449 | return self._exp 450 | 451 | @exp.setter 452 | def exp(self, exp_amount: int) -> None: 453 | if exp_amount > 9223372036854775807: # Azure DB uses Bigint for EXP 454 | raise ValueError( 455 | "You should not try to set EXP above 9.2 Quintillion!" 456 | ) 457 | elif exp_amount < -9223372036854775808: 458 | raise ValueError( 459 | "You should not try to set EXP below -9.2 Quintillion!" 460 | ) 461 | else: 462 | self.set_stat_by_column("exp", exp_amount) 463 | self._exp = exp_amount 464 | 465 | def add_exp(self, amount: int) -> None: 466 | """Add the specified amount to the current existing EXP pool 467 | 468 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 469 | 470 | Args: 471 | 472 | amount (`int`): Represents the amount of EXP to be added to the current pool 473 | """ 474 | new_exp = int(self.exp) + amount 475 | self.exp = new_exp 476 | 477 | @property 478 | def strength(self) -> int: 479 | """`int`: Represents the character's STR stat pool 480 | 481 | The setter only accepts values that can be held using Java's signed 482 | `shorts` (-32768 - 32767). 483 | 484 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 485 | """ 486 | return self._strength 487 | 488 | @strength.setter 489 | def strength(self, amount: int) -> None: 490 | # Azure DB uses Int (max 2.1b) for stats, but it may cause problems 491 | # with the client if one exceeds signed shorts (max 32k) 492 | if amount > 32767: 493 | raise ValueError("You should not try to set STR above 30k!") 494 | elif amount < -32768: 495 | raise ValueError("You should not try to set STR to less than -32k!") 496 | else: 497 | self.set_stat_by_column("str", amount) 498 | self._strength = amount 499 | 500 | def add_str(self, amount: int) -> None: 501 | """Add the specified amount to the current existing STR pool 502 | 503 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 504 | 505 | Args: 506 | 507 | amount (`int`): Represents the amount of STR to be added to the current pool 508 | """ 509 | new_str = int(self.strength) + amount 510 | self.strength = new_str 511 | 512 | @property 513 | def dex(self) -> int: 514 | """`int`: Represents the character's DEX stat pool 515 | 516 | The setter only accepts values that can be held using Java's signed 517 | `shorts` (-32768 - 32767). 518 | 519 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 520 | """ 521 | return self._dex 522 | 523 | @dex.setter 524 | def dex(self, amount: int) -> None: 525 | if amount > 32767: 526 | raise ValueError("You should not try to set DEX above 30k!") 527 | elif amount < -32768: 528 | raise ValueError("You should not try to set DEX to less than -32k!") 529 | else: 530 | self.set_stat_by_column("dex", amount) 531 | self._dex = amount 532 | 533 | def add_dex(self, amount: int) -> None: 534 | """Add the specified amount to the current existing DEX pool 535 | 536 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 537 | 538 | Args: 539 | 540 | amount (`int`): Represents the amount of DEX to be added to the current pool 541 | """ 542 | new_dex = int(self.dex) + amount 543 | self.dex = new_dex 544 | 545 | @property 546 | def inte(self) -> int: 547 | """`int`: Represents the character's INT stat pool 548 | 549 | The setter only accepts values that can be held using Java's signed 550 | `shorts` (-32768 - 32767). 551 | 552 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 553 | """ 554 | return self._inte 555 | 556 | @inte.setter 557 | def inte(self, amount: int) -> None: 558 | if amount > 32767: 559 | raise ValueError("You should not try to set INT above 30k!") 560 | elif amount < -32768: 561 | raise ValueError("You should not try to set INT to less than -32k!") 562 | else: 563 | self.set_stat_by_column("int", amount) 564 | self._inte = amount 565 | 566 | def add_inte(self, amount: int) -> None: 567 | """Add the specified amount to the current existing INT pool 568 | 569 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 570 | 571 | Args: 572 | 573 | amount (`int`): Represents the amount of INT to be added to the current pool 574 | """ 575 | new_inte = int(self.inte) + amount 576 | self.inte = new_inte 577 | 578 | @property 579 | def luk(self) -> int: 580 | """`int`: Represents the character's LUK stat pool 581 | 582 | The setter only accepts values that can be held using Java's signed 583 | `shorts` (-32768 - 32767). 584 | 585 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 586 | """ 587 | return self._luk 588 | 589 | @luk.setter 590 | def luk(self, amount: int) -> None: 591 | if amount > 32767: 592 | raise ValueError("You should not try to set LUK above 30k!") 593 | elif amount < -32768: 594 | raise ValueError("You should not try to set LUK to less than -32k!") 595 | else: 596 | self.set_stat_by_column("luk", amount) 597 | self._luk = amount 598 | 599 | def add_luk(self, amount: int) -> None: 600 | """Add the specified amount to the current existing LUK pool 601 | 602 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 603 | 604 | Args: 605 | 606 | amount (`int`): Represents the amount of LUK to be added to the current pool 607 | """ 608 | new_luk = int(self.luk) + amount 609 | self.luk = new_luk 610 | 611 | def get_primary_stats(self) -> dict[str, int]: 612 | """Returns str, int, dex, luk values in a dictionary 613 | 614 | Returns: 615 | A dictionary of the 4 primary stats 616 | """ 617 | primary_stats = { 618 | "str": self.strength, 619 | "dex": self.dex, 620 | "int": self.inte, 621 | "luk": self.luk 622 | } 623 | return primary_stats 624 | 625 | @property 626 | def max_hp(self) -> int: 627 | """`int`: Represents the character's Max HP stat pool 628 | 629 | The setter only accepts values from 1 to 500,000. YMMV if you're using 630 | an older Odin-like server whose client can not support 500,000 max HP. 631 | 632 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 633 | """ 634 | return self._max_hp 635 | 636 | @max_hp.setter 637 | def max_hp(self, amount: int) -> None: 638 | # Client-sided cap of 500k 639 | if amount > 500000: 640 | raise ValueError("You should not try to set Max HP above 500k!") 641 | elif amount < 1: 642 | raise ValueError("You should not try to set Max HP below 1!") 643 | else: 644 | self.set_stat_by_column("maxhp", amount) 645 | self._max_hp = amount 646 | 647 | def add_max_hp(self, amount: int) -> None: 648 | """Add the specified amount to the current existing Max HP pool 649 | 650 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 651 | 652 | Args: 653 | 654 | amount (`int`): Represents the amount of Max HP to be added to the current pool 655 | """ 656 | new_hp = int(self.max_hp) + amount 657 | self.max_hp = new_hp 658 | 659 | @property 660 | def max_mp(self) -> int: 661 | """`int`: Represents the character's Max MP stat pool 662 | 663 | The setter only accepts values from 1 to 500,000. YMMV if you're using 664 | an older Odin-like server whose client can not support 500,000 max MP. 665 | 666 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 667 | """ 668 | return self._max_mp 669 | 670 | @max_mp.setter 671 | def max_mp(self, amount: int) -> None: 672 | # Client-sided cap of 500k 673 | if amount > 500000: 674 | raise ValueError("You should not try to set Max MP above 500k!") 675 | elif amount < 1: 676 | raise ValueError("You should not try to set Max MP below 1!") 677 | else: 678 | self.set_stat_by_column("maxmp", amount) 679 | self._max_mp = amount 680 | 681 | def add_max_mp(self, amount: int) -> None: 682 | """Add the specified amount to the current existing Max MP pool 683 | 684 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 685 | 686 | Args: 687 | 688 | amount (`int`): Represents the amount of max MP to be added to the current pool 689 | """ 690 | new_mp = int(self.max_mp) + amount 691 | self.max_mp = new_mp 692 | 693 | @property 694 | def ap(self) -> int: 695 | """`int`: Represents the character's free Ability Points (AP) pool 696 | 697 | The setter only accepts values that can be held using Java's signed 698 | `shorts` (-32768 - 32767). Negative values are allowed for developers' 699 | testing convenience. 700 | 701 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 702 | """ 703 | return self._ap 704 | 705 | @ap.setter 706 | def ap(self, amount: int) -> None: 707 | # Azure DB uses Int (max 2.1b) for stats, but it may cause problems 708 | # with the client if one exceeds signed shorts (max 32k) 709 | if amount > 32767: 710 | raise ValueError("You should not try to set AP above 32k!") 711 | elif amount < -32768: 712 | raise ValueError("You should not try to set AP to less than -32k!") 713 | else: 714 | self.set_stat_by_column("ap", amount) 715 | self._ap = amount 716 | 717 | def add_ap(self, amount: int) -> None: 718 | """Add the specified amount to the current existing free AP pool 719 | 720 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 721 | 722 | Args: 723 | 724 | amount (`int`): Represents the amount of free AP to be added to the current pool 725 | """ 726 | new_ap = int(self.ap) + amount 727 | self.ap = new_ap 728 | 729 | @property 730 | def bl_slots(self) -> int: 731 | """`int`: Represents the character's Buddy List slots 732 | 733 | The setter only accepts values from 20 to 100. 734 | 735 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 736 | """ 737 | return self._bl_slots 738 | 739 | @bl_slots.setter 740 | def bl_slots(self, amount: int) -> None: 741 | # Client-sided cap of 100 742 | if amount > 100: 743 | raise ValueError("You should not try to set BL slots above 100!") 744 | elif amount < 20: 745 | raise ValueError("You should not try to set BL slots below 20!") 746 | else: 747 | self.set_stat_by_column("buddyCapacity", amount) 748 | self._bl_slots = amount 749 | 750 | def add_bl_slots(self, amount: int) -> None: 751 | """Add the specified amount to the current existing BL slots cap 752 | 753 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 754 | 755 | Args: 756 | 757 | amount (`int`): Represents the amount of BL slots to be added to the current limit 758 | """ 759 | new_amount = int(self.bl_slots) + amount 760 | self.bl_slots = new_amount 761 | 762 | @property 763 | def rebirths(self) -> int: 764 | """`int`: Represents the character's rebirth count 765 | 766 | The setter only accepts values from 0 to the upper limit for a 32-bit 767 | signed `int`. 768 | 769 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 770 | """ 771 | return self._rebirths 772 | 773 | @rebirths.setter 774 | def rebirths(self, amount: int) -> None: 775 | if amount > 2147483647: 776 | raise ValueError("You should not try to set rebirths above 2.1b!") 777 | elif amount < 0: 778 | raise ValueError("You should not try to set rebirths below 0!") 779 | else: 780 | self.set_stat_by_column("reborns", amount) 781 | self._rebirths = amount 782 | 783 | def add_rebirths(self, amount: int) -> None: 784 | """Add the specified amount to the current existing rebirth count 785 | 786 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 787 | 788 | Args: 789 | 790 | amount (`int`): Represents the amount of rebirths to be added to the current count 791 | """ 792 | new_amount = int(self.rebirths) + amount 793 | self.rebirths = new_amount 794 | 795 | @property 796 | def ambition(self) -> int: 797 | """`int`: Represents the character's Ambition pool 798 | 799 | The setter only accepts values from 0 to the upper limit for a 32-bit 800 | signed `int`. Note that we are unsure of the actual limitations in-game 801 | , so YMMV for large numbers. The current checks are based on the DB's 802 | limits. 803 | 804 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 805 | """ 806 | return self._ambition 807 | 808 | @ambition.setter 809 | def ambition(self, amount: int) -> None: 810 | # TODO: Add checks; DB allows 2.1b, 811 | # but not sure what the actual cap in source is 812 | if amount > 2147483647: 813 | raise ValueError("You should not try to set Ambition above 2.1b!") 814 | elif amount < 0: 815 | raise ValueError("You should not try to set Ambition below 0!") 816 | self.set_stat_by_column("ambition", amount) 817 | self._ambition = amount 818 | 819 | def add_ambition(self, amount: int) -> None: 820 | """Add the specified amount to the current existing Ambition pool 821 | 822 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 823 | 824 | Args: 825 | 826 | amount (`int`): Represents the amount of ambition exp to be added to the current pool 827 | """ 828 | new_amount = int(self.ambition) + amount 829 | self.ambition = new_amount 830 | 831 | @property 832 | def insight(self) -> int: 833 | """`int`: Represents the character's Insight pool 834 | 835 | The setter only accepts values from 0 to the upper limit for a 32-bit 836 | signed `int`. Note that we are unsure of the actual limitations in-game 837 | , so YMMV for large numbers. The current checks are based on the DB's 838 | limits. 839 | 840 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 841 | """ 842 | return self._insight 843 | 844 | @insight.setter 845 | def insight(self, amount: int) -> None: 846 | # TODO: Add checks; DB allows 2.1b, 847 | # but not sure what the actual cap in source is 848 | if amount > 2147483647: 849 | raise ValueError("You should not try to set Insight above 2.1b!") 850 | elif amount < 0: 851 | raise ValueError("You should not try to set Insight below 0!") 852 | self.set_stat_by_column("insight", amount) 853 | self._insight = amount 854 | 855 | def add_insight(self, amount: int) -> None: 856 | """Add the specified amount to the current existing Insight pool 857 | 858 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 859 | 860 | Args: 861 | 862 | amount (`int`): Represents the amount of insight exp to be added to the current pool 863 | """ 864 | new_amount = int(self.insight) + amount 865 | self.insight = new_amount 866 | 867 | @property 868 | def willpower(self) -> int: 869 | """`int`: Represents the character's Willpower pool 870 | 871 | The setter only accepts values from 0 to the upper limit for a 32-bit 872 | signed `int`. Note that we are unsure of the actual limitations in-game 873 | , so YMMV for large numbers. The current checks are based on the DB's 874 | limits. 875 | 876 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 877 | """ 878 | return self._willpower 879 | 880 | @willpower.setter 881 | def willpower(self, amount: int) -> None: 882 | # TODO: Add checks; DB allows 2.1b, 883 | # but not sure what the actual cap in source is 884 | if amount > 2147483647: 885 | raise ValueError("You should not try to set Willpower above 2.1b!") 886 | elif amount < 0: 887 | raise ValueError("You should not try to set Willpower below 0!") 888 | self.set_stat_by_column("willpower", amount) 889 | self._willpower = amount 890 | 891 | def add_willpower(self, amount: int) -> None: 892 | """Add the specified amount to the current existing Willpower pool 893 | 894 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 895 | 896 | Args: 897 | 898 | amount (`int`): Represents the amount of willpower exp to be added to the current pool 899 | """ 900 | new_amount = int(self.willpower) + amount 901 | self.willpower = new_amount 902 | 903 | @property 904 | def diligence(self) -> int: 905 | """`int`: Represents the character's Diligence pool 906 | 907 | The setter only accepts values from 0 to the upper limit for a 32-bit 908 | signed `int`. Note that we are unsure of the actual limitations in-game 909 | , so YMMV for large numbers. The current checks are based on the DB's 910 | limits. 911 | 912 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 913 | """ 914 | return self._diligence 915 | 916 | @diligence.setter 917 | def diligence(self, amount: int) -> None: 918 | # TODO: Add checks; DB allows 2.1b, 919 | # but not sure what the actual cap in source is 920 | if amount > 2147483647: 921 | raise ValueError("You should not try to set Diligence above 2.1b!") 922 | elif amount < 0: 923 | raise ValueError("You should not try to set Diligence below 0!") 924 | self.set_stat_by_column("diligence", amount) 925 | self._diligence = amount 926 | 927 | def add_diligence(self, amount: int) -> None: 928 | """Add the specified amount to the current existing Diligence pool 929 | 930 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 931 | 932 | Args: 933 | 934 | amount (`int`): Represents the amount of diligence exp to be added to the current pool 935 | """ 936 | new_amount = int(self.diligence) + amount 937 | self.diligence = new_amount 938 | 939 | @property 940 | def empathy(self) -> None: 941 | """`int`: Represents the character's Empathy pool 942 | 943 | The setter only accepts values from 0 to the upper limit for a 32-bit 944 | signed `int`. Note that we are unsure of the actual limitations in-game 945 | , so YMMV for large numbers. The current checks are based on the DB's 946 | limits. 947 | 948 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 949 | """ 950 | return self._empathy 951 | 952 | @empathy.setter 953 | def empathy(self, amount: int) -> None: 954 | # TODO: Add checks; DB allows 2.1b, 955 | # but not sure what the actual cap in source is 956 | if amount > 2147483647: 957 | raise ValueError("You should not try to set Empathy above 2.1b!") 958 | elif amount < 0: 959 | raise ValueError("You should not try to set Empathy below 0!") 960 | self.set_stat_by_column("empathy", amount) 961 | self._empathy = amount 962 | 963 | def add_empathy(self, amount: int) -> None: 964 | """Add the specified amount to the current existing Empathy pool 965 | 966 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 967 | 968 | Args: 969 | 970 | amount (`int`): Represents the amount of empathy exp to be added to the current pool 971 | """ 972 | new_amount = int(self.empathy) + amount 973 | self.empathy = new_amount 974 | 975 | @property 976 | def charm(self) -> int: 977 | """`int`: Represents the character's Charm pool 978 | 979 | The setter only accepts values from 0 to the upper limit for a 32-bit 980 | signed `int`. Note that we are unsure of the actual limitations in-game 981 | , so YMMV for large numbers. The current checks are based on the DB's 982 | limits. 983 | 984 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 985 | """ 986 | return self._charm 987 | 988 | @charm.setter 989 | def charm(self, amount: int) -> None: 990 | # TODO: Add checks; DB allows 2.1b, 991 | # but not sure what the actual cap in source is 992 | if amount > 2147483647: 993 | raise ValueError("You should not try to set Charm above 2.1b!") 994 | elif amount < 0: 995 | raise ValueError("You should not try to set Charm below 0!") 996 | self.set_stat_by_column("charm", amount) 997 | self._charm = amount 998 | 999 | def add_charm(self, amount: int) -> None: 1000 | """Add the specified amount to the current existing Charm pool 1001 | 1002 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 1003 | 1004 | Args: 1005 | 1006 | amount (`int`): Represents the amount of charm exp to be added to the current pool 1007 | """ 1008 | new_amount = int(self.charm) + amount 1009 | self.charm = new_amount 1010 | 1011 | def get_personality_traits(self) -> dict[str, int]: 1012 | """Returns the 6 personality traits' values in a dictionary 1013 | 1014 | Returns: 1015 | A dictionary of personality traits 1016 | """ 1017 | traits = { 1018 | "ambition": self.ambition, 1019 | "insight": self.insight, 1020 | "willpower": self.willpower, 1021 | "diligence": self.diligence, 1022 | "empathy": self.empathy, 1023 | "charm": self.charm, 1024 | } 1025 | return traits 1026 | 1027 | @property 1028 | def honour(self) -> int: 1029 | """`int`: Represents the character's Honour pool 1030 | 1031 | The setter only accepts values from 0 to the upper limit for a 32-bit 1032 | signed `int`. Note that we are unsure of the actual limitations in-game 1033 | , so YMMV for large numbers. The current checks are based on the DB's 1034 | limits. 1035 | 1036 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 1037 | """ 1038 | return self._honour 1039 | 1040 | @honour.setter 1041 | def honour(self, amount: int) -> None: 1042 | # TODO: Add checks; DB allows 2.1b, 1043 | # but not sure what the actual cap in source is 1044 | if amount > 2147483647: 1045 | raise ValueError("You should not try to set Honour above 2.1b!") 1046 | elif amount < 0: 1047 | raise ValueError("You should not try to set Honour below 0!") 1048 | self.set_stat_by_column("innerExp", amount) 1049 | self._honour = amount 1050 | 1051 | def add_honour(self, amount: int) -> None: 1052 | """Add the specified amount to the current existing Honour pool 1053 | 1054 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 1055 | 1056 | Args: 1057 | 1058 | amount (`int`): Represents the amount of honour exp to be added to the current pool 1059 | """ 1060 | new_amount = int(self.honour) + amount 1061 | self.honour = new_amount 1062 | 1063 | @property 1064 | def mute(self) -> str: 1065 | """`str`: Represents whether a character is chat-banned 1066 | 1067 | The setter only accepts string values "false", or "true". 1068 | 1069 | ### CAN ONLY BE SET WHEN SERVER IS OFF! 1070 | """ 1071 | return self._mute 1072 | 1073 | @mute.setter 1074 | def mute(self, status: str) -> None: 1075 | if status in ("false", "true"): 1076 | self.set_stat_by_column("chatban", status) 1077 | self._mute = status 1078 | else: 1079 | raise ValueError("Invalid input! Stick to `true` or `false`!") 1080 | 1081 | @property 1082 | def account(self) -> Account: 1083 | """`Account`: Represents the account associate with the character""" 1084 | return self._account 1085 | 1086 | def currency(self) -> dict[str, int]: 1087 | """Returns the values of the currencies held, in a dictionary 1088 | 1089 | Returns: 1090 | A dictionary of mesos, nx, maple points, vp, dp. 1091 | """ 1092 | currencies = { 1093 | "mesos": self.meso, 1094 | "nx": self.account.nx, 1095 | "maplepoints": self.account.maple_points, 1096 | "vp": self.account.vp, 1097 | "dp": self.account.dp, 1098 | } 1099 | return currencies 1100 | 1101 | def get_deep_copy(self) -> list[str]: 1102 | """Returns all known info about the character as a list 1103 | 1104 | Returns: 1105 | A dictionary of IGN, Char ID, Account ID, Job Name, RB count, 1106 | Level, Mesos, Fame, Gender, HP, MP, Stats, AP, EXP, Honour, 1107 | Traits, BL slots, Map ID, Mute Status, Login Status, Ban Status, 1108 | Ban Reason, Total Char Slots, Free Char Slots, DP, VP, NX, 1109 | and Maple Points. 1110 | """ 1111 | attributes = [ 1112 | f"Character {self.name}'s attributes:\n", 1113 | f"Character ID: {self.character_id}, ", 1114 | f"Account ID: {self.account_id}, ", 1115 | f"Job: {self.get_job_name()}, ", 1116 | f"Rebirth Count: {self.rebirths}, ", 1117 | f"Level: {self.level}, ", 1118 | f"Meso Count: {self.meso}, ", 1119 | f"Fame: {self.fame}, ", 1120 | f"Gender: {self.gender}, ", 1121 | f"HP/MP: {self.max_hp}, {self.max_mp}, ", 1122 | f"Stats: {self.get_primary_stats()}, ", 1123 | f"Free AP Pool: {self.ap}, ", 1124 | f"EXP Pool: {self.exp}, ", 1125 | f"Honour: {self.honour}, ", 1126 | f"Traits: {self.get_personality_traits()}, ", 1127 | f"Total Buddy List Slots: {self.bl_slots}, ", 1128 | f"Map ID: {self.map}, ", 1129 | f"Mute Status: {self.mute}, ", 1130 | ] 1131 | 1132 | attributes.extend(self.account.get_deep_copy()[2:]) 1133 | 1134 | return attributes 1135 | 1136 | def get_inv(self) -> Inventory: 1137 | """Create an `Inventory` instance from the Character ID attribute 1138 | 1139 | Uses the Character ID associated with the character, and the 1140 | `Inventory` class constructor to create a new `Inventory` object instance, 1141 | with the relevant inventory attributes from the database. 1142 | 1143 | Returns: 1144 | An `Inventory` object instantiated with corresponding data from the 1145 | connected database. 1146 | Defaults to `None` if the operation fails. 1147 | 1148 | Raises: 1149 | Generic error on failure - handled by the `get_db_first_hit()` method 1150 | """ 1151 | inventory = Inventory(self.character_id, self._database_config) 1152 | return inventory 1153 | 1154 | def get_char_img(self) -> str: 1155 | """Generates a character avatar using `MapleStory.io`; PLEASE USE SPARINGLY! 1156 | 1157 | Returns: 1158 | A string, a link to the generated avatar 1159 | """ 1160 | equipped_items = [self.face, self.hair] 1161 | equipped_inv = self.get_inv().equipped_inv 1162 | 1163 | for item in equipped_inv: 1164 | item_id = equipped_inv[item]["itemid"] 1165 | equipped_items.append(item_id) 1166 | 1167 | url = f"https://maplestory.io/api/GMS/216/Character/200{self.skin}/{str(equipped_items)[1:-1]}/stand1/1".replace(" ", "") 1168 | 1169 | return url 1170 | 1171 | def set_stat_by_column(self, column: str, value: Any) -> None: 1172 | """Update a character's stats from column name in database 1173 | 1174 | Grabs the database attributes provided through the class constructor. 1175 | Uses these attributes to attempt a database connection through 1176 | `utility.write_to_db`. Attempts to update the field represented by the 1177 | provided column in the characters table, with the provided value. 1178 | **NOT** recommended to use this alone, as it won't update the 1179 | character instance variables (in memory) post-change. 1180 | 1181 | ### ONLY WORKS WHEN SERVER IS OFF! 1182 | 1183 | Args: 1184 | 1185 | value (`int`, `str`, or `datetime`): Represents the value to be set in the database 1186 | column (`str`): Represents the column in the database that is to be updated 1187 | 1188 | Returns: 1189 | A `bool` representing whether the operation was successful 1190 | 1191 | Raises: 1192 | Generic error, handled in `utility.write_to_db` 1193 | """ 1194 | status = utils.write_to_db( 1195 | self._database_config, 1196 | f"UPDATE `characters` SET {column} = '{value}' " 1197 | f"WHERE `name` = '{self.name}'" 1198 | ) 1199 | if status: 1200 | print( 1201 | f"Successfully updated {column} value " 1202 | f"for character: {self.name}." 1203 | ) 1204 | self._stats[column] = value # Update the stats in the dictionary 1205 | return status 1206 | 1207 | def get_stat_by_column(self, column: str) -> Any: 1208 | """Fetches account attribute by column name 1209 | 1210 | Args: 1211 | 1212 | column (`str`): Represents column name in DB 1213 | 1214 | Returns: 1215 | An `int`, `str`, or `datetime`, representing user attribute queried 1216 | 1217 | Raises: 1218 | Generic error on failure, handled by `utils.get_stat_by_column` 1219 | """ 1220 | return utils.get_stat_by_column(self._stats, column) 1221 | --------------------------------------------------------------------------------