├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── analyze.yml │ ├── build.yml │ ├── database.yml │ ├── deploy.yml │ ├── docker.yml │ └── pages.yml ├── .gitignore ├── .gitlab-ci.yml ├── .readthedocs.yml ├── .run ├── Coverage.run.xml ├── Docs (Build).run.xml ├── Docs (Serve).run.xml ├── Generate (Skip Images).run.xml ├── Generate.run.xml ├── Sphinx.run.xml └── Unittests in tests.run.xml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── api │ ├── api.md │ ├── database.md │ ├── errors.md │ ├── generation.md │ ├── models │ │ ├── achievement.md │ │ ├── base.md │ │ ├── charm.md │ │ ├── creature.md │ │ ├── house.md │ │ ├── imbuement.md │ │ ├── index.md │ │ ├── item.md │ │ ├── mount.md │ │ ├── npc.md │ │ ├── outfit.md │ │ ├── quest.md │ │ ├── spell.md │ │ ├── update.md │ │ └── world.md │ ├── parsers.md │ ├── schema.md │ └── utils.md ├── changelog.md ├── images │ ├── TibiaWiki.gif │ └── logo.gif ├── index.md ├── intro.md └── schema.md ├── generateWithDocker.sh ├── mkdocs.yaml ├── pyproject.toml ├── requirements-docs.txt ├── requirements-server.txt ├── requirements-testing.txt ├── requirements.txt ├── sonar-project.properties ├── tests ├── __init__.py ├── models │ ├── __init__.py │ ├── test_achievement.py │ ├── test_book.py │ ├── test_item.py │ └── test_npc.py ├── parsers │ ├── __init__.py │ ├── test_achievement.py │ ├── test_creature.py │ ├── test_npc.py │ └── test_update.py ├── resources │ ├── content_achievement.txt │ ├── content_book.txt │ ├── content_charm.txt │ ├── content_creature.txt │ ├── content_house.txt │ ├── content_imbuement.txt │ ├── content_item.txt │ ├── content_item_damage_reflection.txt │ ├── content_item_no_attrib.txt │ ├── content_item_perfect_shot.txt │ ├── content_item_resist.txt │ ├── content_item_sounds.txt │ ├── content_item_store.txt │ ├── content_key.txt │ ├── content_loot_statistics.txt │ ├── content_mount.txt │ ├── content_npc.txt │ ├── content_npc_travel.txt │ ├── content_outfit.txt │ ├── content_quest.txt │ ├── content_spell.txt │ ├── content_update.txt │ ├── content_world.txt │ ├── response_category_without_continue.json │ ├── response_image_info.json │ └── response_revisions.json ├── test_generation.py ├── tests_models.py ├── tests_parsers.py ├── tests_schema.py ├── tests_utils.py └── tests_wikiapi.py └── tibiawikisql ├── __init__.py ├── __main__.py ├── api.py ├── database.py ├── errors.py ├── generation.py ├── models ├── __init__.py ├── achievement.py ├── base.py ├── charm.py ├── creature.py ├── house.py ├── imbuement.py ├── item.py ├── mount.py ├── npc.py ├── outfit.py ├── quest.py ├── spell.py ├── update.py └── world.py ├── parsers ├── __init__.py ├── achievement.py ├── base.py ├── book.py ├── charm.py ├── creature.py ├── house.py ├── imbuement.py ├── item.py ├── key.py ├── mount.py ├── npc.py ├── outfit.py ├── quest.py ├── spell.py ├── update.py └── world.py ├── schema.py ├── server.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | !tibiawikisql/ 3 | !requirements.txt -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4 | - Galarzaa90 5 | patreon: # Replace with a single Patreon username 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: galarzaa 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: # Replace with a single Liberapay username 11 | issuehunt: # Replace with a single IssueHunt username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | polar: # Replace with a single Polar username 14 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/analyze.yml: -------------------------------------------------------------------------------- 1 | name: 🔎 Analyze 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | build: 12 | name: 🧪 Test & Analyze 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 🚚 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 1 19 | - name: Set up Python 🐍 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: 3.13 23 | - name: Install dependencies ⚙️ 24 | run: | 25 | python -m pip install -U pip 26 | pip install -U -e .[testing] 27 | - name: Test with Coverage 🔧 28 | run: | 29 | coverage run -m unittest discover 30 | - name: Generate Coverage Reports 📋 31 | run: | 32 | coverage report 33 | coverage xml 34 | - name: Upload reports 📤 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: reports 38 | path: | 39 | coverage.xml 40 | sonarcloud: 41 | name: SonarCloud 42 | runs-on: ubuntu-latest 43 | needs: build 44 | steps: 45 | - name: Checkout 🚚 46 | uses: actions/checkout@v4 47 | with: 48 | fetch-depth: 0 49 | - name: Download reports 📥 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: reports 53 | - name: SonarCloud Scan ☁️ 54 | uses: SonarSource/sonarqube-scan-action@v5 55 | env: 56 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 57 | codecov: 58 | name: CodeCov 59 | runs-on: ubuntu-latest 60 | needs: build 61 | steps: 62 | - name: Download reports 📥 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: reports 66 | - name: Upload to Codecov ☂️ 67 | uses: codecov/codecov-action@v5 68 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: ⚙️ Build 2 | 3 | 4 | on: [push] 5 | 6 | jobs: 7 | build: 8 | name: ⚙️🧪 Build and test 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 4 12 | matrix: 13 | python-version: ["3.10", "3.11", "3.12", "3.13"] 14 | 15 | steps: 16 | - name: Checkout 🚚 17 | uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 🐍 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies ⚙️ 23 | run: | 24 | pip install -U pip 25 | pip install -U -e .[testing] 26 | - name: Test 🧪 27 | run: | 28 | python -m unittest discover 29 | -------------------------------------------------------------------------------- /.github/workflows/database.yml: -------------------------------------------------------------------------------- 1 | name: 🗄 Build Database 2 | on: 3 | push: 4 | branches: 5 | - main 6 | schedule: 7 | - cron: '0 0 * * 0' # Every Sunday at 00:00 8 | 9 | jobs: 10 | build: 11 | name: ⚙️ Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 🚚 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 🐍 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.13" 21 | 22 | - name: Install ⚙️ 23 | run: pip install -U -e . 24 | 25 | - name: Cache Images 📦 26 | uses: actions/cache@v4 27 | with: 28 | path: images/ 29 | key: images 30 | restore-keys: | 31 | images 32 | 33 | - name: Generate Database 🗄 34 | run: tibiawikisql generate --skip-deprecated 35 | 36 | - name: Upload database 🚀 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: tibiawiki-db 40 | path: | 41 | tibiawiki.db 42 | images/ 43 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build-n-publish: 10 | name: ⚙️🚀 Build and publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 🚚 14 | uses: actions/checkout@master 15 | - name: Set up Python 3.13 🐍 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.13 19 | - name: Install dependencies ⚙️ 20 | run: | 21 | python -m pip install wheel twine build 22 | - name: Build Package 📦 23 | run: | 24 | python -m build 25 | - name: Publish to PyPi 🚀 26 | uses: pypa/gh-action-pypi-publish@release/v1 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.PYPI_API_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: 🐋 Docker Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | - main 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | build: 13 | name: ⚙️ Build and Publish Images 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Cache Docker Layers 📦 17 | uses: actions/cache@v4 18 | with: 19 | path: /tmp/.buildx-cache 20 | key: ${{ runner.os }}-buildx-${{ github.sha }} 21 | restore-keys: | 22 | ${{ runner.os }}-buildx- 23 | - name: Checkout 🚚 24 | uses: actions/checkout@v4 25 | 26 | - name: Docker Meta 📋 27 | id: meta 28 | uses: docker/metadata-action@v5 29 | with: 30 | images: | 31 | ghcr.io/${{ github.repository }} 32 | ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPO }} 33 | tags: | 34 | type=ref,event=branch 35 | type=pep440,pattern={{version}} 36 | type=pep440,pattern={{major}}.{{minor}}.{{patch}} 37 | type=pep440,pattern={{major}}.{{minor}} 38 | type=pep440,pattern={{major}} 39 | - name: Login to Docker Hub 🐳 40 | uses: docker/login-action@v3 41 | with: 42 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 43 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 44 | 45 | - name: Login to GHCR 🐙 46 | uses: docker/login-action@v3 47 | with: 48 | registry: ghcr.io 49 | username: ${{ github.repository_owner }} 50 | password: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | - name: Set up Docker Buildx 🐋 53 | id: buildx 54 | uses: docker/setup-buildx-action@v3 55 | 56 | - name: Build and Push 🚀 57 | id: docker_build 58 | uses: docker/build-push-action@v6 59 | with: 60 | context: ./ 61 | file: ./Dockerfile 62 | builder: ${{ steps.buildx.outputs.name }} 63 | push: true 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} 66 | cache-from: type=local,src=/tmp/.buildx-cache 67 | cache-to: type=local,dest=/tmp/.buildx-cache 68 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: 🐙 Github Pages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | name: 📄 Build & Deploy Docs 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 🚚 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Python 🐍 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.13" 19 | 20 | - name: Install dependencies ⚙️ 21 | run: | 22 | pip install -U -e .[docs] 23 | 24 | - name: Build MkDocs 📙 25 | run: | 26 | mkdocs build 27 | 28 | - name: Deploy 🚀 29 | uses: JamesIves/github-pages-deploy-action@4.0.0 30 | with: 31 | branch: gh-pages 32 | folder: site 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /images/ 2 | *.db 3 | *.db-journal 4 | 5 | # IDEs 6 | .idea/ 7 | .vscode/ 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Environments 93 | .env 94 | .venv 95 | env/ 96 | venv/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:3.13 2 | 3 | cache: 4 | paths: 5 | - .cache/pip 6 | 7 | variables: 8 | SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" 9 | GIT_DEPTH: "0" 10 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 11 | 12 | stages: 13 | - build 14 | - test 15 | - deploy 16 | 17 | before_script: 18 | - pip install -U -e . 19 | 20 | build: 21 | stage: build 22 | script: 23 | - pip install build 24 | - python -m build 25 | artifacts: 26 | name: tibiawikisql-dist 27 | paths: 28 | - dist/ 29 | 30 | docs: 31 | stage: build 32 | script: 33 | - pip install -U -e .[docs] 34 | - mkdocs build 35 | artifacts: 36 | name: tibiawikisql-docs 37 | paths: 38 | - site/ 39 | 40 | coverage: 41 | stage: test 42 | script: 43 | - pip install -U -e .[testing] 44 | - coverage run -m unittest discover 45 | - coverage report 46 | - coverage html 47 | - coverage xml 48 | artifacts: 49 | name: Coverage_Report 50 | paths: 51 | - htmlcov/ 52 | - coverage.xml 53 | 54 | database: 55 | stage: deploy 56 | rules: 57 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH 58 | when: always 59 | - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH 60 | when: manual 61 | script: 62 | - tibiawikisql generate 63 | cache: 64 | paths: 65 | - images/ 66 | artifacts: 67 | paths: 68 | - tibiawiki.db 69 | 70 | 71 | pages: 72 | stage: deploy 73 | dependencies: 74 | - docs 75 | - coverage 76 | script: 77 | - mkdir public 78 | - mv htmlcov/ public/coverage/ 79 | - mv site/* public/ 80 | artifacts: 81 | paths: 82 | - public 83 | only: 84 | - main 85 | - dev 86 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-24.04" 5 | tools: 6 | python: "3" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | mkdocs: 16 | configuration: mkdocs.yaml 17 | -------------------------------------------------------------------------------- /.run/Coverage.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.run/Docs (Build).run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.run/Docs (Serve).run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.run/Generate (Skip Images).run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /.run/Generate.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /.run/Sphinx.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | -------------------------------------------------------------------------------- /.run/Unittests in tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim 2 | 3 | WORKDIR /usr/src/app 4 | COPY requirements.txt . 5 | RUN pip install -r requirements.txt 6 | 7 | LABEL maintainer="Allan Galarza " 8 | LABEL org.opencontainers.image.licenses="Apache 2.0" 9 | LABEL org.opencontainers.image.authors="Allan Galarza " 10 | LABEL org.opencontainers.image.url="https://github.com/Galarzaa90/tibiawiki-sql" 11 | LABEL org.opencontainers.image.source="https://github.com/Galarzaa90/tibiawiki-sql" 12 | LABEL org.opencontainers.image.vendor="Allan Galarza " 13 | LABEL org.opencontainers.image.title="tibiawiki-sql" 14 | LABEL org.opencontainers.image.description="Python script that generates a SQLite database from TibiaWiki articles." 15 | 16 | COPY . . 17 | ENTRYPOINT ["python","-m", "tibiawikisql", "generate"] 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include CHANGELOG.rst 4 | include requirements.txt 5 | recursive-include tibiawikisql 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tibiawiki-sql 2 | 3 | Script that generates a sqlite database for the MMO Tibia. 4 | 5 | Inspired in [Mytherin's Tibiaylzer](https://github.com/Mytherin/Tibialyzer) TibiaWiki parsing script. 6 | 7 | This script fetches data from TibiaWiki via its API, compared to relying on [database dumps](http://tibia.fandom.com/wiki/Special:Statistics) 8 | that are not updated as frequently. By using the API, the data obtained is always fresh. 9 | 10 | This script is not intended to be running constantly, it is meant to be run once, generate a sqlite database and use it 11 | externally. 12 | 13 | If you integrate this into your project or use the generated data, make sure to credit [TibiaWiki](https://tibia.fandom.com) and its contributors. 14 | 15 | 16 | [![Build Status](https://travis-ci.org/Galarzaa90/tibiawiki-sql.svg?branch=master)](https://travis-ci.org/Galarzaa90/tibiawiki-sql) 17 | [![GitHub (pre-)release](https://img.shields.io/github/release/Galarzaa90/tibiawiki-sql/all.svg)](https://github.com/Galarzaa90/tibiawiki-sql/releases) 18 | [![PyPI](https://img.shields.io/pypi/v/tibiawikisql.svg)](https://pypi.python.org/pypi/tibiawikisql/) 19 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tibiawikisql.svg) 20 | ![PyPI - License](https://img.shields.io/pypi/l/tibiawikisql.svg) 21 | 22 | ## Requirements 23 | * Python 3.10 or higher 24 | 25 | ## Installing 26 | To install the latest version on PyPi: 27 | 28 | ```sh 29 | pip install tibiawikisql 30 | ``` 31 | 32 | or 33 | 34 | Install the latest version from GitHub 35 | 36 | pip install git+https://github.com/Galarzaa90/tibiawiki-sql.git 37 | 38 | ## Running 39 | 40 | ```sh 41 | python -m tibiawikisql generate 42 | ``` 43 | 44 | OR 45 | 46 | ```sh 47 | tibiawikisql 48 | ``` 49 | 50 | The process can be long, taking up to 10 minutes the first time. All images are saved to the `images` folder. On 51 | subsequent runs, images will be read from disk instead of being fetched from TibiaWiki again. 52 | If a newer version of the image has been uploaded, it will be updated. 53 | 54 | When done, a database file called `tibiawiki.db` will be found on the folder. 55 | 56 | ## Docker 57 | ![Docker Pulls](https://img.shields.io/docker/pulls/galarzaa90/tibiawiki-sql) 58 | ![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/galarzaa90/tibiawiki-sql?sort=semver) 59 | 60 | The database can also be generated without installing the project, it's dependencies, or Python, by using Docker. 61 | Make sure to have Docker installed, then simply run: 62 | 63 | ```sh 64 | generateWithDocker.sh 65 | ``` 66 | 67 | The script will build a Docker image and run the script inside a container. The `tibiawiki.db` file will end up in 68 | the project's root directory as normal. 69 | 70 | ## Database contents 71 | * Achievements 72 | * Charms 73 | * Creatures 74 | * Creature drop statistics 75 | * Houses 76 | * Imbuements 77 | * Items 78 | * Mounts 79 | * NPCs 80 | * NPC offers 81 | * Outfits 82 | * Quests 83 | * Spells 84 | * Updates 85 | * Worlds 86 | 87 | ## Documentation 88 | Check out the [documentation page](https://galarzaa90.github.io/tibiawiki-sql/). 89 | 90 | 91 | ## Contributing 92 | Improvements and bug fixes are welcome, via pull requests 93 | For questions, suggestions and bug reports, submit an issue. 94 | 95 | The best way to contribute to this project is by contributing to [TibiaWiki](https://tibia.fandom.com). 96 | 97 | ![image](https://vignette.wikia.nocookie.net/tibia/images/d/d9/Tibiawiki_Small.gif/revision/latest?cb=20150129101832&path-prefix=en) 98 | -------------------------------------------------------------------------------- /docs/api/api.md: -------------------------------------------------------------------------------- 1 | # TibiaWiki API 2 | 3 | ::: tibiawikisql.api 4 | options: 5 | inherited_members: true 6 | -------------------------------------------------------------------------------- /docs/api/database.md: -------------------------------------------------------------------------------- 1 | 2 | ::: tibiawikisql.database 3 | -------------------------------------------------------------------------------- /docs/api/errors.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.errors 2 | -------------------------------------------------------------------------------- /docs/api/generation.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.generation 2 | -------------------------------------------------------------------------------- /docs/api/models/achievement.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.achievement 2 | -------------------------------------------------------------------------------- /docs/api/models/base.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.base 2 | -------------------------------------------------------------------------------- /docs/api/models/charm.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.charm 2 | -------------------------------------------------------------------------------- /docs/api/models/creature.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.creature 2 | -------------------------------------------------------------------------------- /docs/api/models/house.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.house 2 | -------------------------------------------------------------------------------- /docs/api/models/imbuement.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.imbuement 2 | -------------------------------------------------------------------------------- /docs/api/models/index.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models 2 | -------------------------------------------------------------------------------- /docs/api/models/item.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.item 2 | -------------------------------------------------------------------------------- /docs/api/models/mount.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.mount 2 | -------------------------------------------------------------------------------- /docs/api/models/npc.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.npc 2 | -------------------------------------------------------------------------------- /docs/api/models/outfit.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.outfit 2 | -------------------------------------------------------------------------------- /docs/api/models/quest.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.quest 2 | -------------------------------------------------------------------------------- /docs/api/models/spell.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.spell 2 | -------------------------------------------------------------------------------- /docs/api/models/update.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.update 2 | -------------------------------------------------------------------------------- /docs/api/models/world.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.models.world 2 | -------------------------------------------------------------------------------- /docs/api/parsers.md: -------------------------------------------------------------------------------- 1 | 2 | ::: tibiawikisql.parsers.base 3 | 4 | ::: tibiawikisql.parsers.achievement 5 | 6 | 7 | ::: tibiawikisql.parsers.book 8 | 9 | 10 | ::: tibiawikisql.parsers.charm 11 | 12 | 13 | ::: tibiawikisql.parsers.creature 14 | 15 | 16 | ::: tibiawikisql.parsers.house 17 | 18 | 19 | ::: tibiawikisql.parsers.imbuement 20 | 21 | 22 | ::: tibiawikisql.parsers.item 23 | 24 | 25 | ::: tibiawikisql.parsers.key 26 | 27 | 28 | ::: tibiawikisql.parsers.mount 29 | 30 | 31 | ::: tibiawikisql.parsers.npc 32 | 33 | 34 | ::: tibiawikisql.parsers.outfit 35 | 36 | 37 | ::: tibiawikisql.parsers.quest 38 | 39 | 40 | ::: tibiawikisql.parsers.spell 41 | 42 | 43 | ::: tibiawikisql.parsers.update 44 | 45 | 46 | ::: tibiawikisql.parsers.world 47 | -------------------------------------------------------------------------------- /docs/api/schema.md: -------------------------------------------------------------------------------- 1 | 2 | ::: tibiawikisql.schema 3 | -------------------------------------------------------------------------------- /docs/api/utils.md: -------------------------------------------------------------------------------- 1 | ::: tibiawikisql.utils 2 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" 2 | -------------------------------------------------------------------------------- /docs/images/TibiaWiki.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Galarzaa90/tibiawiki-sql/ff2c9b86462c694ceb2492f90fd2d75b2eb6023f/docs/images/TibiaWiki.gif -------------------------------------------------------------------------------- /docs/images/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Galarzaa90/tibiawiki-sql/ff2c9b86462c694ceb2492f90fd2d75b2eb6023f/docs/images/logo.gif -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ## TibiaWikiSQL 2 | 3 | TibiaWikiSQL is a script that generates a SQLite database from TibiaWiki articles by using its API. 4 | 5 | This allows you to have all the data available at once, as well as take advantage of the features of a relational database. 6 | 7 | All information is gathered from [TibiaWiki](https://tibia.fandom.com), visit and contribute to the project! 8 | 9 | If you use this into your project or use the generate data, make sure to credit them and their contributors. 10 | 11 | [![TibiaWiki Logo](images/TibiaWiki.gif)](https://tibia.fandom.com/wiki/Main_Page) 12 | 13 | [![GitHub (pre-)release](https://img.shields.io/github/release/Galarzaa90/tibiawiki-sql/all.svg)](https://github.com/Galarzaa90/tibiawiki-sql/releases) 14 | [![PyPI](https://img.shields.io/pypi/v/tibiawikisql.svg)](https://pypi.python.org/pypi/tibiawikisql/) 15 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tibiawikisql.svg) 16 | ![PyPI - License](https://img.shields.io/pypi/l/tibiawikisql.svg) 17 | ![Docker Pulls](https://img.shields.io/docker/pulls/galarzaa90/tibiawiki-sql) 18 | ![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/galarzaa90/tibiawiki-sql?sort=semver) 19 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | TibiaWikiSQL works as a command-line interface, allowing passing parameters to customize the behaviour of the script. 3 | 4 | ## Prerequisites 5 | TibiaWikiSQL requires Python 3.10 or higher. 6 | 7 | ## Installation 8 | This module can be installed from PyPi using: 9 | 10 | ```shell 11 | python -m pip install -U tibiawikisql 12 | ``` 13 | ## Usage 14 | ## As a script 15 | Once the module has been installed, it can be run by using: 16 | 17 | ```shell 18 | tibiawikisql 19 | ``` 20 | 21 | Or 22 | 23 | ```shell 24 | python -m tibiawikisql 25 | ``` 26 | 27 | The generate script can be run using: 28 | 29 | ```shell 30 | tibiawikisql generate 31 | ``` 32 | 33 | This fetches all the relevant articles from TibiaWiki and stores them in the datatabase. 34 | 35 | It accepts the following parameters: 36 | 37 | - `-s`/`--skip-images` Option to skip fetching and saving images. 38 | - `-db`/ `--db-name` The name of the generated database file. `tibiawiki.db` by default. 39 | - `-sd`/ `--skip-deprecated` Option to skip deprecated articles when parsing. 40 | 41 | The generated database is saved in the current directory, as well as a folder called `images` with all the fetched images. 42 | 43 | Subsequent calls will use the images in the directory instead of fetching them again, serving as an image cache. 44 | 45 | ### As a module 46 | 47 | TibiaWikiSQL can now be imported to be used as an API, whether to fetch live articles from TibiaWiki or to easily manage 48 | entities from the generated database. 49 | 50 | !!! note 51 | 52 | Due to the structure of TibiaWiki articles, with some content being rendered dynamically, some information is not 53 | available when live fetching, compared to fetching from the generated database. 54 | 55 | 56 | The following is an example of an article being obtained from TibiaWiki. 57 | 58 | ```python 59 | import tibiawikisql 60 | article = tibiawikisql.WikiClient.get_article("Demon") 61 | # creature now contains all the parsed information 62 | creature = Creature.from_article(article) 63 | # This would result in None, since the article doesn't contain an item. 64 | item = Item.from_article(article) 65 | ``` 66 | 67 | The following is an example of an article being obtained from the database. 68 | 69 | ```python 70 | import tibiawikisql 71 | import sqlite3 72 | 73 | # Path to the previously generated database 74 | conn = sqlite3.connect("tibiawiki.db") 75 | # creature now contains all the parsed information, including loot statistics. 76 | creature = Creature.get_one_by_field(conn, "title", "Demon") 77 | # This would return a list of Item objects. 78 | # Note that when multiple objects are obtained, their child rows are not fetched. 79 | swords = Item.search(conn, "type", "Sword Weapons") 80 | ``` 81 | -------------------------------------------------------------------------------- /generateWithDocker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo docker build -t tibiawiki-sql . 4 | 5 | touch tibiawiki.db 6 | mkdir -p images 7 | 8 | sudo docker run \ 9 | -v "$(pwd)"/tibiawiki.db:/usr/src/app/tibiawiki.db \ 10 | -v "$(pwd)"/images:/usr/src/app/images \ 11 | -ti --rm tibiawiki-sql 12 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | site_name: TibiaWikiSQL 3 | site_author: Allan Galarza 4 | site_description: TibiaWikiSQL is a script that generates a SQLite database from TibiaWiki articles by using its API. 5 | repo_url: https://github.com/Galarzaa90/tibiawiki-sql 6 | repo_name: Galarzaa90/tibiawiki-sql 7 | copyright: > 8 | © 2025 Allan Galarza
9 | Tibia is made by CipSoft, all Tibia content is copyrighted by CipSoft GmbH.
10 | The data collected by this library is provided by TibiaWiki. 11 | 12 | theme: 13 | name: material 14 | language: en 15 | logo: images/logo.gif 16 | palette: 17 | # Palette toggle for automatic mode 18 | - media: "(prefers-color-scheme)" 19 | toggle: 20 | icon: material/brightness-auto 21 | name: Switch to light mode 22 | 23 | # Palette toggle for light mode 24 | - media: "(prefers-color-scheme: light)" 25 | scheme: default 26 | primary: brown 27 | toggle: 28 | icon: material/brightness-7 29 | name: Switch to dark mode 30 | 31 | # Palette toggle for dark mode 32 | - media: "(prefers-color-scheme: dark)" 33 | scheme: slate 34 | primary: brown 35 | toggle: 36 | icon: material/brightness-4 37 | name: Switch to system preference 38 | icon: 39 | repo: fontawesome/brands/github 40 | features: 41 | - content.code.copy 42 | - navigation.footer 43 | - navigation.instant 44 | - navigation.instant.progress 45 | - navigation.tabs 46 | - navigation.tabs.sticky 47 | - navigation.top 48 | - navigation.tracking 49 | - search.suggest 50 | - search.highlight 51 | - search.share 52 | - toc.follow 53 | 54 | extra: 55 | social: 56 | - icon: fontawesome/brands/github 57 | link: https://github.com/Galarzaa90/tibiawiki-sql 58 | - icon: simple/pypi 59 | link: https://pypi.org/project/tibiawikisql/ 60 | - icon: fontawesome/brands/docker 61 | link: https://hub.docker.com/r/galarzaa90/tibiawiki-sql 62 | 63 | plugins: 64 | - search 65 | - mkdocstrings: 66 | handlers: 67 | python: 68 | options: 69 | # General 70 | allow_inspection: true 71 | extensions: 72 | - griffe_pydantic: { schema: true } 73 | - griffe_inherited_docstrings 74 | - docstring_inheritance.griffe 75 | find_stubs_package: False 76 | force_inspection: false 77 | preload_modules: [ ] 78 | show_bases: true 79 | show_source: true 80 | # Headings 81 | annotations_path: brief 82 | docstring_style: google 83 | members_order: source 84 | separate_signature: true 85 | show_signature_annotations: true 86 | signature_crossrefs: true 87 | show_symbol_type_heading: true 88 | show_root_heading: true 89 | show_overloads: true 90 | heading_level: 3 91 | docstring_options: 92 | ignore_init_summary: true 93 | merge_init_into_class: true 94 | summary: true 95 | inventories: 96 | - url: https://docs.python.org/3/objects.inv 97 | 98 | 99 | markdown_extensions: 100 | - admonition 101 | - pymdownx.details 102 | - pymdownx.superfences 103 | - pymdownx.highlight: 104 | anchor_linenums: true 105 | - pymdownx.snippets 106 | - toc: 107 | permalink: true 108 | 109 | watch: 110 | - docs 111 | - mkdocs.yaml 112 | - tibiawikisql 113 | 114 | nav: 115 | - Home: 116 | - Home: index.md 117 | - Introduction: intro.md 118 | - Changelog: changelog.md 119 | - API Reference: 120 | - TibiaWiki API: api/api.md 121 | - Database: api/database.md 122 | - Generation: api/generation.md 123 | - Errors: api/errors.md 124 | - Models: 125 | - Package Index: api/models/index.md 126 | - Base: api/models/base.md 127 | - Achievement: api/models/achievement.md 128 | - Book: api/models/book.md 129 | - Charm: api/models/charm.md 130 | - Creature: api/models/creature.md 131 | - House: api/models/house.md 132 | - Imbuement: api/models/imbuement.md 133 | - Item: api/models/item.md 134 | - Mount: api/models/mount.md 135 | - Npc: api/models/npc.md 136 | - Outfit: api/models/outfit.md 137 | - Quest: api/models/quest.md 138 | - Spell: api/models/spell.md 139 | - Update: api/models/update.md 140 | - World: api/models/world.md 141 | - Parsers: api/parsers.md 142 | - Schema: api/schema.md 143 | - Utilities: api/utils.md 144 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [tool.setuptools.packages.find] 7 | include = [ 8 | "tibiawikisql*", 9 | ] 10 | 11 | [project] 12 | name = "tibiawikisql" 13 | dynamic = ["version", "dependencies", "optional-dependencies"] 14 | authors = [ 15 | { name = "Allan Galarza", email = "allan.galarza@gmail.com" } 16 | ] 17 | maintainers = [ 18 | { name = "Allan Galarza", email = "allan.galarza@gmail.com" } 19 | ] 20 | license = { text = 'Apache 2.0' } 21 | description = "Python script that generates a SQLite database from TibiaWiki articles" 22 | requires-python = '>=3.10' 23 | readme = "README.md" 24 | classifiers = [ 25 | "Development Status :: 5 - Production/Stable", 26 | "Environment :: Console", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: Apache Software License", 29 | "Natural Language :: English", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Programming Language :: SQL", 38 | "Topic :: Database", 39 | "Topic :: Games/Entertainment :: Role-Playing", 40 | "Topic :: Internet", 41 | "Topic :: Utilities", 42 | ] 43 | 44 | [project.urls] 45 | "Homepage" = "https://github.com/Galarzaa90/tibiawiki-sql" 46 | 47 | [project.scripts] 48 | tibiawikisql = "tibiawikisql.__main__:cli" 49 | 50 | [tool.setuptools.dynamic] 51 | version = { attr = "tibiawikisql.__version__" } 52 | dependencies = { file = ["requirements.txt"] } 53 | 54 | [tool.setuptools.dynamic.optional-dependencies] 55 | docs = { file = ["requirements-docs.txt"] } 56 | testing = { file = ["requirements-testing.txt"] } 57 | server = { file = ["requirements-server.txt"] } 58 | 59 | [tool.ruff] 60 | exclude = [ 61 | "__pycache__", 62 | ".git/", 63 | "build/", 64 | ".idea/", 65 | "venv/", 66 | ".venv/", 67 | "docs/", 68 | "images/", 69 | "logs/", 70 | "tibiawikisql/__main__.py", 71 | "tibiawikisql/server.py", 72 | ] 73 | 74 | 75 | [tool.ruff.lint] 76 | select = [ 77 | "A", # flake8-builtins 78 | "ANN", # flake8-annotations 79 | "ARG", # flake8-unused-arguments 80 | "B", # flake8-bugbear 81 | "BLE", # flake8-blind-except 82 | "C4", # flake8-comprehensions 83 | "COM", # flake8-commas 84 | "D", # pydocstyle 85 | "DOC", # pydoclint 86 | "DTZ", # flake8-datetimez 87 | "E", "W", # pycodestyle 88 | "EM", # flake8-errmsg 89 | "ERA", # eradicate 90 | "F", # Pyflakes 91 | "FA", # flake8-future-annotations 92 | "FLY", # flynt 93 | "FURB", # refurb" 94 | "G", # flake8-logging-format 95 | "ICN", # flake8-import-conventions 96 | "INP", # flake8-no-pep420 97 | "ISC", # flake8-implicit-str-concat 98 | "LOG", # flake8-logging 99 | "N", # pep8-naming 100 | "PERF", # Perflint 101 | "PIE", # flake8-pie 102 | "PL", # Pylint 103 | "Q", # flake8-quotes 104 | "RET", # flake8-return 105 | "RSE", # flake8-raise 106 | "RUF", # Ruff-specific rules 107 | "S", # flake8-bandit 108 | "SIM", # flake8-simplify 109 | "SLF", # flake8-self 110 | "SLOT", # flake8-slots 111 | "T10", # flake8-debugger 112 | "T20", # flake8-print 113 | "TC", # refurb 114 | "TID", # flake8-tidy-imports 115 | "UP", # pyupgrade 116 | "YTT", # flake8-2020 117 | ] 118 | ignore = [ 119 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} 120 | "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable 121 | "PLR0913", # Too many arguments in function definition ({c_args} > {max_args}) 122 | "D105", # Missing docstring in magic method 123 | "PERF203", # `try`-`except` within a loop incurs performance overhead 124 | ] 125 | 126 | [tool.ruff.lint.per-file-ignores] 127 | "tests/**/*" = ["D", "ANN201", "SLF001"] 128 | "**/models/*" = ["D100"] 129 | 130 | [tool.ruff.lint.pycodestyle] 131 | max-line-length = 120 132 | 133 | [tool.ruff.lint.pydocstyle] 134 | convention = "google" 135 | 136 | [tool.ruff.lint.pep8-naming] 137 | extend-ignore-names = ["assert*"] 138 | 139 | 140 | [tool.ruff.lint.flake8-type-checking] 141 | runtime-evaluated-base-classes = ["pydantic.BaseModel"] 142 | 143 | 144 | [tool.coverage.run] 145 | include = ["tibiawikisql/**/*.py"] 146 | 147 | [tool.coverage.report] 148 | exclude_lines = [ 149 | "pragma: no cover", 150 | "if __name__ == .__main__.:", 151 | "def __repr__", 152 | "def __eq__", 153 | "def __len__", 154 | "def __lt__", 155 | "def __gt__", 156 | "def __ne__", 157 | "raise NotImplementedError", 158 | "if TYPE_CHECKING:" 159 | ] 160 | 161 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 162 | "typing.Self".msg = "Use typing_extensions.Self instead." 163 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | griffe-inherited-docstrings 2 | docstring-inheritance 3 | griffe-pydantic 4 | griffe-generics 5 | mkdocs-material 6 | mkdocstrings[python] 7 | ruff 8 | -------------------------------------------------------------------------------- /requirements-server.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | ruff 3 | polyfactory 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | click 3 | colorama 4 | mwparserfromhell 5 | lupa 6 | pydantic 7 | PyPika 8 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=Galarzaa90_tibiawiki-sql 2 | sonar.organization=galarzaa90 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | sonar.projectName=tibiawiki-sql 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | sonar.sources=tibiawikisql/ 10 | sonar.python.coverage.reportPaths=coverage.xml 11 | 12 | # Encoding of the source code. Default is default system encoding 13 | sonar.sourceEncoding=UTF-8 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_PATH = os.path.abspath(os.path.dirname(__file__)) 4 | RESOURCES_PATH = os.path.join(BASE_PATH, "resources/") 5 | 6 | 7 | def load_resource(resource: str): 8 | with open(os.path.join(RESOURCES_PATH, resource)) as f: 9 | return f.read() 10 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Galarzaa90/tibiawiki-sql/ff2c9b86462c694ceb2492f90fd2d75b2eb6023f/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_achievement.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import sqlite3 3 | import unittest 4 | 5 | from polyfactory.factories.pydantic_factory import ModelFactory 6 | 7 | from tibiawikisql.models import Achievement 8 | from tibiawikisql.schema import AchievementTable 9 | 10 | 11 | class AchievementFactory(ModelFactory[Achievement]): 12 | _article_id_counter = 1000 13 | 14 | @classmethod 15 | def article_id(cls) -> int: 16 | cls._article_id_counter += 1 17 | return cls._article_id_counter 18 | 19 | 20 | ACHIEVEMENT_ANNIHILATOR = Achievement( 21 | article_id=2744, 22 | title="Annihilator", 23 | name="Annihilator", 24 | grade=2, 25 | points=5, 26 | description="You've daringly jumped into the infamous Annihilator and survived - taking home fame, glory and your reward.", 27 | spoiler="Obtainable by finishing The Annihilator Quest.", 28 | is_secret=False, 29 | is_premium=True, 30 | achievement_id=57, 31 | version="8.60", 32 | status="active", 33 | timestamp=datetime.datetime.fromisoformat("2021-05-26T20:40:00+00:00"), 34 | ) 35 | 36 | 37 | class TestAchievement(unittest.TestCase): 38 | def setUp(self): 39 | self.conn = sqlite3.connect(":memory:") 40 | self.conn.executescript(AchievementTable.get_create_table_statement()) 41 | 42 | def tearDown(self): 43 | self.conn.close() 44 | 45 | def test_achievement_insert(self): 46 | achievements = AchievementFactory.batch(50) 47 | for achievement in achievements: 48 | achievement.insert(self.conn) 49 | 50 | def test_achievement_get_by_field_no_results(self): 51 | achievement = Achievement.get_one_by_field(self.conn, "achievement_id", 57) 52 | 53 | self.assertIsNone(achievement) 54 | 55 | def test_achievement_get_by_field_with_result(self): 56 | ACHIEVEMENT_ANNIHILATOR.insert(self.conn) 57 | 58 | db_achievement = Achievement.get_one_by_field(self.conn, "achievement_id", 57) 59 | 60 | self.assertIsInstance(db_achievement, Achievement) 61 | self.assertEqual(ACHIEVEMENT_ANNIHILATOR.timestamp, db_achievement.timestamp) 62 | 63 | def test_achievement_get_list_by_field_no_results(self): 64 | db_achievements = Achievement.get_list_by_field(self.conn, "achievement_id", 1) 65 | 66 | self.assertEqual(0, len(db_achievements)) 67 | 68 | def test_achievement_get_list_by_field_with_result(self): 69 | ACHIEVEMENT_ANNIHILATOR.insert(self.conn) 70 | 71 | db_achievements = Achievement.get_list_by_field(self.conn, "achievement_id", 57) 72 | 73 | self.assertNotEqual(0, len(db_achievements)) 74 | -------------------------------------------------------------------------------- /tests/models/test_book.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import unittest 3 | 4 | from polyfactory.factories.pydantic_factory import ModelFactory 5 | 6 | from tibiawikisql.models import Book, Item 7 | from tibiawikisql.schema import BookTable, ItemAttributeTable, ItemSoundTable, ItemStoreOfferTable, ItemTable 8 | 9 | 10 | class BookFactory(ModelFactory[Book]): 11 | _article_id_counter = 1000 12 | 13 | @classmethod 14 | def article_id(cls) -> int: 15 | cls._article_id_counter += 1 16 | return cls._article_id_counter 17 | 18 | class ItemFactory(ModelFactory[Item]): 19 | _article_id_counter = 1000 20 | 21 | @classmethod 22 | def article_id(cls) -> int: 23 | cls._article_id_counter += 1 24 | return cls._article_id_counter 25 | 26 | 27 | class TestBook(unittest.TestCase): 28 | def setUp(self): 29 | self.conn = sqlite3.connect(":memory:") 30 | self.conn.executescript(ItemTable.get_create_table_statement()) 31 | self.conn.executescript(ItemAttributeTable.get_create_table_statement()) 32 | self.conn.executescript(ItemStoreOfferTable.get_create_table_statement()) 33 | self.conn.executescript(ItemSoundTable.get_create_table_statement()) 34 | self.conn.executescript(BookTable.get_create_table_statement()) 35 | 36 | 37 | def tearDown(self): 38 | self.conn.close() 39 | 40 | def test_book_insert(self): 41 | book = BookFactory.build(book_type="Book (Brown)") 42 | item = ItemFactory.build(title="Book (Brown)") 43 | item.insert(self.conn) 44 | 45 | book.insert(self.conn) 46 | inserted_book = book.table.get_one_by_field(self.conn, "article_id", book.article_id) 47 | 48 | self.assertEqual(item.article_id, inserted_book["article_id"]) 49 | -------------------------------------------------------------------------------- /tests/models/test_item.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from tests import load_resource 5 | from tibiawikisql.api import Article 6 | from tibiawikisql.models import Item 7 | from tibiawikisql.parsers import ItemParser 8 | 9 | 10 | class TestItemParser(unittest.TestCase): 11 | 12 | def test_item_parser_from_article_success(self): 13 | article = Article( 14 | article_id=1, 15 | title="Fire Sword", 16 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 17 | content=load_resource("content_item.txt"), 18 | ) 19 | 20 | item = ItemParser.from_article(article) 21 | 22 | self.assertIsInstance(item, Item) 23 | self.assertEqual(len(item.attributes_dict.keys()), len(item.attributes)) 24 | fire_sword_look_text = ("You see a fire sword (Atk:24 physical + 11 fire, Def:20 +1)." 25 | " It can only be wielded properly by players of level 30 or higher." 26 | "\nIt weights 23.00 oz.\n" 27 | "The blade is a magic flame.") 28 | self.assertEqual(fire_sword_look_text, item.look_text) 29 | 30 | def test_item_parser_from_article_no_attrib(self): 31 | article = Article( 32 | article_id=1, 33 | title="Football", 34 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 35 | content=load_resource("content_item_no_attrib.txt"), 36 | ) 37 | 38 | item = ItemParser.from_article(article) 39 | 40 | self.assertIsInstance(item, Item) 41 | 42 | def test_item_parser_from_article_perfect_shot(self): 43 | article = Article( 44 | article_id=1, 45 | title="Gilded Eldritch Wand", 46 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 47 | content=load_resource("content_item_perfect_shot.txt"), 48 | ) 49 | 50 | item = ItemParser.from_article(article) 51 | 52 | self.assertIsInstance(item, Item) 53 | self.assertIn("perfect_shot", item.attributes_dict) 54 | self.assertIn("perfect_shot_range", item.attributes_dict) 55 | 56 | def test_item_parser_from_article_damage_reflection(self): 57 | article = Article( 58 | article_id=1, 59 | title="Spiritthorn Armor", 60 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 61 | content=load_resource("content_item_damage_reflection.txt"), 62 | ) 63 | 64 | item = ItemParser.from_article(article) 65 | 66 | self.assertIsInstance(item, Item) 67 | self.assertIn("damage_reflection", item.attributes_dict) 68 | -------------------------------------------------------------------------------- /tests/models/test_npc.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import sqlite3 3 | import unittest 4 | 5 | from tibiawikisql.models import Npc 6 | from tibiawikisql.schema import ItemTable, NpcBuyingTable, NpcDestinationTable, NpcJobTable, NpcRaceTable, \ 7 | NpcSellingTable, NpcSpellTable, \ 8 | NpcTable, \ 9 | SpellTable 10 | 11 | 12 | class TestNpc(unittest.TestCase): 13 | def setUp(self): 14 | self.conn = sqlite3.connect(":memory:") 15 | self.conn.executescript(NpcTable.get_create_table_statement()) 16 | self.conn.executescript(NpcSpellTable.get_create_table_statement()) 17 | self.conn.executescript(NpcJobTable.get_create_table_statement()) 18 | self.conn.executescript(NpcRaceTable.get_create_table_statement()) 19 | self.conn.executescript(NpcBuyingTable.get_create_table_statement()) 20 | self.conn.executescript(NpcSellingTable.get_create_table_statement()) 21 | self.conn.executescript(ItemTable.get_create_table_statement()) 22 | self.conn.executescript(NpcDestinationTable.get_create_table_statement()) 23 | 24 | def test_npc_with_spells(self): 25 | # Arrange 26 | NpcTable.insert( 27 | self.conn, 28 | article_id=1, 29 | title="Azalea", 30 | name="Azalea", 31 | gender="Female", 32 | city="Rathleton", 33 | subarea="Oramond", 34 | location="The temple in Upper Rathleton", 35 | version="10.50", 36 | x=33593, 37 | y=31899, 38 | z=6, 39 | status="active", 40 | timestamp=datetime.datetime.fromisoformat("2024-07-29T16:37:09+00:00"), 41 | ) 42 | NpcJobTable.insert(self.conn, npc_id=1, name="Druid") 43 | NpcJobTable.insert(self.conn, npc_id=1, name="Druid Guild Leader") 44 | NpcJobTable.insert(self.conn, npc_id=1, name="Cleric") 45 | NpcJobTable.insert(self.conn, npc_id=1, name="Healer") 46 | NpcJobTable.insert(self.conn, npc_id=1, name="Florist") 47 | NpcRaceTable.insert(self.conn, npc_id=1, name="Human") 48 | self.conn.executescript(SpellTable.get_create_table_statement()) 49 | SpellTable.insert( 50 | self.conn, 51 | article_id=1, 52 | title="Food (Spell)", 53 | name="Food (Spell)", 54 | words="exevo pan", 55 | effect="Creates various kinds of food.", 56 | spell_type="Instant", 57 | group_spell="Support", 58 | level=14, 59 | mana=120, 60 | soul=1, 61 | price=300, 62 | is_premium=False, 63 | is_promotion=False, 64 | status="active", 65 | timestamp=datetime.datetime.fromisoformat("2024-07-29T16:37:09+00:00"), 66 | ) 67 | NpcSpellTable.insert(self.conn, npc_id=1, spell_id=1, druid=True) 68 | 69 | npc = Npc.get_one_by_field(self.conn, "name", "Azalea") 70 | 71 | self.assertIsInstance(npc, Npc) 72 | self.assertEqual(5, len(npc.jobs)) 73 | self.assertEqual("Human", npc.race) 74 | self.assertEqual(1, len(npc.teaches)) 75 | self.assertEqual("Food (Spell)", npc.teaches[0].spell_title) 76 | -------------------------------------------------------------------------------- /tests/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Galarzaa90/tibiawiki-sql/ff2c9b86462c694ceb2492f90fd2d75b2eb6023f/tests/parsers/__init__.py -------------------------------------------------------------------------------- /tests/parsers/test_achievement.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from tests import load_resource 5 | from tibiawikisql.api import Article 6 | from tibiawikisql.models import Achievement 7 | from tibiawikisql.parsers import AchievementParser 8 | 9 | 10 | class TestAchievementParser(unittest.TestCase): 11 | 12 | def test_achievement_parser_from_article(self): 13 | article = Article( 14 | article_id=1, 15 | title="Demonic Barkeeper", 16 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 17 | content=load_resource("content_achievement.txt"), 18 | ) 19 | 20 | achievement = AchievementParser.from_article(article) 21 | 22 | self.assertIsInstance(achievement, Achievement) 23 | -------------------------------------------------------------------------------- /tests/parsers/test_creature.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tibiawikisql.parsers.creature import parse_abilities, parse_maximum_damage 4 | 5 | 6 | class TestCreatureParser(unittest.TestCase): 7 | 8 | def test_parse_abilities_with_template(self): 9 | ability_content = ("{{Ability List|{{Melee|0-500}}|{{Ability|Great Fireball|150-250|fire|scene=" 10 | "{{Scene|spell=5sqmballtarget|effect=Fireball Effect|caster=Demon|look_direction=" 11 | "|effect_on_target=Fireball Effect|missile=Fire Missile|missile_direction=south-east" 12 | "|missile_distance=5/5|edge_size=32}}}}|{{Ability|[[Great Energy Beam]]|300-480|lifedrain" 13 | "|scene={{Scene|spell=8sqmbeam|effect=Blue Electricity Effect|caster=Demon|" 14 | "look_direction=east}}}}|{{Ability|Close-range Energy Strike|210-300|energy}}|" 15 | "{{Ability|Mana Drain|30-120|manadrain}}|{{Healing|range=80-250}}|{{Ability|" 16 | "Shoots [[Fire Field]]s||fire}}|{{Ability|Distance Paralyze||paralyze}}|" 17 | "{{Summon|Fire Elemental|1}}}}") 18 | 19 | result = parse_abilities(ability_content) 20 | 21 | self.assertEqual(9, len(result)) 22 | 23 | 24 | def test_parse_abilities_no_template(self): 25 | ability_content = ("[[Melee]] (0-220), [[Physical Damage|Smoke Strike]] (0-200; does [[Physical Damage]]), " 26 | "[[Life Drain|Smoke Wave]] (0-380; does [[Life Drain]]), [[Paralyze|Ice Wave]] (very strong " 27 | "[[Paralyze]]), [[Avalanche (rune)|Avalanche]] (0-240) or (strong [[Paralyze]]), " 28 | "[[Berserk|Ice Berserk]] (0-120), [[Paralyze|Smoke Berserk]] (strong [[Paralyze]]), " 29 | "[[Self-Healing]] (around 200 [[Hitpoints]]), [[Haste]].") 30 | 31 | result = parse_abilities(ability_content) 32 | 33 | self.assertEqual(1, len(result)) 34 | self.assertEqual("no_template", result[0]["element"]) 35 | 36 | def test_parse_parse_abilities_mixed(self): 37 | ability_content = "{{Ability List|{{Melee|0-500}}|Plain text}}" 38 | 39 | result = parse_abilities(ability_content) 40 | 41 | self.assertEqual(2, len(result)) 42 | self.assertEqual("Plain text", result[-1]["name"]) 43 | self.assertEqual("plain_text", result[-1]["element"]) 44 | 45 | def test_parse_parse_abilities_empty(self): 46 | ability_content = "" 47 | 48 | result = parse_abilities(ability_content) 49 | 50 | self.assertEqual(0, len(result)) 51 | 52 | def test_parse_max_damage_template(self): 53 | max_damage_content = "{{Max Damage|physical=500|fire=250|lifedrain=480|energy=300|manadrain=120|summons=250}}" 54 | 55 | result = parse_maximum_damage(max_damage_content) 56 | 57 | self.assertIsInstance(result, dict) 58 | self.assertEqual(500, result["physical"]) 59 | self.assertEqual(250, result["fire"]) 60 | self.assertEqual(480, result["lifedrain"]) 61 | self.assertEqual(300, result["energy"]) 62 | self.assertEqual(120, result["manadrain"]) 63 | self.assertEqual(250, result["summons"]) 64 | self.assertEqual(1530, result["total"]) 65 | 66 | def test_parse_max_damage_no_template(self): 67 | max_damage_content = "1500 (2000 with UE)" 68 | 69 | result = parse_maximum_damage(max_damage_content) 70 | 71 | self.assertIsInstance(result, dict) 72 | self.assertEqual(2000, result["total"]) 73 | 74 | def test_parse_max_damage_no_template_no_number(self): 75 | max_damage_content = "Unknown." 76 | 77 | result = parse_maximum_damage(max_damage_content) 78 | 79 | self.assertEqual({}, result) 80 | 81 | def test_parse_max_damage_empty(self): 82 | max_damage_content = "" 83 | 84 | result = parse_maximum_damage(max_damage_content) 85 | 86 | self.assertEqual({}, result) 87 | -------------------------------------------------------------------------------- /tests/parsers/test_npc.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from tests import load_resource 5 | from tibiawikisql.api import Article 6 | from tibiawikisql.models import Npc 7 | from tibiawikisql.parsers import NpcParser 8 | 9 | 10 | class TestNpcParser(unittest.TestCase): 11 | def test_npc_parser_from_article_success(self): 12 | article = Article( 13 | article_id=1, 14 | title="Yaman", 15 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 16 | content=load_resource("content_npc.txt"), 17 | ) 18 | 19 | npc = NpcParser.from_article(article) 20 | 21 | self.assertIsInstance(npc, Npc) 22 | self.assertEqual("Yaman", npc.title) 23 | self.assertEqual("Djinn", npc.race) 24 | self.assertEqual(1, len(npc.races)) 25 | 26 | def test_npc_parser_from_article_travel_destinations(self): 27 | article = Article( 28 | article_id=1, 29 | title="Captain Bluebear", 30 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 31 | content=load_resource("content_npc_travel.txt"), 32 | ) 33 | 34 | npc = NpcParser.from_article(article) 35 | 36 | self.assertIsInstance(npc, Npc) 37 | self.assertEqual("Captain Bluebear", npc.title) 38 | self.assertEqual("Human", npc.race) 39 | self.assertEqual(10, len(npc.destinations)) 40 | -------------------------------------------------------------------------------- /tests/parsers/test_update.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from tests import load_resource 5 | from tibiawikisql.api import Article 6 | from tibiawikisql.models import Update 7 | from tibiawikisql.parsers import UpdateParser 8 | 9 | 10 | class TestUpdateParser(unittest.TestCase): 11 | def test_update_parser_success(self): 12 | article = Article( 13 | article_id=1, 14 | title="Updates/8.00", 15 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 16 | content=load_resource("content_update.txt"), 17 | ) 18 | 19 | update = UpdateParser.from_article(article) 20 | 21 | self.assertIsInstance(update, Update) 22 | -------------------------------------------------------------------------------- /tests/resources/content_achievement.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Achievement|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | grade = 1 3 | | name = Demonic Barkeeper 4 | | description = Thick, red - shaken, not stirred - and with a straw in it: that's the way you prefer your demon blood. Served with an onion ring, the subtle metallic aftertaste is almost not noticeable. Beneficial effects on health or mana are welcome. 5 | | spoiler = Obtainable by shaking 250 [[Flask of Demonic Blood]]. 6 | | premium = no 7 | | points = 3 8 | | secret = no 9 | | implemented = 8.62 10 | | achievementid = 111 11 | | relatedpages = [[Flask of Demonic Blood]] 12 | }} 13 | -------------------------------------------------------------------------------- /tests/resources/content_book.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Book|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | booktype = Book (Black) 3 | | title = Imperial Scripts 4 | | pagename = Imperial Scripts (Book) 5 | | location = [[Isle of the Kings]] 6 | | blurb = A committee report about creating a new committee to report to this committee. 7 | | author = [[Sir Acrothet Simfus]] 8 | | returnpage = Isle of the Kings Library 9 | | relatedpages = [[Thais]] 10 | | text = Imperial Scripts

Transcribed by the Royal
scrivener, Sir Acrothet Simfus

Committee Meeting XVII

Insofar as we have yet to
ascertain the needs of our
citizenry in comparison to those
of our benevolent leader and
other high ranking officials,

It has been deemed forthwith
that a committee should be
formed to handle such concerns,
and will hereafter report its
findings to this committee once
every cycle.

Of matter pertaining to politics,
it has thusly been found that
despite egregious wrongs
committed by here-to-for
unknown assailants, both afield
and afoot, we should, in due
time, attempt to develop a plan
which results in less of our
deaths, and more of theirs.

A committee will be formed
and henceforth be titled
"The diplomatic committee"
and will report to us twice
every cycle, and include their
findings, which shall hope-
fully include less corpses on our
behalf

etc. etc.
~ 11 | | implemented = 12 | }} 13 | -------------------------------------------------------------------------------- /tests/resources/content_charm.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Charm|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Curse (Charm) 3 | | actualname = Curse 4 | | type = Offensive 5 | | cost = 900 6 | | effect = Triggers on a creature with a certain chance and deals 5% of its initial hit points as [[Death Damage]] once. 7 | | implemented = 11.50.6055 8 | | notes = The chance of this charm being triggered is 10% per attack done. Since the elemental damage charms are applied on top of the creature's elemental resistances, the Curse charm is recommended for [[Death Damage/Weak|creatures that are weak to Death Damage]]. As with the other elemental damage charms, it's most efficient when used on creatures that have a high amount of hitpoints. 9 | 10 | Elemental damage Charms are the best ones available for [[Druid]]s and [[Sorcerer]]s and should be the first one unlocked by these vocations. They are also very good for [[Paladin]]s depending on the hunt and creatures. Even though [[Knight]]s will usually give preference to Defensive Charms, at some point the Knight may wish to switch to offensive charms to maximize its damage output. 11 | 12 | The choice between the 6 elemental damage charms depends mostly on the creatures the player has unlocked in the bestiary and the creatures usually hunted. Since not many creatures are weak to [[Death Damage]] and those who are usually have some other elemental weakness, as well as the fact that Curse is a rather expensive Charm, it's usually not preferred by players. 13 | | history = 14 | | status = active 15 | }} 16 | -------------------------------------------------------------------------------- /tests/resources/content_house.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Building|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Crystal Glance 3 | | implemented = 8.00 4 | | type = Guildhall 5 | | location = north part of [[Svargrond]] at the stairs to the magic carpet 6 | | posx = 125.246 7 | | posy = 121.142 8 | | posz = 7 9 | | street = Arena Walk 10 | | houseid = 55302 11 | | size = 315 12 | | beds = 24 13 | | rent = 1000000 14 | | city = Svargrond 15 | | openwindows = 17 16 | | floors = 5 17 | | rooms = 15 18 | | furnishings = [[Beer Cask]], 2x5 [[Big Table (Object)|Big Table]], 3 [[Chalk Board]]s, 3 [[Dustbin]]s, [[Locker (Object)|Locker]], 4x11 [[Red Carpet]], 10sqm [[Table]], 5 [[Torch Bearer (Metal)|Torch Bearer]]s, 15 [[Wall Lamp]]s, [[Wine Cask]] 19 | | notes = Guild depot is {{Mapper Coords|125.244|121.149|8|8|text=here}}. 20 | | history = Before {{OfficialNewsArchive|4984|April 15, 2019}}, this house's rent was 19625 gold per month. 21 | | image = 22 | Crystal Glance (-1).png|Basement floor 23 | Crystal Glance (0).png|Ground floor 24 | Crystal Glance (+1).png|First floor 25 | Crystal Glance (+2).png|Second floor 26 | Crystal Glance (+3).png|Third floor 27 | 28 | }} 29 | -------------------------------------------------------------------------------- /tests/resources/content_imbuement.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Imbuement|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Powerful Strike 3 | | actualname = Powerful Strike 4 | | prefix = Powerful 5 | | type = Strike 6 | | category = Critical Hit 7 | | effect = {{Imbuement Effect/Strike|50%|10%}} 8 | | slots = swords, clubs, axes, bows, crossbows 9 | | astralsources = Protective Charm: 20, Sabretooth: 25, Vexclaw Talon: 5 10 | | implemented = 11.02 11 | | notes = This imbuement may also be applied to [[Rod of Destruction]], [[Wand of Destruction]], [[Falcon Rod]], [[Lion Wand]] and [[Falcon Wand]]. 12 | {{JSpoiler|The ability to apply this imbuement can be obtained by finishing the [[Heart of Destruction Quest]] or by finishing the [[Forgotten Knowledge Quest]].}} 13 | }} 14 | -------------------------------------------------------------------------------- /tests/resources/content_item.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Fire Sword 3 | | article = a 4 | | actualname = fire sword 5 | | plural = fire swords 6 | | itemid = 3280 7 | | objectclass = Weapons 8 | | primarytype = Sword Weapons 9 | | flavortext = The blade is a magic flame. 10 | | sounds = {{Sound List|}} 11 | | implemented = 3.0 12 | | lightradius = 3 13 | | lightcolor = 199 14 | | immobile = no 15 | | walkable = yes 16 | | pickupable = yes 17 | | usable = yes 18 | | levelrequired = 30 19 | | hands = One 20 | | weapontype = Sword 21 | | attack = 24 22 | | fire_attack = 11 23 | | defense = 20 24 | | defensemod = +1 25 | | upgradeclass = 2 26 | | enchantable = no 27 | | weight = 23.00 28 | | marketable = yes 29 | | droppedby = {{Dropped By|Demodras|Dragon Lord|Enusat the Onyx Wing|Guardian of Tales|Hellfire Fighter|Hellhound|Hero|Kalyassa|Kerberos|Lava Golem|Magma Crawler|Massive Fire Elemental|Mawhawk|Memory of a Hero|Neferi the Spy|Pirat Mate|Renegade Knight|Retching Horror|Sabretooth (Creature)|Sulphur Spouter|The Baron from Below|The Count of the Core|The Duke of the Depths|Thornfire Wolf|Ushuriel|Vile Grandmaster|Vulcongra|Weeper}} 30 | | value = 4,000 - 8,000 31 | | npcvalue = 4000 32 | | npcprice = 0 33 | | buyfrom = -- 34 | | sellto = Baltim: 1000, Brengus: 1000, Cedrik: 1000, Esrik: 1000, Flint: 1000, Gamel: 1000, H.L.: 335, Habdel: 1000, Hardek: 1000, Memech: 1000, Morpel: 1000, Nah'Bob, Robert: 1000, Rock In A Hard Place: 1000, Romella: 1000, Rowenna: 1000, Sam: 1000, Shanar: 1000, Turvy: 1000, Ulrik: 1000, Uzgod: 1000, Willard: 1000 35 | | notes = Mainly used from [[level]] 30 to 35 by [[Knight]]s. This sword is also used by higher level Knights to fight creatures immune to [[Physical Damage]], like [[Ghost]]s. Provides a small amount of red light. The graphic for the Fire Sword is an edit of the old [[Rapier]] sprite. 36 | {{JSpoiler| 37 | * Can be obtained in the [[Orc Fortress Quest]]. 38 | * 3 Fire Swords can be traded for a [[Magic Sulphur]] with [[Haroun]] or [[Yaman]]. 39 | }} 40 | | history = Before [[Updates/7.2|Update 7.2]] the Fire Sword was the best easy-obtainable one handed sword on all [[Game World]]s but [[Antica]]. It was possible to inflict two rounds of damage at a time because of its weight. 41 | 42 | == Earlier ways of obtaining == 43 | [[Dragon]]s used to drop these [[sword]]s. Long ago they were also obtainable from a [[quest]] [[A Sweaty Cyclops]] was part of. 44 | 45 | == Sprite Change == 46 | With the [[Updates/9.5|2012 Spring Patch]] the sprite of the Fire Sword changed from [[File:Fire Sword (Old).gif]] to [[File:Fire Sword.gif]]. 47 | }} 48 | -------------------------------------------------------------------------------- /tests/resources/content_item_damage_reflection.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Spiritthorn Armor 3 | | article = a 4 | | actualname = spiritthorn armor 5 | | plural = 6 | | itemid = 39147 7 | | objectclass = Body Equipment 8 | | primarytype = Armors 9 | | implemented = 12.90.12182 10 | | immobile = no 11 | | walkable = yes 12 | | pickupable = yes 13 | | levelrequired = 400 14 | | vocrequired = knights 15 | | imbueslots = 2 16 | | upgradeclass = 4 17 | | attrib = sword fighting +4, axe fighting +4, club fighting +4, 19 damage reflection 18 | | armor = 20 19 | | resist = physical +13% 20 | | weight = 160.00 21 | | stackable = no 22 | | marketable = yes 23 | | droppedby = {{Dropped By|Magma Bubble|Plunder Patriarch}} 24 | | value = Negotiable gp 25 | | npcvalue = 0 26 | | npcprice = 0 27 | | buyfrom = -- 28 | | sellto = -- 29 | | notes = Part of the [[Spiritthorn Set]]. It is a possible reward of the [[Primal Ordeal Quest]], which guarantees one random item from the [[Primal Set]] through the [[Hazard System]]. 30 | 31 | It has [[Damage Reflection]]. 32 | }} 33 | -------------------------------------------------------------------------------- /tests/resources/content_item_no_attrib.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Football 3 | | article = a 4 | | actualname = football 5 | | plural = footballs 6 | | itemid = 2990, 9104 7 | | objectclass = Other Items 8 | | primarytype = Game Tokens 9 | | implemented = 6.0 10 | | destructible = no 11 | | immobile = no 12 | | walkable = yes 13 | | pickupable = no 14 | | value = 0 15 | | npcvalue = 0 16 | | npcprice = 111 17 | | npcvaluerook = 0 18 | | npcpricerook = 111 19 | | buyfrom = Beatrice, Bertha, Gorn, Lee'Delle: 111, Perod, Sarina, Shiantis, Zora 20 | | sellto = -- 21 | | notes = It is used to play [[Football (Game)|football]] (Soccer in [[USA]] and Australia). Also, it seems to be worshiped by the [[Swamp Troll]]s near [[Venore]]. 22 | }} 23 | -------------------------------------------------------------------------------- /tests/resources/content_item_perfect_shot.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Gilded Eldritch Wand 3 | | article = a 4 | | actualname = gilded eldritch wand 5 | | plural = 6 | | itemid = 36669 7 | | objectclass = Weapons 8 | | primarytype = Wands 9 | | flavortext = Refined by the legendary artisan Gnomaurum. 10 | | implemented = 12.70.10953 11 | | lightradius = 3 12 | | lightcolor = 210 13 | | immobile = no 14 | | walkable = yes 15 | | pickupable = yes 16 | | levelrequired = 250 17 | | vocrequired = sorcerers 18 | | imbueslots = 2 19 | | upgradeclass = 4 20 | | range = 4 21 | | crithit_ch = 22 | | critextra_dmg = 23 | | manacost = 22 24 | | damagetype = Fire 25 | | damagerange = 85-105 26 | | attrib = magic level +2, fire magic level +1, perfect shot +65 at range 4 27 | | resist = energy +4% 28 | | weight = 37.00 29 | | marketable = yes 30 | | droppedby = {{Dropped By|The Brainstealer}} 31 | | value = Negotiable 32 | | npcvalue = 0 33 | | npcprice = 0 34 | | buyfrom = -- 35 | | sellto = -- 36 | | notes = It works just like a standard [[Eldritch Wand]]. 37 | }} 38 | -------------------------------------------------------------------------------- /tests/resources/content_item_resist.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Dream Shroud 3 | | article = a 4 | | actualname = dream shroud 5 | | itemid = 29423 6 | | marketable = yes 7 | | implemented = 12.00.7695 8 | | objectclass = Body Equipment 9 | | primarytype = Armors 10 | | levelrequired = 180 11 | | vocrequired = sorcerers and druids 12 | | imbueslots = 1 13 | | armor = 12 14 | | resist = energy +10% 15 | | attrib = magic level +3 16 | | weight = 25.00 17 | | droppedby = {{Dropped By|Alptramun}} 18 | | value = Negotiable 19 | | npcvalue = 0 20 | | npcprice = 0 21 | | buyfrom = -- 22 | | sellto = -- 23 | | pickupable = yes 24 | }} 25 | -------------------------------------------------------------------------------- /tests/resources/content_item_sounds.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Mini NabBot 3 | | article = a 4 | | actualname = mini NabBot 5 | | itemid = 32759, 32760 6 | | flavortext = It is the first NabBot prototype of service. Awarded by NabBot.xyz. 7 | | implemented = 12.30.9287 8 | | objectclass = Household Items 9 | | primarytype = Fansite Items 10 | | weight = 60.00 11 | | sounds = {{Sound List|I am the first mini NabBot prototype, at least the first one that works properly.|Too many questions, too many answers.|Congrats ''Player'' on getting that level! Maybe you can solo rats now?|That's what you get ''Player'', for messing with that monster!|''Player'' got a level? Here, have a cookie!}} 12 | | droppedby = {{Dropped By|}} 13 | | value = Negotiable 14 | | npcvalue = 0 15 | | npcprice = 0 16 | | buyfrom = -- 17 | | sellto = -- 18 | | notes = It's [[NabBot]]'s fansite item.
If you ''use'' it, it will turn into a [[Mini NabBot (Activated)]] and play a sound: [[File:Mini NabBot (Activating).gif]] [[File:Mini NabBot (Activated).gif]]
19 | If used again, it will deactivate again: [[File:Mini NabBot (Deactivating).gif]] [[File:Mini NabBot.gif]] 20 | 21 | The following players have '''received''' the item: 22 | 23 | For designing the item: 24 | * {{Character|Juh Mong}}, creator of the item, winner of the fansite's item contest. 25 | 26 | For contributing to the fansite: 27 | * {{Character|Galarzaa Fidera}}, fansite administrator and developer. 28 | * {{Character|Nezune}}, co-creator. 29 | * {{Character|Tschas}}, development contributor. 30 | * {{Character|Sayuri Nowan}}, fansite helper. 31 | * {{Character|Callie Aena}}, graphic designer. 32 | * {{Character|Trollefar}}, Administrator of [[TibiaData]], which provided services to [[NabBot]]. 33 | * {{Character|Dev Zimm}}, collaboration with the fansite. 34 | 35 | For winning fansite contests: 36 | * {{Character|Hojkk}} - winner of the Draw NabBot Contest. 37 | * {{Character|Fire Draggon}} - winner of the Tibia Movie Awards 2020, Vocation Guide category. 38 | * {{Character|Griggi}} - winner of the Craft NabBot Contest. 39 | | fansite = [[NabBot]] 40 | | pickupable = yes 41 | }} 42 | -------------------------------------------------------------------------------- /tests/resources/content_item_store.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Object|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Health Potion 3 | | article = a 4 | | actualname = health potion 5 | | plural = health potions 6 | | itemid = 266 7 | | marketable = yes 8 | | usable = yes 9 | | implemented = 8.10 10 | | objectclass = Plants, Animal Products, Food and Drink 11 | | primarytype = Liquids 12 | | weight = 2.70 13 | | stackable = yes 14 | | consumable = yes 15 | | droppedby = {{Dropped By|Barbarian Bloodwalker|Barbarian Headsplitter|Barbarian Skullhunter|Bibby Bloodbath|Bonebeast|Cyclops|Dark Apprentice|Dark Magician|Dawnfly|Death Priest|Demon Skeleton|Dragon Hatchling|Dreadbeast|Dwarf Guard|Dworc Voodoomaster|Elf Arcanist|Elf Overseer|Emerald Damselfly|Frost Dragon Hatchling|Frost Giant|Ghoulish Hyaena|Golden Servant|Golden Servant Replica|Grave Guard|Haunted Treeling|Insectoid Scout|Insectoid Worker|Iron Servant|Iron Servant Replica|Juvenile Cyclops|Kongra|Lizard Sentinel|Lizard Templar|Mad Scientist|Minotaur Guard|Mutated Rat|Oodok Witchmaster|Orc Leader|Orc Warlord|Renegade Orc|Salamander|Terror Bird|The Snapper|Thornback Tortoise|Troll-Trained Salamander|Undead Cavebear|Undead Gladiator|Valkyrie|Wailing Widow}} 16 | | value = < 50 17 | | storevalue = {{Store Trades|{{Store Product|6|amount=125}}|{{Store Product|11|amount=300}}}} 18 | | npcvalue = 0 19 | | npcprice = 50 20 | | buyfrom = Agostina: 75, Alaistar, Alberto: 75, Asima, Asnarus, Brom: 75, Brutus: 75, Chartan, Chuckles, Digger, Dino: 75, Emilio: 75, Fabiana: 75, Faloriel, Frederik, Ghorza, Gnomegica, Hamish, Lorenzo: 75, Maun, Mehkesh, Nelly, Onfroi, Rabaz, Rachel, Raffael, Rock In A Hard Place, Romir, Roughington: 75, Sandra, Shadowpunch: 75, Shiriel, Siflind, Sigurd, Talila, Tandros, Tarun, Topsy, Valindara, Victor: 75, Xodet 21 | | sellto = -- 22 | | notes = Heals the target for 125 to 175 [[hitpoint]]s, an average of 150. The leftover [[Empty Potion Flask (Small)|empty potion flask]] can be sold back to the potion vendor for 5 gp. For comparisons of healing efficiency, see the [[Money Spent per Hit Point|GP/HP]] and the [[Capacity Taken per Hit Point|Cap/HP efficiency tables]]. Notice that you can only use this potion on yourself or another player, it doesn't work for summoned or wild creatures. 23 | {{JSpoiler|4 of them can be obtained from [[Gnomish Supply Package]]s.}} 24 | | pickupable = yes 25 | }} 26 | -------------------------------------------------------------------------------- /tests/resources/content_key.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Key|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | number = 3940 3 | | aka = Fibula Key 4 | | primarytype = Wooden 5 | | secondarytype = Copper 6 | | location = [[Fibula]] 7 | | value = 0 - 800 8 | | npcvalue = 0 9 | | npcprice = 800 10 | | buyfrom = Dermot:2000, Simon the Beggar 11 | | sellto = -- 12 | | origin = 13 | | shortnotes = Used to enter/exit the [[Fibula Dungeon]]. Comes in [[Wooden Key|wooden]] and [[Copper Key|copper]] variants. 14 | | longnotes = Used to enter/exit the [[Fibula Dungeon]]. When buying the key from [[Simon]] he will first ask you for some money, continue giving him money, and when you have given him 800 [[gp]] he will hand you the key. There is also a copper key with the same number (3940) that works too. 15 | }} 16 | -------------------------------------------------------------------------------- /tests/resources/content_loot_statistics.txt: -------------------------------------------------------------------------------- 1 | __NOWYSIWYG__ 2 | 3 | {{Loot2 4 | |version=10.37 5 | |kills=36488 6 | |name=Demon 7 | |Empty, times:99 8 | |Gold Coin, times:36302, amount:1-200, total:1829431 9 | |Platinum Coin, times:36300, amount:1-8, total:156453 10 | |Great Mana Potion, times:9150, amount:1-3, total:18360 11 | |Great Spirit Potion, times:9063, amount:1-3, total:18072 12 | |Demon Horn, times:7359, amount:1, total:7359 13 | |Ultimate Health Potion, times:7209, amount:1-3, total:14420 14 | |Fire Mushroom, times:7203, amount:1-6, total:25278 15 | |Demonic Essence, times:7186 16 | |Assassin Star, times:5646, amount:1-10, total:31060 17 | |Small Topaz, times:3700, amount:1-5, total:10998 18 | |Small Ruby, times:3651, amount:1-5, total:10757 19 | |Small Emerald, times:3596, amount:1-5, total:10751 20 | |Small Amethyst, times:3582, amount:1-5, total:10862 21 | |Fire Axe, times:1467, amount:1, total:1467 22 | |Talon, times:1223, amount:1, total:1223 23 | |Red Gem, times:1093, amount:1, total:1093 24 | |Orb, times:1061, amount:1, total:1061 25 | |Ring of Healing, times:967, amount:1, total:967 26 | |Might Ring, times:909, amount:1, total:909 27 | |Stealth Ring, times:882, amount:1, total:882 28 | |Giant Sword, times:714, amount:1, total:714 29 | |Ice Rapier, times:695, amount:1, total:695 30 | |Golden Sickle, times:498, amount:1, total:498 31 | |Purple Tome, times:453, amount:1, total:453 32 | |Devil Helmet, times:447, amount:1, total:447 33 | |Gold Ring, times:375, amount:1, total:375 34 | |Demon Shield, times:279, amount:1, total:279 35 | |Platinum Amulet, times:256, amount:1, total:256 36 | |Mastermind Shield, times:171, amount:1, total:171 37 | |Golden Legs, times:148 38 | |Demon Trophy, times:32, amount:1, total:32 39 | |Magic Plate Armor, times:32, amount:1, total:32 40 | |Demonrage Sword, times:20, amount:1, total:20 41 | }} 42 | 43 | 44 | {{Loot2 45 | |version=8.6 46 | |kills=24276 47 | |name=Demon 48 | |Empty, times:305 49 | |Gold Coin, times:23577, amount:1-199, total:2078775 50 | |Platinum Coin, times:14435, amount:1, total:14435 51 | |Fire Mushroom, times:5042, amount:1-6, total:16884 52 | |Ultimate Health Potion, times:4755, amount:1-3, total:9539 53 | |Double Axe, times:3994, amount:1, total:3994 54 | |Great Mana Potion, times:3603, amount:1-3, total:7284 55 | |Small Emerald, times:2339, amount:1, total:2339 56 | |Assassin Star, times:1250, amount:1-5, total:3796 57 | |Fire Axe, times:933, amount:1, total:933 58 | |Talon, times:865, amount:1, total:865 59 | |Orb, times:688, amount:1, total:688 60 | |Giant Sword, times:507, amount:1, total:507 61 | |Golden Sickle, times:362, amount:1, total:362 62 | |Stealth Ring, times:336, amount:1, total:336 63 | |Purple Tome, times:312, amount:1, total:312 64 | |Devil Helmet, times:294, amount:1, total:294 65 | |Gold Ring, times:257, amount:1, total:257 66 | |Platinum Amulet, times:180, amount:1, total:180 67 | |Ice Rapier, times:169, amount:1, total:169 68 | |Demon Shield, times:163, amount:1, total:163 69 | |Demon Horn, times:124, amount:1, total:124 70 | |Ring of Healing, times:116, amount:1, total:116 71 | |Golden Legs, times:103 72 | |Mastermind Shield, times:100, amount:1, total:100 73 | |Might Ring, times:38, amount:1, total:38 74 | |Demon Trophy, times:25, amount:1, total:25 75 | |Demonrage Sword, times:20, amount:1, total:20 76 | |Magic Plate Armor, times:18, amount:1, total:18 77 | }} 78 | 79 | {{Loot2 80 | |version=8.54 81 | |kills=4 82 | |name=Demon 83 | |Gold Coin, times:4, amount:71-150, total:432 84 | |Platinum Coin, times:3, amount:1, total:3 85 | |Ultimate Health Potion, times:2, amount:1, total:2 86 | |Double Axe, times:1, amount:1, total:1 87 | |Fire Mushroom, times:1, amount:6, total:6 88 | |Orb, times:1, amount:1, total:1 89 | }} 90 | 91 | {{Loot 92 | |version=8.54 93 | |kills=745 94 | |name=Demon 95 | |Empty, 25 96 | |[[Gold Coin]], 60623 97 | |[[Platinum Coin]], 480 98 | |[[Fire Mushroom]], 445 99 | |[[Ultimate Health Potion]], 273 100 | |[[Great Mana Potion]], 183 101 | |[[Double Axe]], 140 102 | |[[Assassin Star]], 72 103 | |[[Small Emerald]], 71 104 | |[[Fire Axe]], 22 105 | |[[Talon]], 21 106 | |[[Small Stone]], 20 107 | |[[Giant Sword]], 16 108 | |[[Gold Ring]], 16 109 | |[[Stealth Ring]], 12 110 | |[[Orb]], 11 111 | |[[Purple Tome]], 10 112 | |[[Platinum Amulet]], 9 113 | |[[Golden Legs]], 8 114 | |[[Golden Sickle]], 8 115 | |[[Demon Shield]], 7 116 | |[[Ice Rapier]], 6 117 | |[[Mastermind Shield]], 6 118 | |[[Devil Helmet]], 5 119 | |[[Demon Horn]], 3 120 | |[[Might Ring]], 2 121 | |[[Ring of Healing]], 2 122 | |[[Magic Plate Armor]], 1 123 | }} 124 |
Average gold: 81.37 125 | 126 | {{Loot 127 | |version=8.5 128 | |kills=665 129 | |name=Demon 130 | |[[Assassin Star]], 103 131 | |[[Demon Horn]], 3 132 | |[[Demon Shield]], 5 133 | |[[Devil Helmet]], 6 134 | |[[Double Axe]], 139 135 | |[[Fire Axe]], 40 136 | |[[Fire Mushroom]], 510 137 | |[[Giant Sword]], 11 138 | |[[Gold Coin]], 57124 139 | |[[Gold Ring]], 6 140 | |[[Golden Sickle]], 11 141 | |[[Great Mana Potion]], 199 142 | |[[Ice Rapier]], 7 143 | |[[Magic Plate Armor]], 1 144 | |[[Mastermind Shield]], 2 145 | |[[Orb]], 20 146 | |[[Platinum Coin]], 474 147 | |[[Purple Tome]], 7 148 | |[[Ring of Healing]], 2 149 | |[[Small Emerald]], 67 150 | |[[Stealth Ring]], 6 151 | |[[Talon]], 22 152 | |[[Ultimate Health Potion]], 249 153 | |[[Platinum Amulet]], 5 154 | |[[Might Ring]], 2 155 | |[[Demonrage Sword]], 1 156 | }} 157 |
Average gold: 85.87 158 |
159 | -------------------------------------------------------------------------------- /tests/resources/content_mount.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Mount|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Doombringer 3 | | speed = 10 4 | | mount_id = 644 5 | | taming_method = Buying it on Tibia.com or via the [[Store]]. 6 | | bought = yes 7 | | price = 780 8 | | implemented = 10.56 9 | | notes = It can be bought since [[Updates/10.56|Update 10.56]]. 10 | }} 11 | -------------------------------------------------------------------------------- /tests/resources/content_npc.txt: -------------------------------------------------------------------------------- 1 | {{Infobox NPC|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Yaman 3 | | job = Shopkeeper 4 | | location = [[Mal'ouquah]] (Green Djinn Fortress). 5 | | city = Ankrahmun 6 | | posx = 129.21 7 | | posy = 127.108 8 | | posz = 2 9 | | gender = Male 10 | | race = Djinn 11 | | buysell = yes 12 | | implemented = 7.4 13 | | notes = Yaman buys and sells magical items and some other special items. He and [[Alesar]] are dealing in different equipment for the [[Efreet]] and [[Green Djinn]] army. He can preform magical extractions. 14 | 15 | He is particularly indifferent towards humans, but will not trade with them until he has permission from [[Malor]]. 16 | {{JSpoiler|Part of {{Spoiler Section|What a Foolish Quest|Mission 10 - A Sweet Surprise}}.}} 17 | | subarea = Mal'ouquah 18 | }} 19 | -------------------------------------------------------------------------------- /tests/resources/content_npc_travel.txt: -------------------------------------------------------------------------------- 1 | {{Infobox NPC|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Captain Bluebear 3 | | job = Ship Captain 4 | | location = [[Thais]] boat at [[Harbour Street|Harbour]] and [[Main Street]]. 5 | | city = Thais 6 | | posx = 126.54 7 | | posy = 125.210 8 | | posz = 6 9 | | gender = Male 10 | | race = Human 11 | | buysell = no 12 | | sounds = {{Sound List|Passages to Carlin, Ab'Dendriel, Edron, Venore, Port Hope, Liberty Bay, Yalahar, Roshamuul and Svargrond.}} 13 | | implemented = 6.4 14 | | notes = Captain Bluebear will transport any [[premium]] players by ship to: 15 | {{Transport |Ab'Dendriel, 130 |Carlin, 110 |Edron, 160 |Liberty Bay, 180 |Port Hope, 160 |Roshamuul, 210 |Oramond, 150 |Svargrond, 180 |Venore, 170 |Yalahar, 200}} 16 | 17 | He can also transport anyone to the [[Character World Transfer|world transfer]] island [[Travora]] for 1000 [[gp]], including [[Free Account]] players. 18 | }} 19 | -------------------------------------------------------------------------------- /tests/resources/content_outfit.txt: -------------------------------------------------------------------------------- 1 | {{Infobox_Outfit|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Barbarian 3 | | primarytype = Premium 4 | | male_id = 143 5 | | female_id = 147 6 | | premium = yes 7 | | outfit = premium 8 | | addons = premium, see [[Barbarian Outfits Quest]]. 9 | | achievement = Brutal Politeness 10 | | implemented = 7.8 11 | | artwork = Barbarian Outfits Artwork.jpg 12 | | notes = This outfit stands for raw power, battle cries and lack of manners. I heard that there are a few barbarians living near Northport. They might just be able to provide fitting addons. 13 | }} 14 | -------------------------------------------------------------------------------- /tests/resources/content_quest.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Quest|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = The Annihilator Quest 3 | | aka = Anni Quest 4 | | reward = [[Demon Outfits]] and one out of four available items: [[Magic Sword]], [[Demon Armor]], [[Stonecutter Axe]], and a [[Present Box]] with an [[Annihilation Bear]] inside. [[Annihilator]] [[achievement]]. 5 | | location = [[Edron]] [[Hero Cave]]. 6 | | lvl = 100 7 | | lvlrec = 125 8 | | transcripts = no 9 | | premium = yes 10 | | log = The Ultimate Challenges 11 | | dangers = Most creatures are on the path to the quest such as [[Hunter]]s, [[Demon Skeleton]]s, [[Wild Warrior]]s, [[Priestess]]es, [[Monk]]s, [[Hero]]es and [[Minotaur Guard]]s. The quest involves only [[Angry Demon]]s and summoned [[Fire Elemental]]s. 12 | | legend = Deep in the earth, near hell, the demons guard their great treasure, who only the bravest could dare to retrieve! 13 | | implemented = 7.24 14 | }} 15 | -------------------------------------------------------------------------------- /tests/resources/content_spell.txt: -------------------------------------------------------------------------------- 1 | {{Infobox Spell|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Flame Strike 3 | | spellid = 89 4 | | implemented = 6.6 5 | | type = Instant 6 | | subclass = Attack 7 | | damagetype = Fire 8 | | words = exori flam 9 | | premium = yes 10 | | mana = 20 11 | | levelrequired = 14 12 | | cooldown = 2 13 | | cooldowngroup = 2 14 | | voc = [[Sorcerer]]s and [[Druid]]s 15 | | spellcost = 800 16 | | effect = [[File:Burned Icon.gif]] Shoots a [[Fire Damage|fire]] missile at the selected target up to 3 square meters away. If no target is selected, the damage is focused on the single square right in front of the caster. 17 | | animation = Flame Strike animation.gif‎ 18 | | notes = One of the most often used spells when fighting creatures vulnerable to [[Fire Damage|fire damage]], as it deals relatively high damage and costs very little mana. 19 | | history = The spell's animation was changed in the [[Winter Update 2007]]. Before the update it could not be cast at a target. 20 | {{{!}} class="wikitable" 21 | !Before 22 | !After 23 | {{!}}- 24 | {{!}}[[File:Flame Strike (Old).gif]] 25 | {{!}}[[File:Flame Strike.gif]] 26 | {{!}}- 27 | {{!}}[[File:Flame strike1.gif]] 28 | {{!}}[[File:Flame strike1(after winter update 2007).gif]] 29 | {{!}}} 30 | }} 31 | -------------------------------------------------------------------------------- /tests/resources/content_update.txt: -------------------------------------------------------------------------------- 1 | {{Infobox_Update|List={{{1|}}}|GetValue={{{GetValue|}}} 2 | | name = Summer Update 2007 3 | | implemented = 8.00 4 | | date = June 26, 2007 5 | | primarytype = Major 6 | | newsid = 536 7 | | secondarytype = Summer 8 | | image = Frost Dragon.gif 9 | | previous = 7.92 10 | | next = 8.10 11 | | previousmajor = 7.9 12 | | nextmajor = 8.10 13 | | summary = New Ice Islands were added, [[Svargrond]], vocation balancing changes and many new weapons. 14 | | changelist = 15 | * The new ice islands [[Grimlund]], [[Helheim]], [[Hrodmir]], [[Nibelor]], [[Okolnir]] and [[Tyrsung]] were added 16 | ** A new hometown, the city of [[Svargrond]], in [[Hrodmir]]. 17 | * New creatures such as the [[Chakoyas]], [[Barbarians]] and more ice themed creatures. 18 | * The [[Svargrond Arena]] was added. 19 | * Many vocation balancing changes 20 | ** Magic damage formula changes. 21 | ** Added over 60 new weapons and ammunition. 22 | ** Added vocation and level requirements to weapons. 23 | | teasers = *{{OfficialNewsArchive|513|Update Teaser Series About to Start Next Week}} 24 | *{{OfficialNewsArchive|519|Welcome to Svargrond}} 25 | *{{OfficialNewsArchive|521|Cool New Monsters}} 26 | *{{OfficialNewsArchive|525|Area Concept - Ice Islands}} 27 | *{{OfficialNewsArchive|526|A New Challenge}} 28 | *{{OfficialNewsArchive|528|Vocation Balancing}} 29 | *{{OfficialNewsArchive|531|Cool New Things}} 30 | *{{OfficialNewsArchive|534|Feel the Merciless Grip of Frost and Cold!}} 31 | | itemsections = {{ItemDPLs|8.00|Axe Weapons|Club Weapons|Sword Weapons|Distance Weapons|Ammunition|Body Equipment|Quest Items}} 32 | | artwork = Summer update 2007.jpg 33 | | notes = 34 | == Vocation Balancing Changes == 35 | === Damage Formula Changes === 36 | * The damage dealt with melee and distance weapons was generally raised. 37 | * In general, experience level has less of a influence on damage. 38 | * Damage dealt with weapons will be more influenced by skill level. 39 | * Damage dealt by spells will be more influenced by magic level. 40 | ** This results in a reduction of the damage dealt by knights and paladins using runes. 41 | * To make up for the damage reduction of spells for knights and paladins, new spells were created, with damage based on their respective skills and weapons. 42 | 43 | === Weapon Requirements === 44 | This update added level and vocation requirements to wield weapons '''properly'''. When a weapon is not wielded properly, the damage dealt is reduced by half.
45 | Only [[knight]]s are able to use two-handed weapons, with the exception of staves and poleaxes. 46 | 47 | This change was necessary since most of the damage depends from skills now, so this would prevent a player fresh out of [[Rookgaard]] to deal great damage using a high attack weapon. 48 | 49 | === New ammunition === 50 | There was new ammunition for paladins too. They are now sold in shops or made with new spells. 51 | 52 | == New Ice Islands == 53 | *[[Grimlund]] (with the Chakoya settlement [[Inukaya]]) 54 | *[[Helheim]] 55 | *[[Hrodmir]] (with the settlement [[Svargrond]] and the barbarian camps [[Ragnir]], [[Bittermor]] and [[Grimhorn]]) 56 | *[[Nibelor]] 57 | *[[Okolnir]] 58 | *[[Tyrsung]] (with the mountain [[Jotunar]] and the hidden [[Frozen Trench]]) 59 | | galleries = 60 | 61 | File:Norseman Outfits.jpg 62 | File:Update Teaser 8.0 ethereal spear.jpg 63 | File:Update Teaser 8.0 geyser.jpg 64 | File:Update Teaser 8.0 hut large.jpg 65 | File:Update Teaser 8.0 mammoth.jpg 66 | File:Update Teaser 8.0 seal.jpg 67 | File:Update Teaser 8.0 trophies.jpg 68 | 69 | 70 | File:Chakoya.jpg|Don't let their cute looks decieve you. (tibia.com) 71 | File:Braindeath.jpg|Imagine how hard they are if they summon a nightmare! (tibia.pl) 72 | File:Barbarian.jpg|'Cool new monsters'. (tibiahumor.net) 73 | File:Mammoth small.jpg| 'We've seen this creature before' (tibiabr.com) 74 | File:Frost-dragon.jpg| 'Certainly not a creature to mess with' (tibiahispano.com) 75 | File:Ice-golem.jpg| 'Massive monster looks very tough' (tibiamx.com.mx) 76 | File:Frost-giants.jpg| 'Half mammoth, half troll :P' (tibiacity.org) 77 | File:Penguin-and-silver-rabbit.jpg| 'They're evil!' (tibianews.net) 78 | File:Barbarians2.jpg| (tibianews.net) 79 | File:Crystal-spider.jpg| (tibianews.net) 80 | 81 | }} 82 | -------------------------------------------------------------------------------- /tests/resources/content_world.txt: -------------------------------------------------------------------------------- 1 | {{Infobox World 2 | | name = Mortera 3 | | type = Retro Open PvP 4 | | online = Oct 15, 2014 5 | | offline = April 19, 2018 6 | | ingamestatus = deprecated 7 | | mergedinto = Firmera 8 | | battleye = yes 9 | | protectedsince = September 26, 2017 10 | | location = USA 11 | | worldboardid = 135438 12 | | tradeboardid = 135441 13 | }} 14 | 15 | == General Information == 16 | Upon its creation, it was {{OfficialNewsArchive|2946|announced}} that this server would temporarily be [[premium]]-only. This phase ended when free account players were also allowed access on {{OfficialNewsArchive|3126|March 24, 2015}}. 17 | 18 | [[Character World Transfer]]s to and from this world were impossible until {{OfficialNewsArchive|4298|September 14, 2017}}, when this restriction was removed. 19 | 20 | First player to get into mainland was {{Character|Huasteck}} 21 | -------------------------------------------------------------------------------- /tests/resources/response_category_without_continue.json: -------------------------------------------------------------------------------- 1 | { 2 | "query": { 3 | "categorymembers": [ 4 | { 5 | "pageid": 6974, 6 | "ns": 0, 7 | "title": "Creature Spells", 8 | "sortkeyprefix": "*", 9 | "timestamp": "2017-11-13T05:44:47Z" 10 | }, 11 | { 12 | "pageid": 21602, 13 | "ns": 0, 14 | "title": "Ultimate Spells", 15 | "sortkeyprefix": "*", 16 | "timestamp": "2017-11-13T05:42:20Z" 17 | }, 18 | { 19 | "pageid": 1917, 20 | "ns": 0, 21 | "title": "Animate Dead", 22 | "sortkeyprefix": "", 23 | "timestamp": "2014-09-20T15:56:10Z" 24 | }, 25 | { 26 | "pageid": 61903, 27 | "ns": 0, 28 | "title": "Apprentice's Strike", 29 | "sortkeyprefix": "", 30 | "timestamp": "2014-09-20T15:14:55Z" 31 | }, 32 | { 33 | "pageid": 5549, 34 | "ns": 0, 35 | "title": "Armageddon Spell", 36 | "sortkeyprefix": "", 37 | "timestamp": "2010-08-30T16:58:37Z" 38 | }, 39 | { 40 | "pageid": 68921, 41 | "ns": 0, 42 | "title": "Arrow Call", 43 | "sortkeyprefix": "", 44 | "timestamp": "2014-09-20T17:41:13Z" 45 | }, 46 | { 47 | "pageid": 19931, 48 | "ns": 0, 49 | "title": "Avalanche", 50 | "sortkeyprefix": "", 51 | "timestamp": "2014-09-20T15:59:03Z" 52 | }, 53 | { 54 | "pageid": 815, 55 | "ns": 0, 56 | "title": "Berserk", 57 | "sortkeyprefix": "", 58 | "timestamp": "2014-09-20T15:15:16Z" 59 | }, 60 | { 61 | "pageid": 30160, 62 | "ns": 0, 63 | "title": "Blood Rage", 64 | "sortkeyprefix": "", 65 | "timestamp": "2014-09-20T15:15:36Z" 66 | }, 67 | { 68 | "pageid": 68875, 69 | "ns": 0, 70 | "title": "Bruise Bane", 71 | "sortkeyprefix": "", 72 | "timestamp": "2014-09-20T15:04:27Z" 73 | } 74 | ] 75 | } 76 | } -------------------------------------------------------------------------------- /tests/resources/response_image_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "query": { 3 | "pages": { 4 | "1595": { 5 | "pageid": 1595, 6 | "ns": 6, 7 | "title": "File:Golden Armor.gif", 8 | "imagerepository": "local", 9 | "imageinfo": [ 10 | { 11 | "timestamp": "2005-06-13T13:44:13Z", 12 | "user": "Rune Farmer", 13 | "userid": "270071", 14 | "parsedcomment": "Better copy which isn't cropped on the side", 15 | "comment": "Better copy which isn't cropped on the side", 16 | "url": "https://vignette.wikia.nocookie.net/tibia/images/d/d0/Golden_Armor.gif/revision/latest?cb=20050613134413&path-prefix=en", 17 | "descriptionurl": "https://tibia.fandom.com/wiki/File:Golden_Armor.gif", 18 | "bitdepth": "8" 19 | } 20 | ] 21 | }, 22 | "-1": { 23 | "ns": 6, 24 | "title": "File:Golden Shield.gif", 25 | "missing": "", 26 | "imagerepository": "" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /tests/resources/response_revisions.json: -------------------------------------------------------------------------------- 1 | { 2 | "query": { 3 | "pages": { 4 | "1600": { 5 | "pageid": 1600, 6 | "ns": 0, 7 | "title": "Golden Armor", 8 | "revisions": [ 9 | { 10 | "timestamp": "2017-04-26T20:04:19Z", 11 | "*": "{{Infobox Item|List={{{1|}}}|GetValue={{{GetValue|}}}\n| name = Golden Armor\n| marketable = yes\n| sprites = {{Frames|{{Frame Sprite|55355}}}}\n| article = a\n| actualname = golden armor\n| plural = ?\n| itemid = 3360\n| flavortext = It's an enchanted armor.\n| itemclass = Body Equipment\n| primarytype = Armors\n| secondarytype = \n| stackable = no\n| vocrequired = knights and paladins\n| armor = 14\n| imbueslots = 2\n| weight = 80.00\n| droppedby = {{Dropped By|Ferumbras|Ferumbras Mortal Shell|Ghazbaran|Golden Servant|Hellflayer|Hellgorak|Horadron|Juggernaut|Kerberos|Massacre|The Last Lore Keeper|Undead Dragon|Warlock|Zanakeph|Zarabustor}}\n| value = 20,000 - 32,000\n| npcvalue = 20000\n| npcprice = 0\n| buyfrom = --\n| sellto = Rashid, Shanar:1500, Hardek:1500, H.L.:580\n| notes = Tenth best armor, part of the [[Golden Set]].\nAfter the Update 8.4, the Golden Armor became for knights and paladins only. This can also be gotten by [[Rust Remover|unrusting]] a [[Rusty Armor (Rare)]].\n{{JSpoiler|Obtainable through the [[Behemoth Quest]].}}\n}}" 12 | } 13 | ] 14 | }, 15 | "-1": { 16 | "ns": 0, 17 | "title": "Golden Shield", 18 | "missing": "" 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/test_generation.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tibiawikisql.generation import parse_spell_data 4 | 5 | 6 | class TestGeneration(unittest.TestCase): 7 | def test_parse_spell_data(self): 8 | # language=lua 9 | content = """return { 10 | ["Animate Dead"] = { 11 | ["vocation"] = {"Druid", "Sorcerer"}, 12 | ["level"] = 27, 13 | ["price"] = 1200, 14 | ["sellers"] = { 15 | ["Azalea"] = "Druid", 16 | ["Barnabas Dee"] = "Sorcerer", 17 | ["Charlotta"] = "Druid", 18 | ["Gundralph"] = true, 19 | ["Hjaern"] = "Druid", 20 | ["Malunga"] = "Sorcerer", 21 | ["Myra"] = "Sorcerer", 22 | ["Rahkem"] = "Druid", 23 | ["Romir"] = "Sorcerer", 24 | ["Shalmar"] = true, 25 | ["Tamara"] = "Druid", 26 | ["Tamoril"] = "Sorcerer", 27 | ["Tothdral"] = "Sorcerer", 28 | ["Ustan"] = "Druid" 29 | } 30 | }, 31 | ["Annihilation"] = { 32 | ["vocation"] = {"Knight"}, 33 | ["level"] = 110, 34 | ["price"] = 20000, 35 | ["sellers"] = { 36 | ["Ormuhn"] = true, 37 | ["Razan"] = true, 38 | ["Puffels"] = true, 39 | ["Tristan"] = true, 40 | ["Uso"] = true, 41 | ["Graham"] = true, 42 | ["Thorwulf"] = true, 43 | ["Zarak"] = true 44 | } 45 | }, 46 | ["Apprentice's Strike"] = { 47 | ["vocation"] = {"Druid", "Sorcerer"}, 48 | ["level"] = 8, 49 | ["price"] = 0, 50 | ["sellers"] = { 51 | ["Azalea"] = "Druid", 52 | ["Barnabas Dee"] = "Sorcerer", 53 | ["Charlotta"] = "Druid", 54 | ["Chatterbone"] = "Sorcerer", 55 | ["Eroth"] = "Druid", 56 | ["Etzel"] = "Sorcerer", 57 | ["Garamond"] = true, 58 | ["Gundralph"] = true, 59 | ["Hjaern"] = "Druid", 60 | ["Lea"] = "Sorcerer", 61 | ["Malunga"] = "Sorcerer", 62 | ["Marvik"] = "Druid", 63 | ["Muriel"] = "Sorcerer", 64 | ["Myra"] = "Sorcerer", 65 | ["Padreia"] = "Druid", 66 | ["Rahkem"] = "Druid", 67 | ["Romir"] = "Sorcerer", 68 | ["Shalmar"] = true, 69 | ["Smiley"] = "Druid", 70 | ["Tamara"] = "Druid", 71 | ["Tamoril"] = "Sorcerer", 72 | ["Tothdral"] = "Sorcerer", 73 | ["Ustan"] = "Druid" 74 | } 75 | }, 76 | ["Arrow Call"] = { 77 | ["vocation"] = {"Paladin"}, 78 | ["level"] = 1, 79 | ["price"] = 0, 80 | ["sellers"] = { 81 | ["Asrak"] = true, 82 | ["Dario"] = true, 83 | ["Elane"] = true, 84 | ["Ethan"] = true, 85 | ["Hawkyr"] = true, 86 | ["Helor"] = true, 87 | ["Irea"] = true, 88 | ["Isolde"] = true, 89 | ["Legola"] = true, 90 | ["Razan"] = true, 91 | ["Silas"] = true, 92 | ["Ursula"] = true 93 | } 94 | }, 95 | ["Avalanche"] = { 96 | ["vocation"] = {"Druid"}, 97 | ["level"] = 30, 98 | ["price"] = 1200, 99 | ["sellers"] = { 100 | ["Azalea"] = true, 101 | ["Charlotta"] = true, 102 | ["Elathriel"] = true, 103 | ["Gundralph"] = true, 104 | ["Hjaern"] = true, 105 | ["Marvik"] = true, 106 | ["Padreia"] = true, 107 | ["Rahkem"] = true, 108 | ["Shalmar"] = true, 109 | ["Smiley"] = true, 110 | ["Tamara"] = true, 111 | ["Ustan"] = true 112 | } 113 | }, 114 | ["Balanced Brawl"] = { 115 | ["vocation"] = {"Monk"}, 116 | ["level"] = 175, 117 | ["price"] = 250000, 118 | ["sellers"] = {["Enpa Rudra"] = true} 119 | } 120 | }""" 121 | 122 | spell_offers = parse_spell_data(content) 123 | 124 | self.assertEqual(70, len(spell_offers)) 125 | -------------------------------------------------------------------------------- /tests/tests_parsers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from tests import load_resource 5 | from tibiawikisql.api import Article 6 | from tibiawikisql.models import Achievement 7 | from tibiawikisql.parsers import AchievementParser 8 | 9 | 10 | class TestParsers(unittest.TestCase): 11 | def test_achievement_parser_success(self): 12 | article = Article( 13 | article_id=1, 14 | title="Demonic Barkeeper", 15 | timestamp=datetime.datetime.fromisoformat("2018-08-20T04:33:15+00:00"), 16 | content=load_resource("content_achievement.txt"), 17 | ) 18 | 19 | achievement = AchievementParser.from_article(article) 20 | 21 | self.assertIsInstance(achievement, Achievement) 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/tests_schema.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import unittest 3 | import datetime 4 | 5 | from tibiawikisql.errors import InvalidColumnValueError 6 | from tibiawikisql.schema import AchievementTable 7 | 8 | SAMPLE_ACHIEVEMENT_ROW = { 9 | "article_id": 2744, 10 | "title": "Annihilator", 11 | "name": "Annihilator", 12 | "grade": 2, 13 | "points": 5, 14 | "description": "You've daringly jumped into the infamous Annihilator and survived - taking home fame, glory and your reward.", 15 | "spoiler": "Obtainable by finishing The Annihilator Quest.", 16 | "is_secret": False, 17 | "is_premium": True, 18 | "achievement_id": 57, 19 | "version": "8.60", 20 | "status": "active", 21 | "timestamp": datetime.datetime.fromisoformat("2021-05-26T20:40:00+00:00"), 22 | } 23 | 24 | 25 | class TestSchema(unittest.TestCase): 26 | 27 | def setUp(self): 28 | self.conn = sqlite3.connect(":memory:") 29 | self.conn.row_factory = sqlite3.Row 30 | 31 | def test_achievement_table_insert_success(self): 32 | self.conn.executescript(AchievementTable.get_create_table_statement()) 33 | 34 | AchievementTable.insert(self.conn, **SAMPLE_ACHIEVEMENT_ROW) 35 | 36 | def test_achievement_table_insert_none_non_nullable_field(self): 37 | self.conn.executescript(AchievementTable.get_create_table_statement()) 38 | row = SAMPLE_ACHIEVEMENT_ROW.copy() 39 | row["title"] = None 40 | 41 | with self.assertRaises(InvalidColumnValueError): 42 | AchievementTable.insert(self.conn, **row) 43 | 44 | def test_achievement_table_insert_wrong_type(self): 45 | self.conn.executescript(AchievementTable.get_create_table_statement()) 46 | row = SAMPLE_ACHIEVEMENT_ROW.copy() 47 | row["is_premium"] = "yes" 48 | 49 | with self.assertRaises(InvalidColumnValueError): 50 | AchievementTable.insert(self.conn, **row) 51 | 52 | def test_achievement_table_get_by_field_wrong_column(self): 53 | with self.assertRaises(ValueError): 54 | AchievementTable.get_one_by_field(self.conn, "unknown", True) 55 | 56 | def test_achievement_table_get_by_field_no_results(self): 57 | self.conn.executescript(AchievementTable.get_create_table_statement()) 58 | 59 | result = AchievementTable.get_one_by_field(self.conn, "title", "Annihilator") 60 | 61 | self.assertIsNone(result) 62 | 63 | def test_achievement_table_get_by_field_get_results(self): 64 | self.conn.executescript(AchievementTable.get_create_table_statement()) 65 | AchievementTable.insert(self.conn, **SAMPLE_ACHIEVEMENT_ROW) 66 | 67 | result = AchievementTable.get_one_by_field(self.conn, "title", "Annihilator") 68 | 69 | self.assertIsNotNone(result) 70 | self.assertEqual(5, result["points"]) 71 | -------------------------------------------------------------------------------- /tests/tests_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests import load_resource 4 | from tibiawikisql.utils import (clean_links, client_color_to_rgb, parse_boolean, parse_float, parse_integer, 5 | parse_loot_statistics, parse_min_max, parse_sounds) 6 | 7 | 8 | class TestUtils(unittest.TestCase): 9 | def test_clean_links(self): 10 | # Regular link 11 | self.assertEqual(clean_links("[[Holy Damage]]"), "Holy Damage") 12 | # Named link 13 | self.assertEqual(clean_links("[[Curse (Charm)|Curse]]"), "Curse") 14 | # Comments 15 | self.assertEqual(clean_links("Hello "), "Hello") 16 | 17 | def test_clean_links_list(self): 18 | content = """* The new ice islands [[Grimlund]], [[Helheim]], [[Hrodmir]], [[Nibelor]], [[Okolnir]] and [[Tyrsung]] were added 19 | ** A new hometown, the city of [[Svargrond]], in [[Hrodmir]]. 20 | * New creatures such as the [[Chakoyas]], [[Barbarians]] and more ice themed creatures. 21 | * The [[Svargrond Arena]] was added. 22 | * Many vocation balancing changes 23 | ** Magic damage formula changes. 24 | ** Added over 60 new weapons and ammunition. 25 | ** Added vocation and level requirements to weapons.""" 26 | 27 | clean_content = clean_links(content) 28 | 29 | expected = """- The new ice islands Grimlund, Helheim, Hrodmir, Nibelor, Okolnir and Tyrsung were added 30 | - A new hometown, the city of Svargrond, in Hrodmir. 31 | - New creatures such as the Chakoyas, Barbarians and more ice themed creatures. 32 | - The Svargrond Arena was added. 33 | - Many vocation balancing changes 34 | - Magic damage formula changes. 35 | - Added over 60 new weapons and ammunition. 36 | - Added vocation and level requirements to weapons.""" 37 | self.assertEqual(expected, clean_content) 38 | 39 | def test_parse_boolean(self): 40 | self.assertTrue(parse_boolean("yes")) 41 | self.assertFalse(parse_boolean("no")) 42 | self.assertFalse(parse_boolean("--")) 43 | self.assertTrue(parse_boolean("--", True)) 44 | self.assertTrue(parse_boolean("no", invert=True)) 45 | 46 | def test_parse_float(self): 47 | self.assertEqual(parse_float("1.45"), 1.45) 48 | self.assertEqual(parse_float("?"), 0.0) 49 | self.assertIsNone(parse_float("?", None)) 50 | self.assertEqual(parse_float("2.55%"), 2.55) 51 | 52 | def test_parse_integer(self): 53 | self.assertEqual(parse_integer("100 tibia coins"), 100) 54 | self.assertEqual(parse_integer("10056"), 10056) 55 | self.assertEqual(parse_integer("--"), 0) 56 | 57 | def test_parse_min_max(self): 58 | self.assertEqual(parse_min_max("5-20"), (5, 20)) 59 | self.assertEqual(parse_min_max("50"), (0, 50)) 60 | 61 | def test_parse_sounds(self): 62 | sound_string = "{{Sound List|Sound 1|Sound 2|Sound 3}}" 63 | sounds = parse_sounds(sound_string) 64 | self.assertEqual(len(sounds), 3) 65 | 66 | self.assertFalse(parse_sounds("?")) 67 | 68 | def test_parse_loot_statistics(self): 69 | content = load_resource("content_loot_statistics.txt") 70 | kills, loot_statistics = parse_loot_statistics(content) 71 | self.assertEqual(36488, kills) 72 | self.assertEqual(34, len(loot_statistics)) 73 | 74 | kills, loot_statistics = parse_loot_statistics("Something else") 75 | self.assertEqual(kills, 0) 76 | self.assertFalse(loot_statistics) 77 | 78 | def test_client_light_to_rgb(self): 79 | self.assertEqual(client_color_to_rgb(-1), 0) 80 | self.assertEqual(client_color_to_rgb(0), 0) 81 | self.assertEqual(client_color_to_rgb(3), 0x99) 82 | self.assertEqual(client_color_to_rgb(215), 0xffffff) 83 | self.assertEqual(client_color_to_rgb(216), 0) 84 | -------------------------------------------------------------------------------- /tests/tests_wikiapi.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock 3 | 4 | import tibiawikisql.api 5 | from tests import load_resource 6 | from tibiawikisql.api import Article, WikiClient, WikiEntry, Image 7 | 8 | 9 | class TestWikiApi(unittest.TestCase): 10 | 11 | 12 | def setUp(self): 13 | self.wiki_client = WikiClient() 14 | 15 | def test_category_functions(self): 16 | json_response = load_resource("response_category_without_continue.json") 17 | tibiawikisql.api.requests.Session.get = MagicMock() 18 | tibiawikisql.api.requests.Session.get.return_value.text = json_response 19 | members = list(self.wiki_client.get_category_members("Spells")) 20 | self.assertIsInstance(members[0], WikiEntry) 21 | self.assertEqual(len(members), 8) 22 | 23 | members = list(self.wiki_client.get_category_members("Spells", False)) 24 | self.assertEqual(len(members), 10) 25 | 26 | titles = list(self.wiki_client.get_category_members_titles("Spells")) 27 | self.assertIsInstance(titles[0], str) 28 | self.assertEqual(len(titles), 8) 29 | 30 | def test_article_functions(self): 31 | json_response = load_resource("response_revisions.json") 32 | tibiawikisql.api.requests.Session.get = MagicMock() 33 | tibiawikisql.api.requests.Session.get.return_value.text = json_response 34 | # Response is mocked, so this doesn't affect the output, but this matches the order in the mocked response. 35 | titles = ["Golden Armor", "Golden Shield"] 36 | articles = list(self.wiki_client.get_articles(titles)) 37 | self.assertIsInstance(articles[0], Article) 38 | self.assertEqual(articles[0].title, titles[0]) 39 | self.assertIsNone(articles[1]) 40 | 41 | article = self.wiki_client.get_article(titles[0]) 42 | self.assertIsInstance(article, Article) 43 | self.assertEqual(article.title, titles[0]) 44 | 45 | def test_image_functions(self): 46 | json_response = load_resource("response_image_info.json") 47 | tibiawikisql.api.requests.Session.get = MagicMock() 48 | tibiawikisql.api.requests.Session.get.return_value.text = json_response 49 | tibiawikisql.api.requests.Session.get.return_value.status_code = 200 50 | # Response is mocked, so this doesn't affect the output, but this matches the order in the mocked response. 51 | titles = ["Golden Armor.gif", "Golden Shield.gif"] 52 | images = list(self.wiki_client.get_images_info(titles)) 53 | self.assertIsInstance(images[0], Image) 54 | self.assertEqual(images[0].file_name, titles[0]) 55 | self.assertIsNone(images[1]) 56 | 57 | image = self.wiki_client.get_image_info(titles[0]) 58 | self.assertIsInstance(image, Image) 59 | self.assertEqual(image.file_name, titles[0]) 60 | self.assertEqual(image.extension, ".gif") 61 | self.assertEqual(image.clean_name, "Golden Armor") 62 | -------------------------------------------------------------------------------- /tibiawikisql/__init__.py: -------------------------------------------------------------------------------- 1 | """API that reads and parses information from `TibiaWiki `_.""" 2 | 3 | __author__ = "Allan Galarza" 4 | __copyright__ = "Copyright 2025 Allan Galarza" 5 | 6 | __license__ = "Apache 2.0" 7 | __version__ = "7.0.1" 8 | -------------------------------------------------------------------------------- /tibiawikisql/__main__.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | import click 4 | import colorama 5 | 6 | from tibiawikisql import __version__, generation 7 | from tibiawikisql.utils import timed 8 | 9 | DATABASE_FILE = "tibiawiki.db" 10 | 11 | colorama.init() 12 | 13 | 14 | @click.group(context_settings={'help_option_names': ['-h', '--help']}) 15 | @click.version_option(__version__, '-V', '--version') 16 | def cli(): 17 | # Empty command group to disable default command. 18 | pass 19 | 20 | 21 | @cli.command(name="generate") 22 | @click.option('-s', '--skip-images', help="Skip fetching and loading images to the database.", is_flag=True) 23 | @click.option('-db', '--db-name', help="Name for the database file.", default=DATABASE_FILE) 24 | @click.option('-sd', '--skip-deprecated', help="Skips fetching deprecated articles and their images.", is_flag=True) 25 | def generate(skip_images, db_name, skip_deprecated): 26 | """Generates a database file.""" 27 | with timed() as t: 28 | with sqlite3.connect(db_name) as conn: 29 | generation.generate(conn, skip_images, skip_deprecated) 30 | click.echo(f"Command finished in {t.elapsed:.2f} seconds.") 31 | 32 | 33 | if __name__ == "__main__": 34 | cli() 35 | -------------------------------------------------------------------------------- /tibiawikisql/api.py: -------------------------------------------------------------------------------- 1 | """API to fetch information from [TibiaWiki](https://tibia.fandom.com) through MediaWiki's API.""" 2 | 3 | import datetime 4 | import json 5 | import urllib.parse 6 | from collections.abc import Generator 7 | from typing import ClassVar 8 | 9 | from pydantic import BaseModel, computed_field 10 | import requests 11 | 12 | from tibiawikisql import __version__ 13 | from tibiawikisql.utils import parse_templatates_data 14 | 15 | BASE_URL = "https://tibia.fandom.com" 16 | 17 | 18 | class WikiEntry(BaseModel): 19 | """Represents a Wiki entry, such as an article or file.""" 20 | 21 | article_id: int 22 | """The entry's ID.""" 23 | title: str 24 | """The entry's title.""" 25 | timestamp: datetime.datetime 26 | """The date of the entry's last edit.""" 27 | 28 | def __eq__(self, other: object) -> bool: 29 | """Check for equality. 30 | 31 | Returns: 32 | `True` if both objects are instances of this class and have the same `article_id`. 33 | 34 | """ 35 | if isinstance(other, self.__class__): 36 | return self.article_id == other.article_id 37 | return False 38 | 39 | @computed_field 40 | @property 41 | def url(self) -> str: 42 | """The URL to the article's display page.""" 43 | return f"{BASE_URL}/wiki/{urllib.parse.quote(self.title.replace(' ','_'))}" 44 | 45 | 46 | class Article(WikiEntry): 47 | """Represents a Wiki article.""" 48 | 49 | content: str 50 | """The article's source content.""" 51 | 52 | @property 53 | def infobox_attributes(self) -> dict: 54 | """Returns a mapping of the template attributes.""" 55 | return parse_templatates_data(self.content) 56 | 57 | 58 | class Image(WikiEntry): 59 | """Represents an image info.""" 60 | 61 | file_url: str 62 | """The URL to the file.""" 63 | 64 | @property 65 | def extension(self) -> str | None: 66 | """The image's file extension.""" 67 | parts = self.title.split(".") 68 | if len(parts) == 1: 69 | return None 70 | return f".{parts[-1]}" 71 | 72 | @property 73 | def file_name(self) -> str: 74 | """The image's file name.""" 75 | return self.title.replace("File:", "") 76 | 77 | @property 78 | def clean_name(self) -> str: 79 | """The image's name without extension and prefix.""" 80 | return self.file_name.replace(self.extension, "") 81 | 82 | 83 | class WikiClient: 84 | """Contains methods to communicate with TibiaWiki's API.""" 85 | 86 | ENDPOINT: ClassVar[str] = f"{BASE_URL}/api.php" 87 | 88 | headers: ClassVar[dict[str, str]]= { 89 | "User-Agent": f'tibiawikisql/{__version__}', # noqa: Q000 90 | } 91 | 92 | def __init__(self) -> None: 93 | """Creates a new instance of the client.""" 94 | self.session = requests.Session() 95 | 96 | def get_category_members(self, name: str, skip_index: bool = True) -> Generator[WikiEntry]: 97 | """Create a generator that obtains entries in a certain category. 98 | 99 | Args: 100 | name: The category's name. ``Category:`` prefix is not necessary. 101 | skip_index: Whether to skip index articles or not. 102 | 103 | Yields: 104 | Articles in this category. 105 | 106 | """ 107 | cmcontinue = None 108 | params = { 109 | "action": "query", 110 | "list": "categorymembers", 111 | "cmtitle": f"Category:{name}", 112 | "cmlimit": 500, 113 | "cmtype": "page", 114 | "cmprop": "ids|title|sortkeyprefix|timestamp", 115 | "format": "json", 116 | } 117 | while True: 118 | params["cmcontinue"] = cmcontinue 119 | r = self.session.get(self.ENDPOINT, params=params) 120 | data = json.loads(r.text) 121 | for member in data["query"]["categorymembers"]: 122 | if member["sortkeyprefix"] == "*" and skip_index: 123 | continue 124 | yield WikiEntry( 125 | article_id=member["pageid"], 126 | title=member["title"], 127 | timestamp=member["timestamp"], 128 | ) 129 | try: 130 | cmcontinue = data["continue"]["cmcontinue"] 131 | except KeyError: 132 | # If there's no "cmcontinue", means we reached the end of the list. 133 | break 134 | 135 | def get_category_members_titles(self, name: str, skip_index: bool =True) -> Generator[str]: 136 | """Create a generator that obtains a list of article titles in a category. 137 | 138 | Args: 139 | name: The category's name. ``Category:`` prefix is not necessary. 140 | skip_index: Whether to skip index articles or not. 141 | 142 | Yields: 143 | Titles of articles in this category. 144 | 145 | """ 146 | for member in self.get_category_members(name, skip_index): 147 | yield member.title 148 | 149 | 150 | def get_image_info(self, name: str) -> Image: 151 | """Get an image's info. 152 | 153 | It is not required to prefix the name with ``File:``, but the extension is required. 154 | 155 | Args: 156 | name: The name of the image. 157 | 158 | Returns: 159 | The image's information. 160 | 161 | """ 162 | gen = self.get_images_info([name]) 163 | return next(gen) 164 | 165 | def get_images_info(self, names: list[str]) -> Generator[Image | None]: 166 | """Get the information of a list of image names. 167 | 168 | It is not required to prefix the name with ``File:``, but the extension is required. 169 | 170 | Warning: 171 | The order of the returned images might not match the order of the provided names due to an API limitation. 172 | 173 | Args: 174 | names: A list of names of images to get the info of. 175 | 176 | Yields: 177 | An image's information. 178 | 179 | """ 180 | i = 0 181 | params = { 182 | "action": "query", 183 | "prop": "imageinfo", 184 | "iiprop": "url|timestamp", 185 | "format": "json", 186 | } 187 | while True: 188 | if i >= len(names): 189 | break 190 | params["titles"] = "|".join(f"File:{n}" for n in names[i:min(i + 50, len(names))]) 191 | 192 | r = self.session.get(self.ENDPOINT, params=params) 193 | if r.status_code >= 400: 194 | continue 195 | data = json.loads(r.text) 196 | i += 50 197 | for image_data in data["query"]["pages"].values(): 198 | if "missing" in image_data: 199 | yield None 200 | continue 201 | try: 202 | yield Image( 203 | article_id=image_data["pageid"], 204 | title=image_data["title"], 205 | timestamp=image_data["imageinfo"][0]["timestamp"], 206 | file_url=image_data["imageinfo"][0]["url"], 207 | ) 208 | except KeyError: 209 | continue 210 | 211 | def get_articles(self, names: list[str]) -> Generator[Article | None]: 212 | """Create a generator that obtains a list of articles given their titles. 213 | 214 | Warning: 215 | The order of the returned articles might not match the order of the provided names due to an API limitation. 216 | 217 | Args: 218 | names: A list of names of articles to get the info of. 219 | 220 | Yields: 221 | An article in the list of names. 222 | 223 | """ 224 | i = 0 225 | params = { 226 | "action": "query", 227 | "prop": "revisions", 228 | "rvprop": "content|timestamp", 229 | "format": "json", 230 | } 231 | while True: 232 | if i >= len(names): 233 | break 234 | params["titles"] = "|".join(names[i:min(i + 50, len(names))]) 235 | i += 50 236 | r = self.session.get(self.ENDPOINT, params=params) 237 | data = json.loads(r.text) 238 | for article in data["query"]["pages"].values(): 239 | if "missing" in article: 240 | yield None 241 | continue 242 | yield Article( 243 | article_id=article["pageid"], 244 | timestamp=article["revisions"][0]["timestamp"], 245 | title=article["title"], 246 | content=article["revisions"][0]["*"], 247 | ) 248 | 249 | def get_article(self, name: str) -> Article: 250 | """Get an article's info. 251 | 252 | Args: 253 | name: The name of the Article. 254 | 255 | Returns: 256 | The article matching the title. 257 | 258 | """ 259 | gen = self.get_articles([name]) 260 | return next(gen) 261 | -------------------------------------------------------------------------------- /tibiawikisql/errors.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions used by the package.""" 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | 7 | if TYPE_CHECKING: 8 | from tibiawikisql.api import Article 9 | from tibiawikisql.parsers import BaseParser 10 | from tibiawikisql.database import Column, Table 11 | 12 | 13 | class TibiaWikiSqlError(Exception): 14 | """Base class for all exceptions raised by tibiawiki-sql.""" 15 | 16 | 17 | class AttributeParsingError(TibiaWikiSqlError): 18 | """Error raised when trying to parse an attribute.""" 19 | def __init__(self, cause: type[Exception]) -> None: 20 | """Create an instance of the class. 21 | 22 | Args: 23 | cause: The exception that caused this. 24 | 25 | """ 26 | super().__init__(f"{cause.__class__.__name__}: {cause}") 27 | 28 | 29 | class ArticleParsingError(TibiaWikiSqlError): 30 | """Error raised when an article failed to be parsed.""" 31 | 32 | def __init__(self, article: Article, msg: str | None = None, cause: type[Exception] | None = None) -> None: 33 | """Create an instance of the class. 34 | 35 | Args: 36 | article: The article that failed to parse. 37 | msg: An error message for the error. 38 | cause: The original exception that caused the parsing to fail. 39 | """ 40 | self.article = article 41 | if cause: 42 | msg = f"Error parsing article: `{article.title}` | {cause.__class__.__name__}: {cause}" 43 | else: 44 | msg = f"Error parsing article: `{article.title}` | {msg}" 45 | super().__init__(msg) 46 | 47 | 48 | class TemplateNotFoundError(ArticleParsingError): 49 | """Error raised when the required template is not found in the article.""" 50 | def __init__(self, article: Article, parser: type[BaseParser]) -> None: 51 | """Create an instance of the class. 52 | 53 | Args: 54 | article: The article that failed to parse. 55 | parser: The parser class used. 56 | """ 57 | super().__init__(article, f"Template `{parser.template_name}` not found.") 58 | 59 | 60 | class DatabaseError(TibiaWikiSqlError): 61 | """Error raised when a database related error happens.""" 62 | 63 | 64 | class InvalidColumnValueError(TibiaWikiSqlError): 65 | """Error raised when an invalid value is assigned to a column.""" 66 | 67 | def __init__(self, table: type[Table], column: Column, message: str) -> None: 68 | """Create an instance of the class. 69 | 70 | Args: 71 | table: The table where the column is located. 72 | column: The column with the error. 73 | message: A brief description of the error. 74 | """ 75 | super().__init__(f"Column {column.name!r} in table {table.__tablename__!r}: {message}") 76 | self.table = table 77 | self.column = column 78 | 79 | 80 | class SchemaError(DatabaseError): 81 | """Error raised for invalid schema definitions. 82 | 83 | Notes: 84 | This error is raised very early when running, to verify that classes are defined correctly, 85 | so it is not an error that should be seen when using the library. 86 | """ 87 | 88 | -------------------------------------------------------------------------------- /tibiawikisql/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Contains all the models representing TibiaWiki articles.""" 2 | 3 | from tibiawikisql.models.achievement import Achievement 4 | from tibiawikisql.models.charm import Charm 5 | from tibiawikisql.models.creature import Creature, CreatureAbility, CreatureDrop, CreatureMaxDamage 6 | from tibiawikisql.models.house import House 7 | from tibiawikisql.models.imbuement import Imbuement, ImbuementMaterial 8 | from tibiawikisql.models.item import Book, Item, ItemAttribute, ItemStoreOffer, Key 9 | from tibiawikisql.models.mount import Mount 10 | from tibiawikisql.models.npc import Npc, NpcDestination, NpcOffer, NpcSpell, RashidPosition 11 | from tibiawikisql.models.outfit import Outfit, OutfitImage, OutfitQuest 12 | from tibiawikisql.models.quest import Quest, QuestDanger, QuestReward 13 | from tibiawikisql.models.spell import Spell 14 | from tibiawikisql.models.update import Update 15 | from tibiawikisql.models.world import World 16 | -------------------------------------------------------------------------------- /tibiawikisql/models/achievement.py: -------------------------------------------------------------------------------- 1 | 2 | from tibiawikisql.api import WikiEntry 3 | from tibiawikisql.models.base import RowModel, WithStatus, WithVersion 4 | from tibiawikisql.schema import AchievementTable 5 | 6 | 7 | class Achievement(WikiEntry, WithStatus, WithVersion, RowModel, table=AchievementTable): 8 | """Represents an Achievement.""" 9 | 10 | name: str 11 | """The achievement's name.""" 12 | grade: int | None 13 | """The achievement's grade, from 1 to 3. Also known as 'stars'.""" 14 | points: int | None 15 | """The amount of points given by this achievement.""" 16 | description: str 17 | """The official description shown for the achievement.""" 18 | spoiler: str | None 19 | """Instructions or information on how to obtain the achievement.""" 20 | is_secret: bool 21 | """Whether the achievement is secret or not.""" 22 | is_premium: bool 23 | """Whether a premium account is required to get this achievement.""" 24 | achievement_id: int | None 25 | """The internal ID of the achievement.""" 26 | -------------------------------------------------------------------------------- /tibiawikisql/models/base.py: -------------------------------------------------------------------------------- 1 | """Module with base classes used by models.""" 2 | from __future__ import annotations 3 | 4 | from sqlite3 import Connection, Cursor, Row 5 | from typing import Any, ClassVar, TYPE_CHECKING 6 | 7 | from pydantic import BaseModel, Field 8 | 9 | from tibiawikisql.database import Table 10 | 11 | if TYPE_CHECKING: 12 | from typing_extensions import Self 13 | 14 | 15 | class WithStatus(BaseModel): 16 | """Adds the status field to a model.""" 17 | 18 | status: str 19 | """The in-game status for this element""" 20 | 21 | 22 | class WithVersion(BaseModel): 23 | """Adds the version field to a model.""" 24 | 25 | version: str | None 26 | """The client version when this was implemented in the game, if known.""" 27 | 28 | 29 | class WithImage(BaseModel): 30 | """Adds the image field to a model.""" 31 | 32 | image: bytes | None = Field(None, exclude=True) 33 | """An image representing this article.""" 34 | 35 | 36 | class RowModel(BaseModel): 37 | """A mixin class to indicate that this model comes from a SQL table.""" 38 | 39 | table: ClassVar[type[Table]] = NotImplemented 40 | """The SQL table where this model is stored.""" 41 | 42 | def __init_subclass__(cls, **kwargs) -> None: 43 | super().__init_subclass__() 44 | 45 | if cls.__name__ == "RowModel": 46 | return # skip base class 47 | 48 | if "table" not in kwargs: 49 | msg = f"{cls.__name__} must define a `table` attribute." 50 | raise NotImplementedError(msg) 51 | 52 | table = kwargs["table"] 53 | if not isinstance(table, type) or not issubclass(table, Table): 54 | msg = f"{cls.__name__}.table must be a subclass of Table." 55 | raise TypeError(msg) 56 | cls.table = table 57 | 58 | 59 | def insert(self, conn: Connection | Cursor) -> None: 60 | """Insert the model into its respective database table. 61 | 62 | Args: 63 | conn: A cursor or connection to the database. 64 | """ 65 | rows = {} 66 | for column in self.table.columns: 67 | try: 68 | value = getattr(self, column.name) 69 | if value == column.default: 70 | continue 71 | rows[column.name] = value 72 | except AttributeError: 73 | continue 74 | self.table.insert(conn, **rows) 75 | 76 | @classmethod 77 | def from_row(cls, row: Row | dict[str, Any]) -> Self: 78 | """Return an instance of the model from a row or dictionary. 79 | 80 | Args: 81 | row: A dict representing a row or a Row object. 82 | 83 | Returns: 84 | An instance of the class, based on the row. 85 | 86 | """ 87 | if isinstance(row, Row): 88 | row = dict(row) 89 | return cls.model_validate(row) 90 | 91 | @classmethod 92 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None: 93 | """Get a single element matching the field's value. 94 | 95 | Args: 96 | conn: A connection or cursor of the database. 97 | field: The field to filter with. 98 | value: The value to look for. 99 | use_like: Whether to use ``LIKE`` as a comparator instead of ``=``. 100 | 101 | Returns: 102 | The object found, or ``None`` if no entries match. 103 | 104 | Raises: 105 | ValueError: The specified field doesn't exist in the table. 106 | 107 | """ 108 | row = cls.table.get_one_by_field(conn, field, value, use_like) 109 | return cls.from_row(row) if row else None 110 | 111 | @classmethod 112 | def get_list_by_field( 113 | cls, 114 | conn: Connection | Cursor, 115 | field: str, 116 | value: Any | None = None, 117 | use_like: bool = False, 118 | sort_by: str | None = None, 119 | ascending: bool = True, 120 | ) -> list[Self]: 121 | """Get a list of elements matching the specified field's value. 122 | 123 | Note that this won't get values found in child tables. 124 | 125 | Args: 126 | conn: A connection or cursor of the database. 127 | field: The name of the field to filter by. 128 | value: The value to filter by. 129 | use_like: Whether to use ``LIKE`` as a comparator instead of ``=``. 130 | sort_by: The name of the field to sort by. 131 | ascending: Whether to sort ascending or descending. 132 | 133 | Returns: 134 | A list containing all matching objects. 135 | 136 | Raises: 137 | ValueError: The specified field doesn't exist in the table. 138 | 139 | """ 140 | rows = cls.table.get_list_by_field(conn, field, value, use_like, sort_by, ascending) 141 | return [cls.from_row(r) for r in rows] 142 | 143 | @classmethod 144 | def get_by_id(cls, conn: Connection | Cursor, article_id: int) -> Self | None: 145 | """Get an entry by its article ID. 146 | 147 | Args: 148 | conn: A connection to the database. 149 | article_id: The article ID to search for. 150 | 151 | Returns: 152 | The model matching the article ID if found. 153 | 154 | """ 155 | return cls.get_one_by_field(conn, "article_id", article_id) 156 | 157 | @classmethod 158 | def get_by_title(cls, conn: Connection | Cursor, title: str) -> Self | None: 159 | """Get an entry by its title. 160 | 161 | Args: 162 | conn: A connection to the database. 163 | title: The title of the article. 164 | 165 | Returns: 166 | The model matching the article title if found. 167 | 168 | """ 169 | return cls.get_one_by_field(conn, "title", title) 170 | -------------------------------------------------------------------------------- /tibiawikisql/models/charm.py: -------------------------------------------------------------------------------- 1 | from tibiawikisql.api import WikiEntry 2 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion 3 | from tibiawikisql.schema import CharmTable 4 | 5 | 6 | class Charm(WikiEntry, WithStatus, WithVersion, WithImage, RowModel, table=CharmTable): 7 | """Represents a charm.""" 8 | 9 | name: str 10 | """The name of the charm.""" 11 | type: str 12 | """The type of the charm.""" 13 | effect: str 14 | """The charm's description.""" 15 | cost: int 16 | """The number of charm points needed to unlock.""" 17 | -------------------------------------------------------------------------------- /tibiawikisql/models/house.py: -------------------------------------------------------------------------------- 1 | 2 | from tibiawikisql.models.base import RowModel, WithStatus, WithVersion 3 | from tibiawikisql.api import WikiEntry 4 | from tibiawikisql.schema import HouseTable 5 | 6 | 7 | class House(WikiEntry, WithVersion, WithStatus, RowModel, table=HouseTable): 8 | """Represents a house or guildhall.""" 9 | 10 | house_id: int 11 | """The house's id on tibia.com.""" 12 | name: str 13 | """The name of the house.""" 14 | is_guildhall: bool 15 | """Whether the house is a guildhall or not.""" 16 | city: str 17 | """The city where the house is located.""" 18 | street: str | None 19 | """The name of the street where the house is located.""" 20 | location: str | None 21 | """A brief description of where the house is.""" 22 | beds: int 23 | """The maximum number of beds the house can have.""" 24 | rent: int 25 | """The monthly rent of the house.""" 26 | size: int 27 | """The number of tiles (SQM) of the house.""" 28 | rooms: int | None 29 | """The number of rooms the house has.""" 30 | floors: int | None 31 | """The number of floors the house has.""" 32 | x: int | None 33 | """The x coordinate of the house.""" 34 | y: int | None 35 | """The y coordinate of the house.""" 36 | z: int | None 37 | """The z coordinate of the house.""" 38 | -------------------------------------------------------------------------------- /tibiawikisql/models/imbuement.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from sqlite3 import Connection, Cursor, IntegrityError 3 | from typing import Any 4 | 5 | from pydantic import BaseModel, Field 6 | from pypika import Parameter, Query 7 | from typing_extensions import Self 8 | 9 | from tibiawikisql.api import WikiEntry 10 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion 11 | from tibiawikisql.schema import ImbuementMaterialTable, ImbuementTable, ItemTable 12 | 13 | class Material(BaseModel): 14 | """A material needed to use this imbuement.""" 15 | 16 | item_id: int = 0 17 | """The article ID of the item material.""" 18 | item_title: str 19 | """The title of the item material.""" 20 | amount: int 21 | """The amount of items required.""" 22 | 23 | def insert(self, conn: Connection | Cursor, imbuement_id: int) -> None: 24 | item_table = ItemTable.__table__ 25 | imbuement_material_table = ImbuementMaterialTable.__table__ 26 | q = ( 27 | Query.into(imbuement_material_table) 28 | .columns( 29 | "imbuement_id", 30 | "item_id", 31 | "amount", 32 | ) 33 | .insert( 34 | Parameter(":imbuement_id"), 35 | ( 36 | Query.from_(item_table) 37 | .select(item_table.article_id) 38 | .where(item_table.title == Parameter(":item_title")) 39 | ), 40 | Parameter(":amount"), 41 | ) 42 | ) 43 | query_str = q.get_sql() 44 | with contextlib.suppress(IntegrityError): 45 | conn.execute(query_str, {"imbuement_id": imbuement_id} | self.model_dump(mode="json")) 46 | 47 | class ImbuementMaterial(RowModel, table=ImbuementMaterialTable): 48 | """Represents an item material for an imbuement.""" 49 | 50 | imbuement_id: int 51 | """The article id of the imbuement this material belongs to.""" 52 | imbuement_title: str | None = None 53 | """The title of the imbuement this material belongs to.""" 54 | item_id: int | None = None 55 | """The article id of the item material.""" 56 | item_title: str | None = None 57 | """The title of the item material.""" 58 | amount: int 59 | """The amount of items required.""" 60 | 61 | 62 | class Imbuement(WikiEntry, WithStatus, WithVersion, WithImage, RowModel, table=ImbuementTable): 63 | """Represents an imbuement type.""" 64 | 65 | name: str 66 | """The name of the imbuement.""" 67 | tier: str 68 | """The tier of the imbuement.""" 69 | type: str 70 | """The imbuement's type.""" 71 | category: str 72 | """The imbuement's category.""" 73 | effect: str 74 | """The effect given by the imbuement.""" 75 | slots: str 76 | """The type of items this imbuement may be applied on.""" 77 | materials: list[Material] = Field(default_factory=list) 78 | """The materials needed for the imbuement.""" 79 | 80 | def insert(self, conn): 81 | super().insert(conn) 82 | for material in self.materials: 83 | material.insert(conn, self.article_id) 84 | 85 | @classmethod 86 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None: 87 | imbuement: Self = super().get_one_by_field(conn, field, value, use_like) 88 | if imbuement is None: 89 | return None 90 | imbuement.materials = [Material(**dict(r)) for r in ImbuementMaterialTable.get_by_imbuement_id(conn, imbuement.article_id)] 91 | return imbuement 92 | -------------------------------------------------------------------------------- /tibiawikisql/models/mount.py: -------------------------------------------------------------------------------- 1 | 2 | from tibiawikisql.api import WikiEntry 3 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion 4 | from tibiawikisql.schema import MountTable 5 | 6 | 7 | class Mount(WikiEntry, WithStatus, WithVersion, WithImage, RowModel, table=MountTable): 8 | """Represents a Mount.""" 9 | 10 | name: str 11 | """The name of the mount.""" 12 | speed: int 13 | """The speed given by the mount.""" 14 | taming_method: str 15 | """A brief description on how the mount is obtained.""" 16 | is_buyable: bool 17 | """Whether the mount can be bought from the store or not.""" 18 | price: int | None 19 | """The price in Tibia coins to buy the mount.""" 20 | achievement: str | None 21 | """The achievement obtained for obtaining this mount.""" 22 | light_color: int | None 23 | """The color of the light emitted by this mount in RGB, if any.""" 24 | light_radius: int | None 25 | """The radius of the light emitted by this mount, if any.""" 26 | 27 | -------------------------------------------------------------------------------- /tibiawikisql/models/outfit.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from sqlite3 import Connection, Cursor, IntegrityError 3 | from typing import Any 4 | 5 | from pydantic import BaseModel, Field 6 | from pypika import Parameter, Query, Table 7 | from typing_extensions import Self 8 | 9 | from tibiawikisql.api import WikiEntry 10 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion 11 | from tibiawikisql.schema import OutfitImageTable, OutfitQuestTable, OutfitTable, QuestTable 12 | 13 | class UnlockQuest(BaseModel): 14 | """A quest that unlocks the outfit and/or its addons.""" 15 | 16 | quest_id: int = None 17 | """The article id of the quest that gives the outfit or its addons.""" 18 | quest_title: str 19 | """The title of the quest.""" 20 | unlock_type: str 21 | """Whether the quest is for the outfit or addons.""" 22 | 23 | def insert(self, conn: Connection | Cursor, outfit_id: int): 24 | quest_table = Table(QuestTable.__tablename__) 25 | oufit_quest_table = Table(OutfitQuestTable.__tablename__) 26 | q = ( 27 | Query.into(oufit_quest_table) 28 | .columns( 29 | "outfit_id", 30 | "quest_id", 31 | "unlock_type", 32 | ) 33 | .insert( 34 | Parameter(":outfit_id"), 35 | ( 36 | Query.from_(quest_table) 37 | .select(quest_table.article_id) 38 | .where(quest_table.title == Parameter(":quest_title")) 39 | ), 40 | Parameter(":unlock_type"), 41 | ) 42 | ) 43 | query_str = q.get_sql() 44 | with contextlib.suppress(IntegrityError): 45 | conn.execute(query_str, {"outfit_id": outfit_id} | self.model_dump(mode="json")) 46 | 47 | 48 | class OutfitQuest(RowModel, table=OutfitQuestTable): 49 | """Represents a quest that grants an outfit or it's addon.""" 50 | 51 | outfit_id: int 52 | """The article id of the outfit given.""" 53 | outfit_title: str | None = None 54 | """The title of the outfit given.""" 55 | quest_id: int | None = None 56 | """The article id of the quest that gives the outfit or its addons.""" 57 | quest_title: str 58 | """The title of the quest.""" 59 | unlock_type: str 60 | """Whether the quest is for the outfit or addons.""" 61 | 62 | 63 | class OutfitImage(RowModel, WithImage, table=OutfitImageTable): 64 | """Represents an outfit image.""" 65 | 66 | outfit_id: int 67 | """The article id of the outfit the image belongs to.""" 68 | outfit_name: str 69 | """The name of the outfit.""" 70 | sex: str 71 | """The sex the outfit is for.""" 72 | addon: int 73 | """The addons represented by the image. 74 | 0 for no addons, 1 for first addon, 2 for second addon and 3 for both addons.""" 75 | 76 | 77 | class Outfit(WikiEntry, WithStatus, WithVersion, RowModel, table=OutfitTable): 78 | """Represents an outfit.""" 79 | 80 | name: str 81 | """The name of the outfit.""" 82 | outfit_type: str 83 | """The type of outfit. Basic, Quest, Special, Premium.""" 84 | is_premium: bool 85 | """Whether the outfit requires a premium account or not.""" 86 | is_bought: bool 87 | """Whether the outfit can be bought from the Store or not.""" 88 | is_tournament: bool 89 | """Whether the outfit can be bought with Tournament coins or not.""" 90 | full_price: int | None 91 | """The full price of this outfit in the Tibia Store.""" 92 | achievement: str | None 93 | """The achievement obtained for acquiring this full outfit.""" 94 | images: list[OutfitImage] = Field(default_factory=list, exclude=True) 95 | """The outfit's images.""" 96 | quests: list[UnlockQuest] = Field(default_factory=list) 97 | """Quests that grant the outfit or its addons.""" 98 | 99 | def insert(self, conn: Connection | Cursor) -> None: 100 | super().insert(conn) 101 | for quest in self.quests: 102 | quest.insert(conn, self.article_id) 103 | 104 | @classmethod 105 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None: 106 | outfit: Self = super().get_one_by_field(conn, field, value, use_like) 107 | if outfit is None: 108 | return None 109 | outfit.quests = [UnlockQuest(**dict(r)) for r in OutfitQuestTable.get_list_by_outfit_id(conn, outfit.article_id)] 110 | return outfit 111 | 112 | -------------------------------------------------------------------------------- /tibiawikisql/models/quest.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import sqlite3 3 | from sqlite3 import Connection, Cursor 4 | from typing import Any 5 | 6 | from pydantic import BaseModel, Field 7 | from pypika import Parameter, Query 8 | from typing_extensions import Self 9 | 10 | from tibiawikisql.api import WikiEntry 11 | from tibiawikisql.models.base import RowModel, WithStatus, WithVersion 12 | from tibiawikisql.schema import ( 13 | CreatureTable, 14 | ItemTable, 15 | QuestDangerTable, 16 | QuestRewardTable, 17 | QuestTable, 18 | ) 19 | 20 | 21 | class ItemReward(BaseModel): 22 | """An item awarded in the quest.""" 23 | item_id: int = 0 24 | """The article id of the rewarded item.""" 25 | item_title: str 26 | """The title of the rewarded item.""" 27 | 28 | def insert(self, conn: Connection | Cursor, quest_id: int) -> None: 29 | quest_table = QuestRewardTable.__table__ 30 | item_table = ItemTable.__table__ 31 | q = ( 32 | Query.into(quest_table) 33 | .columns( 34 | "quest_id", 35 | "item_id", 36 | ) 37 | .insert( 38 | Parameter(":quest_id"), 39 | ( 40 | Query.from_(item_table) 41 | .select(item_table.article_id) 42 | .where(item_table.title == Parameter(":item_title")) 43 | ), 44 | ) 45 | ) 46 | 47 | query_str = q.get_sql() 48 | parameters = {"quest_id": quest_id} | self.model_dump() 49 | with contextlib.suppress(sqlite3.IntegrityError): 50 | conn.execute(query_str, parameters) 51 | 52 | class QuestReward(RowModel, table=QuestRewardTable): 53 | """Represents an item obtained in the quest.""" 54 | 55 | quest_id: int 56 | """The article id of the quest.""" 57 | quest_title: str 58 | """The title of the quest.""" 59 | item_id: int | None = None 60 | """The article id of the rewarded item.""" 61 | item_title: str | None = None 62 | """The title of the rewarded item.""" 63 | 64 | def insert(self, conn: sqlite3.Connection | sqlite3.Cursor) -> None: 65 | if self.item_id is not None: 66 | super().insert(conn) 67 | return 68 | quest_table = self.table.__table__ 69 | item_table = ItemTable.__table__ 70 | q = ( 71 | Query.into(quest_table) 72 | .columns( 73 | "quest_id", 74 | "item_id", 75 | ) 76 | .insert( 77 | Parameter(":quest_id"), 78 | ( 79 | Query.from_(item_table) 80 | .select(item_table.article_id) 81 | .where(item_table.title == Parameter(":item_title")) 82 | ), 83 | ) 84 | ) 85 | 86 | query_str = q.get_sql() 87 | with contextlib.suppress(sqlite3.IntegrityError): 88 | conn.execute(query_str, self.model_dump(mode="json")) 89 | 90 | 91 | class QuestCreature(BaseModel): 92 | """Represents a creature found in the quest.""" 93 | 94 | creature_id: int = 0 95 | """The article id of the found creature.""" 96 | creature_title: str 97 | """The title of the found creature.""" 98 | 99 | def insert(self, conn: Connection | Cursor, quest_id: int) -> None: 100 | quest_table = QuestDangerTable.__table__ 101 | creature_table = CreatureTable.__table__ 102 | q = ( 103 | Query.into(quest_table) 104 | .columns( 105 | "quest_id", 106 | "creature_id", 107 | ) 108 | .insert( 109 | Parameter(":quest_id"), 110 | ( 111 | Query.from_(creature_table) 112 | .select(creature_table.article_id) 113 | .where(creature_table.title == Parameter(":creature_title")) 114 | ), 115 | ) 116 | ) 117 | 118 | query_str = q.get_sql() 119 | parameters = {"quest_id": quest_id} | self.model_dump() 120 | with contextlib.suppress(sqlite3.IntegrityError): 121 | conn.execute(query_str, parameters) 122 | 123 | class QuestDanger(RowModel, table=QuestDangerTable): 124 | """Represents a creature found in the quest.""" 125 | 126 | quest_id: int 127 | """The article id of the quest.""" 128 | quest_title: str 129 | """The title of the quest.""" 130 | creature_id: int | None = None 131 | """The article id of the found creature.""" 132 | creature_title: str | None = None 133 | """The title of the found creature.""" 134 | 135 | def insert(self, conn: sqlite3.Connection | sqlite3.Cursor) -> None: 136 | if self.creature_id is not None: 137 | super().insert(conn) 138 | return 139 | quest_table = self.table.__table__ 140 | creature_table = CreatureTable.__table__ 141 | q = ( 142 | Query.into(quest_table) 143 | .columns( 144 | "quest_id", 145 | "creature_id", 146 | ) 147 | .insert( 148 | Parameter(":quest_id"), 149 | ( 150 | Query.from_(creature_table) 151 | .select(creature_table.article_id) 152 | .where(creature_table.title == Parameter(":creature_title")) 153 | ), 154 | ) 155 | ) 156 | 157 | query_str = q.get_sql() 158 | with contextlib.suppress(sqlite3.IntegrityError): 159 | conn.execute(query_str, self.model_dump(mode="json")) 160 | 161 | 162 | 163 | class Quest(WikiEntry, WithStatus, WithVersion, RowModel, table=QuestTable): 164 | """Represents a quest.""" 165 | 166 | name: str 167 | """The name of the quest.""" 168 | location: str | None 169 | """The location of the quest.""" 170 | is_rookgaard_quest: bool 171 | """Whether this quest is in Rookgaard or not.""" 172 | is_premium: bool 173 | """Whether this quest requires a Premium account or not.""" 174 | type: str | None 175 | """The type of quest.""" 176 | quest_log: bool | None 177 | """Whether this quest is registered in the quest log or not.""" 178 | legend: str | None 179 | """The legend of the quest.""" 180 | level_required: int | None 181 | """The level required to finish the quest.""" 182 | level_recommended: int | None 183 | """The recommended level to finish the quest.""" 184 | active_time: str | None 185 | """Times of the year when this quest is active.""" 186 | estimated_time: str | None 187 | """Estimated time to finish this quest.""" 188 | dangers: list[QuestCreature]= Field(default_factory=list) 189 | """Creatures found in the quest.""" 190 | rewards: list[ItemReward] = Field(default_factory=list) 191 | """Items rewarded in the quest.""" 192 | 193 | 194 | def insert(self, conn: sqlite3.Connection | sqlite3.Cursor) -> None: 195 | super().insert(conn) 196 | for reward in self.rewards: 197 | reward.insert(conn, self.article_id) 198 | for danger in self.dangers: 199 | danger.insert(conn, self.article_id) 200 | 201 | @classmethod 202 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None: 203 | quest: Self = super().get_one_by_field(conn, field, value, use_like) 204 | if quest is None: 205 | return None 206 | quest.rewards = [ItemReward(**dict(r)) for r in QuestRewardTable.get_list_by_quest_id(conn, quest.article_id)] 207 | quest.dangers = [QuestCreature(**dict(r)) for r in QuestDangerTable.get_list_by_quest_id(conn, quest.article_id)] 208 | return quest 209 | -------------------------------------------------------------------------------- /tibiawikisql/models/spell.py: -------------------------------------------------------------------------------- 1 | from sqlite3 import Connection, Cursor 2 | from typing import Any 3 | 4 | from pydantic import BaseModel, Field 5 | from typing_extensions import Self 6 | 7 | from tibiawikisql.api import WikiEntry 8 | from tibiawikisql.models.base import RowModel, WithImage, WithStatus, WithVersion 9 | from tibiawikisql.schema import NpcSpellTable, SpellTable 10 | 11 | 12 | class SpellTeacher(BaseModel): 13 | """An NPC that teaches the spell. 14 | 15 | Note that even if the spell can be learned by multiple vocations, an NPC might only teach it to a specific vocation. 16 | """ 17 | 18 | npc_id: int 19 | """The article ID of the NPC that teaches it.""" 20 | npc_title: str 21 | """The title of the NPC that teaches it.""" 22 | npc_city: str 23 | """The city where the NPC is located.""" 24 | knight: bool 25 | """If the NPC teaches the spell to knights.""" 26 | paladin: bool 27 | """If the NPC teaches the spell to paladins.""" 28 | druid: bool 29 | """If the NPC teaches the spell to druids.""" 30 | sorcerer: bool 31 | """If the NPC teaches the spell to sorcerers.""" 32 | monk: bool 33 | """If the NPC teaches the spell to monks.""" 34 | 35 | 36 | class Spell(WikiEntry, WithVersion, WithStatus, WithImage, RowModel, table=SpellTable): 37 | """Represents a Spell.""" 38 | 39 | name: str 40 | """The name of the spell.""" 41 | words: str | None = Field(None) 42 | """The spell's invocation words.""" 43 | effect: str 44 | """The effects of casting the spell.""" 45 | spell_type: str 46 | """The spell's type.""" 47 | group_spell: str 48 | """The spell's group.""" 49 | group_secondary: str | None = Field(None) 50 | """The spell's secondary group.""" 51 | group_rune: str | None = Field(None) 52 | """The group of the rune created by this spell.""" 53 | element: str | None = Field(None) 54 | """The element of the damage made by the spell.""" 55 | mana: int 56 | """The mana cost of the spell.""" 57 | soul: int 58 | """The soul cost of the spell.""" 59 | price: int | None = None 60 | """The gold cost of the spell.""" 61 | cooldown: int 62 | """The spell's individual cooldown in seconds.""" 63 | cooldown2: int | None 64 | """The spell's individual cooldown for the level 2 perk of the Wheel of Destiny.""" 65 | cooldown3: int | None 66 | """The spell's individual cooldown for the level 3 perk of the Wheel of Destiny.""" 67 | cooldown_group: int | None = Field(None) 68 | """The spell's group cooldown in seconds. The time you have to wait before casting another spell in the same group.""" 69 | cooldown_group_secondary: int | None = Field(None) 70 | """The spell's secondary group cooldown.""" 71 | level: int 72 | """The level required to use the spell.""" 73 | is_premium: bool 74 | """Whether the spell is premium only or not.""" 75 | is_promotion: bool 76 | """Whether you need to be promoted to buy or cast this spell.""" 77 | is_wheel_spell: bool 78 | """Whether this spell is acquired through the Wheel of Destiny.""" 79 | is_passive: bool 80 | """Whether this spell is triggered automatically without casting.""" 81 | knight: bool = Field(False) 82 | """Whether the spell can be used by knights or not.""" 83 | paladin: bool = Field(False) 84 | """Whether the spell can be used by paladins or not.""" 85 | druid: bool = Field(False) 86 | """Whether the spell can be used by druids or not.""" 87 | sorcerer: bool = Field(False) 88 | """Whether the spell can be used by sorcerers or not.""" 89 | monk: bool = Field(False) 90 | """Whether the spell can be used by monks or not.""" 91 | taught_by: list[SpellTeacher] = Field(default_factory=list) 92 | """NPCs that teach this spell.""" 93 | 94 | @classmethod 95 | def get_one_by_field(cls, conn: Connection | Cursor, field: str, value: Any, use_like: bool = False) -> Self | None: 96 | spell: Self = super().get_one_by_field(conn, field, value, use_like) 97 | if spell is None: 98 | return spell 99 | spell.taught_by = [SpellTeacher(**dict(r)) for r in NpcSpellTable.get_by_spell_id(conn, spell.article_id)] 100 | return spell 101 | -------------------------------------------------------------------------------- /tibiawikisql/models/update.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from tibiawikisql.api import WikiEntry 4 | from tibiawikisql.models.base import RowModel, WithVersion 5 | from tibiawikisql.schema import UpdateTable 6 | 7 | 8 | class Update(WikiEntry, WithVersion, RowModel, table=UpdateTable): 9 | """Represents a game update.""" 10 | 11 | name: str | None 12 | """The name of the update, if any.""" 13 | news_id: int | None 14 | """The id of the news article that announced the release.""" 15 | release_date: datetime.date 16 | """The date when the update was released.""" 17 | type_primary: str 18 | """The primary type of the update.""" 19 | type_secondary: str | None 20 | """The secondary type of the update.""" 21 | previous: str | None 22 | """The version before this update.""" 23 | next: str | None 24 | """The version after this update.""" 25 | summary: str | None 26 | """A brief summary of the update.""" 27 | changes: str | None 28 | """A brief list of the changes introduced.""" 29 | -------------------------------------------------------------------------------- /tibiawikisql/models/world.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from tibiawikisql.api import WikiEntry 4 | from tibiawikisql.models.base import RowModel 5 | from tibiawikisql.schema import WorldTable 6 | 7 | 8 | class World(WikiEntry, RowModel, table=WorldTable): 9 | """Represents a Game World.""" 10 | 11 | name: str 12 | """The name of the world.""" 13 | pvp_type: str 14 | """The world's PvP type.""" 15 | location: str 16 | """The world's server's physical location.""" 17 | is_preview: bool 18 | """Whether the world is a preview world or not.""" 19 | is_experimental: bool 20 | """Whether the world is a experimental world or not.""" 21 | online_since: datetime.date 22 | """Date when the world became online for the first time.""" 23 | offline_since: datetime.date | None 24 | """Date when the world went offline.""" 25 | merged_into: str | None 26 | """The name of the world this world got merged into, if applicable.""" 27 | battleye: bool 28 | """Whether the world is BattlEye protected or not.""" 29 | battleye_type: str | None 30 | """The type of BattlEye protection the world has. Can be either green or yellow.""" 31 | protected_since: datetime.date | None 32 | """Date when the world started being protected by BattlEye.""" 33 | world_board: int | None 34 | """The board ID for the world's board.""" 35 | trade_board: int | None 36 | """The board ID for the world's trade board.""" 37 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from tibiawikisql.parsers.base import BaseParser, AttributeParser 2 | from tibiawikisql.parsers.achievement import AchievementParser 3 | from tibiawikisql.parsers.charm import CharmParser 4 | from tibiawikisql.parsers.spell import SpellParser 5 | from tibiawikisql.parsers.item import ItemParser 6 | from tibiawikisql.parsers.creature import CreatureParser 7 | from tibiawikisql.parsers.book import BookParser 8 | from tibiawikisql.parsers.key import KeyParser 9 | from tibiawikisql.parsers.npc import NpcParser 10 | from tibiawikisql.parsers.imbuement import ImbuementParser 11 | from tibiawikisql.parsers.quest import QuestParser 12 | from tibiawikisql.parsers.house import HouseParser 13 | from tibiawikisql.parsers.outfit import OutfitParser 14 | from tibiawikisql.parsers.world import WorldParser 15 | from tibiawikisql.parsers.mount import MountParser 16 | from tibiawikisql.parsers.update import UpdateParser 17 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/achievement.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from tibiawikisql.models.achievement import Achievement 4 | from tibiawikisql.parsers.base import AttributeParser, BaseParser 5 | from tibiawikisql.schema import AchievementTable 6 | from tibiawikisql.utils import clean_links, parse_boolean, parse_integer 7 | 8 | 9 | class AchievementParser(BaseParser): 10 | """Parser for achievements.""" 11 | 12 | model = Achievement 13 | table = AchievementTable 14 | template_name = "Infobox_Achievement" 15 | attribute_map: ClassVar = { 16 | "name": AttributeParser(lambda x: x.get("actualname") or x.get("name")), 17 | "grade": AttributeParser.optional("grade", parse_integer), 18 | "points": AttributeParser.optional("points", parse_integer), 19 | "is_premium": AttributeParser.optional("premium", parse_boolean, False), 20 | "is_secret": AttributeParser.optional("secret", parse_boolean, False), 21 | "description": AttributeParser.required("description", clean_links), 22 | "spoiler": AttributeParser.optional("spoiler", clean_links), 23 | "achievement_id": AttributeParser.optional("achievementid", parse_integer), 24 | "version": AttributeParser.optional("implemented"), 25 | "status": AttributeParser.status(), 26 | } 27 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/base.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, ClassVar, Generic, TypeVar 3 | 4 | import pydantic 5 | from pydantic import ValidationError 6 | from typing_extensions import Self 7 | 8 | import tibiawikisql.database 9 | from tibiawikisql.api import Article 10 | from tibiawikisql.database import Table 11 | from tibiawikisql.errors import ( 12 | ArticleParsingError, 13 | AttributeParsingError, 14 | TemplateNotFoundError, 15 | ) 16 | from tibiawikisql.models.base import RowModel 17 | from tibiawikisql.utils import parse_templatates_data 18 | 19 | M = TypeVar("M", bound=RowModel) 20 | P = TypeVar("P", bound=pydantic.BaseModel) 21 | T = TypeVar("T") 22 | D = TypeVar("D") 23 | 24 | 25 | class AttributeParser(Generic[T]): 26 | """Defines how to parser an attribute from a Wiki article into a python object.""" 27 | 28 | def __init__(self, func: Callable[[dict[str, str]], T], fallback: D = ...) -> None: 29 | """Create an instance of the class. 30 | 31 | Args: 32 | func: A callable that takes the template's attributes as a parameter and returns a value. 33 | fallback: Fallback value to set if the value is not found or the callable failed. 34 | 35 | """ 36 | self.func = func 37 | self.fallback = fallback 38 | 39 | def __call__(self, attributes: dict[str, str]) -> T | D: 40 | """Perform parsing on the defined attribute. 41 | 42 | Args: 43 | attributes: The template attributes. 44 | 45 | Returns: 46 | The result of the parser's function or the fallback value if applicable. 47 | 48 | Raises: 49 | AttributeParsingError: If the parser function fails and no fallback was provided. 50 | """ 51 | try: 52 | return self.func(attributes) 53 | except Exception as e: 54 | if self.fallback is Ellipsis: 55 | raise AttributeParsingError(e) from e 56 | return self.fallback 57 | 58 | @classmethod 59 | def required(cls, field_name: str, post_process: Callable[[str], T] = str.strip) -> Self: 60 | """Define a required attribute. 61 | 62 | Args: 63 | field_name: The name of the template attribute in the wiki. 64 | post_process: A function to call on the attribute's value. 65 | 66 | Returns: 67 | An attribute parser expecting a required value. 68 | 69 | """ 70 | return cls(lambda x: post_process(x[field_name])) 71 | 72 | @classmethod 73 | def optional(cls, field_name: str, post_process: Callable[[str], T | None] = str.strip, default: T | None = None) -> Self: 74 | """Create optional attribute parser. Will fall back to None. 75 | 76 | Args: 77 | field_name: The name of the template attribute in the wiki. 78 | post_process: A function to call on the attribute's value. 79 | default: 80 | 81 | Returns: 82 | An attribute parser for an optional value. 83 | 84 | """ 85 | return cls(lambda x: post_process(x[field_name]), default) 86 | 87 | 88 | @classmethod 89 | def status(cls) -> Self: 90 | """Create a parser for the commonly found "status" parameter. 91 | 92 | Returns: 93 | An attribute parser for the status parameter, falling back to "active" if not found. 94 | 95 | """ 96 | return cls(lambda x: x.get("status").lower(), "active") 97 | 98 | @classmethod 99 | def version(cls) -> Self: 100 | """Create a parser for the commonly found "implemented" parameter. 101 | 102 | Returns: 103 | An attribute parser for the implemented parameter. 104 | 105 | """ 106 | return cls(lambda x: x.get("implemented").lower()) 107 | 108 | 109 | class ParserMeta(type): 110 | """Metaclass for all parsers.""" 111 | 112 | registry: ClassVar[dict[str, type["BaseParser"]]] = {} 113 | 114 | def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> type: 115 | cls = super().__new__(mcs, name, bases, namespace) 116 | 117 | if name == "BaseParser": 118 | return cls 119 | required_attrs = ( 120 | ("template_name", str, False), 121 | ("attribute_map", dict, False), 122 | ("model", RowModel, True), 123 | ("table", tibiawikisql.database.Table, True), 124 | ) 125 | for attr, expected_type, is_class in required_attrs: 126 | value = getattr(cls, attr, NotImplemented) 127 | if value is NotImplemented: 128 | msg = f"{name} must define `{attr}`" 129 | raise NotImplementedError(msg) 130 | if is_class: 131 | if not isinstance(value, type) or not issubclass(value, expected_type): 132 | msg = f"{name}.{attr} must be a subclass of {expected_type.__name__}" 133 | raise TypeError(msg) 134 | elif not isinstance(value, expected_type): 135 | msg = f"{name}.{attr} must be of type {expected_type.__name__}" 136 | raise TypeError(msg) 137 | 138 | template_name = getattr(cls, "template_name") # noqa: B009 139 | if not isinstance(template_name, str) or not template_name: 140 | msg = f"{name} must define a non-empty string for `template_name`." 141 | raise ValueError(msg) 142 | 143 | # Register the parser class 144 | if template_name in ParserMeta.registry: 145 | msg = f"Duplicate parser for template '{template_name}'." 146 | raise ValueError(msg) 147 | ParserMeta.registry[template_name] = cls 148 | return cls 149 | 150 | 151 | class BaseParser(metaclass=ParserMeta): 152 | """Base class that defines how to extract information from a Wiki template into a model.""" 153 | 154 | template_name: ClassVar[str] = NotImplemented 155 | """The name of the template that contains the information.""" 156 | 157 | model: ClassVar[type[RowModel]] = NotImplemented 158 | """The model to convert the data into.""" 159 | 160 | table: ClassVar[type[Table]] = NotImplemented 161 | """The SQL table where the data wil be stored.""" 162 | 163 | attribute_map: ClassVar[dict[str, AttributeParser]] = NotImplemented 164 | """A map defining how to process every template attribute.""" 165 | 166 | 167 | @classmethod 168 | def parse_attributes(cls, article: Article) -> dict[str, Any]: 169 | """Parse the attributes of an article into a mapping. 170 | 171 | By default, it will apply the attribute map, but it can be overridden to parse attributes in more complex ways. 172 | It is called by `parse_article`. 173 | 174 | Args: 175 | article: The article to extract the data from. 176 | 177 | Returns: 178 | A dictionary containing the parsed attribute values. 179 | 180 | Raises: 181 | AttributeParsingError: If the required template is not found. 182 | 183 | """ 184 | templates = parse_templatates_data(article.content) 185 | if cls.template_name not in templates: 186 | raise TemplateNotFoundError(article, cls) 187 | attributes = templates[cls.template_name] 188 | row = { 189 | "article_id": article.article_id, 190 | "timestamp": article.timestamp, 191 | "title": article.title, 192 | "_raw_attributes": attributes, 193 | } 194 | try: 195 | for field, parser in cls.attribute_map.items(): 196 | row[field] = parser(attributes) 197 | except AttributeParsingError as e: 198 | raise ArticleParsingError(article, e) from e 199 | return row 200 | 201 | @classmethod 202 | def from_article(cls, article: Article) -> M: 203 | """Parse an article into a TibiaWiki model. 204 | 205 | Args: 206 | article: The article from where the model is parsed. 207 | 208 | Returns: 209 | An inherited model object for the current article. 210 | 211 | """ 212 | row = cls.parse_attributes(article) 213 | try: 214 | return cls.model.model_validate(row) 215 | except ValidationError as e: 216 | raise ArticleParsingError(article, cause=e) from e 217 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/book.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from tibiawikisql.models.item import Book 4 | from tibiawikisql.parsers import BaseParser 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.schema import BookTable 7 | from tibiawikisql.utils import clean_links 8 | 9 | 10 | class BookParser(BaseParser): 11 | """Parser for book articles.""" 12 | 13 | model = Book 14 | table = BookTable 15 | template_name = "Infobox_Book" 16 | attribute_map: ClassVar = { 17 | "name": AttributeParser.required("title"), 18 | "book_type": AttributeParser.optional("booktype", clean_links), 19 | "location": AttributeParser.optional("location", lambda x: clean_links(x, True)), 20 | "blurb": AttributeParser.optional("blurb", lambda x: clean_links(x, True)), 21 | "author": AttributeParser.optional("author", lambda x: clean_links(x, True)), 22 | "prev_book": AttributeParser.optional("prevbook"), 23 | "next_book": AttributeParser.optional("nextbook"), 24 | "text": AttributeParser.required("text", clean_links), 25 | "version": AttributeParser.optional("implemented"), 26 | "status": AttributeParser.status(), 27 | } 28 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/charm.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from tibiawikisql.schema import CharmTable 4 | from tibiawikisql.models.charm import Charm 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.utils import clean_links, parse_integer 8 | 9 | 10 | class CharmParser(BaseParser): 11 | """Parser for charms.""" 12 | model = Charm 13 | table = CharmTable 14 | template_name = "Infobox_Charm" 15 | attribute_map: ClassVar = { 16 | "name": AttributeParser(lambda x: x.get("actualname") or x.get("name")), 17 | "type": AttributeParser(lambda x: x.get("type")), 18 | "effect": AttributeParser(lambda x: clean_links(x.get("effect"))), 19 | "cost": AttributeParser(lambda x: parse_integer(x.get("cost"))), 20 | "version": AttributeParser(lambda x: x.get("implemented"), None), 21 | "status": AttributeParser.status(), 22 | } 23 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/house.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import tibiawikisql.schema 4 | from tibiawikisql.models.house import House 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.utils import clean_links, convert_tibiawiki_position, parse_integer 8 | 9 | 10 | class HouseParser(BaseParser): 11 | """Parses houses and guildhalls.""" 12 | model = House 13 | table = tibiawikisql.schema.HouseTable 14 | template_name = "Infobox_Building" 15 | attribute_map: ClassVar = { 16 | "house_id": AttributeParser.required("houseid", parse_integer), 17 | "name": AttributeParser.required("name"), 18 | "is_guildhall": AttributeParser.required("type", lambda x: x is not None and "guildhall" in x.lower()), 19 | "city": AttributeParser.required("city"), 20 | "street": AttributeParser.optional("street"), 21 | "location": AttributeParser.optional("location", clean_links), 22 | "beds": AttributeParser.required("beds", parse_integer), 23 | "rent": AttributeParser.required("rent", parse_integer), 24 | "size": AttributeParser.required("size", parse_integer), 25 | "rooms": AttributeParser.optional("rooms", parse_integer), 26 | "floors": AttributeParser.optional("floors", parse_integer), 27 | "x": AttributeParser.optional("posx", convert_tibiawiki_position), 28 | "y": AttributeParser.optional("posy", convert_tibiawiki_position), 29 | "z": AttributeParser.optional("posz", int), 30 | "version": AttributeParser.version(), 31 | "status": AttributeParser.status(), 32 | } 33 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/imbuement.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, ClassVar 3 | 4 | from tibiawikisql.api import Article 5 | from tibiawikisql.models.imbuement import Imbuement, Material 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.schema import ImbuementTable 8 | from tibiawikisql.parsers.base import AttributeParser 9 | 10 | astral_pattern = re.compile(r"\s*([^:]+):\s*(\d+),*") 11 | effect_pattern = re.compile(r"Effect/([^|]+)\|([^}|]+)") 12 | 13 | 14 | def parse_astral_sources(content: str) -> dict[str, int]: 15 | """Parse the astral sources of an imbuement. 16 | 17 | Args: 18 | content: A string containing astral sources. 19 | 20 | Returns: 21 | A dictionary containing the material name and te amount required. 22 | 23 | """ 24 | materials = astral_pattern.findall(content) 25 | if materials: 26 | return {item: int(amount) for (item, amount) in materials} 27 | return {} 28 | 29 | 30 | effect_map = { 31 | "Bash": "Club fighting +{}", 32 | "Punch": "Fist fighting +{}", 33 | "Chop": "Axe fighting +{}", 34 | "Slash": "Sword fighting +{}", 35 | "Precision": "Distance fighting +{}", 36 | "Blockade": "Shielding +{}", 37 | "Epiphany": "Magic level +{}", 38 | "Scorch": "Fire damage {}", 39 | "Venom": "Earth damage {}", 40 | "Frost": "Ice damage {}", 41 | "Electrify": "Energy damage {}", 42 | "Reap": "Death damage {}", 43 | "Vampirism": "Life leech {}", 44 | "Void": " Mana leech {}", 45 | "Strike": " Critical hit damage {}", 46 | "Lich Shroud": "Death protection {}", 47 | "Snake Skin": "Earth protection {}", 48 | "Quara Scale": "Ice protection {}", 49 | "Dragon Hide": "Fire protection {}", 50 | "Cloud Fabric": "Energy protection {}", 51 | "Demon Presence": "Holy protection {}", 52 | "Swiftness": "Speed +{}", 53 | "Featherweight": "Capacity +{}", 54 | "Vibrancy": "Remove paralysis chance {}", 55 | } 56 | 57 | 58 | def parse_effect(effect: str) -> str: 59 | """Parse TibiaWiki's effect template into a string effect. 60 | 61 | Args: 62 | effect: The string containing the template. 63 | 64 | Returns: 65 | The effect string. 66 | 67 | """ 68 | m = effect_pattern.search(effect) 69 | category, amount = m.groups() 70 | try: 71 | return effect_map[category].format(amount) 72 | except KeyError: 73 | return f"{category} {amount}" 74 | 75 | 76 | def parse_slots(content: str) -> str: 77 | """Parse the list of slots. 78 | 79 | Cleans up spaces between items. 80 | 81 | Args: 82 | content: A string containing comma separated values. 83 | 84 | Returns: 85 | The slots string. 86 | 87 | """ 88 | slots = content.split(",") 89 | return ",".join(s.strip() for s in slots) 90 | 91 | 92 | class ImbuementParser(BaseParser): 93 | """Parses imbuements.""" 94 | model = Imbuement 95 | table = ImbuementTable 96 | template_name = "Infobox_Imbuement" 97 | attribute_map: ClassVar = { 98 | "name": AttributeParser.required("name"), 99 | "tier": AttributeParser.required("prefix"), 100 | "type": AttributeParser.required("type"), 101 | "category": AttributeParser.required("category"), 102 | "effect": AttributeParser.required("effect", parse_effect), 103 | "version": AttributeParser.required("implemented"), 104 | "slots": AttributeParser.required("slots", parse_slots), 105 | "status": AttributeParser.status(), 106 | } 107 | 108 | @classmethod 109 | def parse_attributes(cls, article: Article) -> dict[str, Any]: 110 | row = super().parse_attributes(article) 111 | if not row: 112 | return row 113 | raw_attributes = row["_raw_attributes"] 114 | if "astralsources" in raw_attributes: 115 | materials = parse_astral_sources(raw_attributes["astralsources"]) 116 | row["materials"] = [Material(item_title=name, amount=amount) for name, amount in materials.items()] 117 | return row 118 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/item.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, ClassVar 3 | 4 | from tibiawikisql.api import Article 5 | from tibiawikisql.models.item import Item, ItemAttribute, ItemStoreOffer 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.parsers.base import AttributeParser 8 | from tibiawikisql.schema import ItemTable 9 | from tibiawikisql.utils import ( 10 | clean_links, 11 | clean_question_mark, 12 | client_color_to_rgb, 13 | find_templates, 14 | parse_boolean, 15 | parse_float, 16 | parse_integer, 17 | parse_sounds, 18 | strip_code, 19 | ) 20 | 21 | 22 | class ItemParser(BaseParser): 23 | """Parses items and objects.""" 24 | model = Item 25 | table = ItemTable 26 | template_name = "Infobox_Object" 27 | attribute_map: ClassVar = { 28 | "name": AttributeParser.required("name"), 29 | "actual_name": AttributeParser.optional("actualname"), 30 | "plural": AttributeParser.optional("plural", clean_question_mark), 31 | "article": AttributeParser.optional("article"), 32 | "is_marketable": AttributeParser.optional("marketable", parse_boolean, False), 33 | "is_stackable": AttributeParser.optional("stackable", parse_boolean, False), 34 | "is_pickupable": AttributeParser.optional("pickupable", parse_boolean, False), 35 | "is_immobile": AttributeParser.optional("immobile", parse_boolean, False), 36 | "value_sell": AttributeParser.optional("npcvalue", parse_integer), 37 | "value_buy": AttributeParser.optional("npcprice", parse_integer), 38 | "weight": AttributeParser.optional("weight", parse_float), 39 | "flavor_text": AttributeParser.optional("flavortext"), 40 | "item_class": AttributeParser.optional("objectclass"), 41 | "item_type": AttributeParser.optional("primarytype"), 42 | "type_secondary": AttributeParser.optional("secondarytype"), 43 | "light_color": AttributeParser.optional("lightcolor", lambda x: client_color_to_rgb(parse_integer(x))), 44 | "light_radius": AttributeParser.optional("lightradius", parse_integer), 45 | "version": AttributeParser.optional("implemented"), 46 | "client_id": AttributeParser.optional("itemid", parse_integer), 47 | "status": AttributeParser.status(), 48 | } 49 | 50 | item_attributes: ClassVar = { 51 | "level": "levelrequired", 52 | "vocation": "vocrequired", 53 | "attack": "attack", 54 | "defense": "defense", 55 | "defense_modifier": "defensemod", 56 | "armor": "armor", 57 | "hands": "hands", 58 | "imbue_slots": "imbueslots", 59 | "imbuements": "imbuements", 60 | "attack+": "atk_mod", 61 | "hit%+": "hit_mod", 62 | "range": "range", 63 | "damage_type": "damagetype", 64 | "damage_range": "damagerange", 65 | "mana_cost": "manacost", 66 | "magic_level": "mlrequired", 67 | "words": "words", 68 | "critical_chance": "crithit_ch", 69 | "critical%": "critextra_dmg", 70 | "hpleech_chance": "hpleech_ch", 71 | "hpleech%": "hpleech_am", 72 | "manaleech_chance": "manaleech_ch", 73 | "manaleech%": "manaleech_am", 74 | "volume": "volume", 75 | "charges": "charges", 76 | "food_time": "regenseconds", 77 | "duration": "duration", 78 | "fire_attack": "fire_attack", 79 | "energy_attack": "energy_attack", 80 | "ice_attack": "ice_attack", 81 | "earth_attack": "earth_attack", 82 | "weapon_type": "weapontype", 83 | "destructible": "destructible", 84 | "holds_liquid": "holdsliquid", 85 | "is_hangable": "hangable", 86 | "is_writable": "writable", 87 | "is_rewritable": "rewritable", 88 | "writable_chars": "writechars", 89 | "is_consumable": "consumable", 90 | "fansite": "fansite", 91 | "blocks_projectiles": "unshootable", 92 | "blocks_path": "blockspath", 93 | "is_walkable": "walkable", 94 | "tile_friction": "walkingspeed", 95 | "map_color": "mapcolor", 96 | "upgrade_classification": "upgradeclass", 97 | "is_rotatable": "rotatable", 98 | "augments": "augments", 99 | "elemental_bond": "elementalbond", 100 | } 101 | 102 | @classmethod 103 | def parse_attributes(cls, article: Article) -> dict[str, Any]: 104 | row = super().parse_attributes(article) 105 | row["attributes"] = [] 106 | for name, attribute in cls.item_attributes.items(): 107 | if attribute in row["_raw_attributes"] and row["_raw_attributes"][attribute]: 108 | row["attributes"].append(ItemAttribute( 109 | name=name, 110 | value=clean_links(row["_raw_attributes"][attribute]), 111 | )) 112 | cls.parse_item_attributes(row) 113 | cls.parse_resistances(row) 114 | cls.parse_sounds(row) 115 | cls.parse_store_value(row) 116 | return row 117 | 118 | @classmethod 119 | def parse_item_attributes(cls, row: dict[str, Any]): 120 | raw_attributes = row["_raw_attributes"] 121 | attributes = row["attributes"] 122 | if "attrib" not in raw_attributes: 123 | return 124 | attribs = raw_attributes["attrib"].split(",") 125 | for attr in attribs: 126 | attr = attr.strip() 127 | m = re.search(r"([\s\w]+)\s([+\-\d]+)", attr) 128 | if "perfect shot" in attr.lower(): 129 | numbers = re.findall(r"(\d+)", attr) 130 | if len(numbers) == 2: 131 | attributes.extend([ 132 | ItemAttribute(name="perfect_shot", value=f"+{numbers[0]}"), 133 | ItemAttribute(name="perfect_shot_range", value=numbers[1]), 134 | ]) 135 | continue 136 | if "damage reflection" in attr.lower(): 137 | value = parse_integer(attr) 138 | attributes.append(ItemAttribute(name="damage_reflection", value=str(value))) 139 | if "damage reflection" in attr.lower(): 140 | value = parse_integer(attr) 141 | attributes.append(ItemAttribute(name="damage_reflection", value=str(value))) 142 | if "magic shield capacity" in attr.lower(): 143 | numbers = re.findall(r"(\d+)", attr) 144 | if len(numbers) == 2: 145 | attributes.extend([ 146 | ItemAttribute(name="magic_shield_capacity", value=f"+{numbers[0]}"), 147 | ItemAttribute(name="magic_shield_capacity%", value=f"{numbers[1]}%"), 148 | ]) 149 | continue 150 | if m: 151 | attribute = m.group(1).replace("fighting", "").replace("level", "").strip().replace(" ", "_").lower() 152 | value = m.group(2) 153 | attributes.append(ItemAttribute(name=attribute.lower(), value=value)) 154 | if "regeneration" in attr: 155 | attributes.append(ItemAttribute(name="regeneration", value="faster regeneration")) 156 | 157 | @classmethod 158 | def parse_resistances(cls, row): 159 | raw_attributes = row["_raw_attributes"] 160 | attributes = row["attributes"] 161 | if "resist" not in raw_attributes: 162 | return 163 | resistances = raw_attributes["resist"].split(",") 164 | for element in resistances: 165 | element = element.strip() 166 | m = re.search(r"([a-zA-Z0-9_ ]+) +(-?\+?\d+)%", element) 167 | if not m: 168 | continue 169 | attribute = m.group(1) + "%" 170 | try: 171 | value = int(m.group(2)) 172 | except ValueError: 173 | value = 0 174 | attributes.append(ItemAttribute(name=attribute, value=str(value))) 175 | 176 | @classmethod 177 | def parse_sounds(cls, row): 178 | if "sounds" not in row["_raw_attributes"]: 179 | return 180 | row["sounds"] = parse_sounds(row["_raw_attributes"]["sounds"]) 181 | 182 | @classmethod 183 | def parse_store_value(self, row): 184 | if "storevalue" not in row["_raw_attributes"]: 185 | return 186 | templates = find_templates(row["_raw_attributes"]["storevalue"], "Store Product", recursive=True) 187 | row["store_offers"] = [] 188 | for template in templates: 189 | price = int(strip_code(template.get(1, 0))) 190 | currency = strip_code(template.get(2, "Tibia Coin")) 191 | amount = int(strip_code(template.get("amount", 1))) 192 | row["store_offers"].append( 193 | ItemStoreOffer(price=price, currency=currency, amount=amount), 194 | ) 195 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/key.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import tibiawikisql.schema 4 | from tibiawikisql.models.item import Key 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.utils import clean_links, parse_integer 8 | 9 | 10 | class KeyParser(BaseParser): 11 | """Parser for keys.""" 12 | model = Key 13 | table = tibiawikisql.schema.ItemKeyTable 14 | template_name = "Infobox_Key" 15 | attribute_map: ClassVar = { 16 | "name": AttributeParser.optional("aka", clean_links), 17 | "number": AttributeParser.optional("number", parse_integer), 18 | "material": AttributeParser.optional("primarytype"), 19 | "location": AttributeParser.optional("location", clean_links), 20 | "notes": AttributeParser.optional("shortnotes", clean_links), 21 | "origin": AttributeParser.optional("origin", clean_links), 22 | "status": AttributeParser.status(), 23 | "version": AttributeParser.optional("implemented", clean_links), 24 | } 25 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/mount.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import tibiawikisql.schema 4 | from tibiawikisql.models.mount import Mount 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.utils import clean_links, client_color_to_rgb, parse_boolean, parse_integer 8 | 9 | 10 | def remove_mount(name: str) -> str: 11 | """Remove "(Mount)" from the name, if found. 12 | 13 | Args: 14 | name: The name to check. 15 | 16 | Returns: 17 | The name with "(Mount)" removed from it. 18 | 19 | """ 20 | return name.replace("(Mount)", "").strip() 21 | 22 | class MountParser(BaseParser): 23 | """Parser for mounts.""" 24 | 25 | model = Mount 26 | table = tibiawikisql.schema.MountTable 27 | template_name = "Infobox_Mount" 28 | attribute_map: ClassVar = { 29 | "name": AttributeParser.required("name", remove_mount), 30 | "speed": AttributeParser.required("speed", int), 31 | "taming_method": AttributeParser.required("taming_method", clean_links), 32 | "is_buyable": AttributeParser.optional("bought", parse_boolean, False), 33 | "price": AttributeParser.optional("price", parse_integer), 34 | "achievement": AttributeParser.optional("achievement"), 35 | "light_color": AttributeParser.optional("lightcolor", lambda x: client_color_to_rgb(parse_integer(x))), 36 | "light_radius": AttributeParser.optional("lightradius", int), 37 | "version": AttributeParser.version(), 38 | "status": AttributeParser.status(), 39 | } 40 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/npc.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | import tibiawikisql.schema 4 | from tibiawikisql.api import Article 5 | from tibiawikisql.models.npc import Npc, NpcDestination 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.parsers.base import AttributeParser 8 | from tibiawikisql.utils import clean_links, convert_tibiawiki_position, find_template, strip_code 9 | 10 | 11 | class NpcParser(BaseParser): 12 | """Parser for NPCs.""" 13 | 14 | table = tibiawikisql.schema.NpcTable 15 | model = Npc 16 | template_name = "Infobox_NPC" 17 | attribute_map: ClassVar = { 18 | "name": AttributeParser(lambda x: x.get("actualname") or x.get("name")), 19 | "gender": AttributeParser.optional("gender"), 20 | "location": AttributeParser.optional("location", clean_links), 21 | "subarea": AttributeParser.optional("subarea"), 22 | "city": AttributeParser.required("city"), 23 | "x": AttributeParser.optional("posx", convert_tibiawiki_position), 24 | "y": AttributeParser.optional("posy", convert_tibiawiki_position), 25 | "z": AttributeParser.optional("posz", int), 26 | "version": AttributeParser.optional("implemented"), 27 | "status": AttributeParser.status(), 28 | } 29 | 30 | @classmethod 31 | def parse_attributes(cls, article: Article) -> dict[str, Any]: 32 | row = super().parse_attributes(article) 33 | raw_attributes = row["_raw_attributes"] 34 | cls._parse_jobs(row) 35 | cls._parse_races(row) 36 | 37 | row["destinations"] = [] 38 | destinations = [] 39 | if "notes" in raw_attributes and "{{Transport" in raw_attributes["notes"]: 40 | destinations.extend(cls._parse_destinations(raw_attributes["notes"])) 41 | for destination, price, notes in destinations: 42 | name = destination.strip() 43 | clean_notes = clean_links(notes.strip()) 44 | if not notes: 45 | clean_notes = None 46 | row["destinations"].append(NpcDestination( 47 | name=name, 48 | price=price, 49 | notes=clean_notes, 50 | )) 51 | return row 52 | 53 | 54 | # region Auxiliary Methods 55 | 56 | @classmethod 57 | def _parse_jobs(cls, row: dict[str, Any]) -> None: 58 | """Read the possible multiple job parameters of an NPC's page and put them together in a list.""" 59 | raw_attributes = row["_raw_attributes"] 60 | row["jobs"] = [ 61 | clean_links(value) 62 | for key, value in raw_attributes.items() 63 | if key.startswith("job") 64 | ] 65 | 66 | @classmethod 67 | def _parse_races(cls, row: dict[str, Any]) -> None: 68 | """Read the possible multiple race parameters of an NPC's page and put them together in a list.""" 69 | raw_attributes = row["_raw_attributes"] 70 | row["races"] = [ 71 | clean_links(value) 72 | for key, value in raw_attributes.items() 73 | if key.startswith("race") 74 | ] 75 | 76 | 77 | @classmethod 78 | def _parse_destinations(cls, value: str) -> list[tuple[str, int, str]]: 79 | """Parse an NPC destinations into a list of tuples. 80 | 81 | The tuple contains the destination's name, price and notes. 82 | Price and notes may not be present. 83 | 84 | Args: 85 | value: A string containing the Transport template with destinations. 86 | 87 | Returns: 88 | A list of tuples, where each element is the name of the destination, the price and additional notes. 89 | """ 90 | template = find_template(value, "Transport", partial=True) 91 | if not template: 92 | return [] 93 | result = [] 94 | for param in template.params: 95 | if param.showkey: 96 | continue 97 | data, *notes = strip_code(param).split(";", 1) 98 | notes = notes[0] if notes else "" 99 | destination, price_str = data.split(",") 100 | try: 101 | price = int(price_str) 102 | except ValueError: 103 | price = 0 104 | result.append((destination, price, notes)) 105 | return result 106 | 107 | # endregion 108 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/outfit.py: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar 2 | 3 | import tibiawikisql.schema 4 | from tibiawikisql.api import Article 5 | from tibiawikisql.models.outfit import Outfit, UnlockQuest 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.parsers.base import AttributeParser 8 | from tibiawikisql.parsers.quest import parse_links 9 | from tibiawikisql.utils import parse_boolean, parse_integer 10 | 11 | 12 | class OutfitParser(BaseParser): 13 | """Parser for outfits.""" 14 | 15 | model = Outfit 16 | table = tibiawikisql.schema.OutfitTable 17 | template_name = "Infobox_Outfit" 18 | attribute_map: ClassVar = { 19 | "name": AttributeParser.required("name"), 20 | "outfit_type": AttributeParser.required("primarytype"), 21 | "is_premium": AttributeParser.optional("premium", parse_boolean, False), 22 | "is_tournament": AttributeParser.optional("tournament", parse_boolean, False), 23 | "is_bought": AttributeParser.optional("bought", parse_boolean, False), 24 | "full_price": AttributeParser.optional("fulloutfitprice", parse_integer), 25 | "achievement": AttributeParser.optional("achievement"), 26 | "status": AttributeParser.status(), 27 | "version": AttributeParser.version(), 28 | } 29 | 30 | @classmethod 31 | def parse_attributes(cls, article: Article) -> dict[str, Any]: 32 | row = super().parse_attributes(article) 33 | if not row: 34 | return row 35 | raw_attributes = row["_raw_attributes"] 36 | row["quests"] = [] 37 | if "outfit" in raw_attributes: 38 | quests = parse_links(raw_attributes["outfit"]) 39 | for quest in quests: 40 | row["quests"].append(UnlockQuest( 41 | quest_title=quest.strip(), 42 | unlock_type="outfit", 43 | )) 44 | if "addons" in raw_attributes: 45 | quests = parse_links(raw_attributes["addons"]) 46 | for quest in quests: 47 | row["quests"].append(UnlockQuest( 48 | quest_title=quest.strip(), 49 | unlock_type="addons", 50 | )) 51 | return row 52 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/quest.py: -------------------------------------------------------------------------------- 1 | import html 2 | import re 3 | from typing import Any, ClassVar 4 | 5 | from tibiawikisql.api import Article 6 | from tibiawikisql.models.quest import ItemReward, Quest, QuestCreature 7 | from tibiawikisql.parsers.base import AttributeParser 8 | from tibiawikisql.parsers import BaseParser 9 | from tibiawikisql.schema import QuestTable 10 | from tibiawikisql.utils import clean_links, parse_boolean, parse_integer 11 | 12 | link_pattern = re.compile(r"\[\[([^|\]]+)") 13 | 14 | 15 | def parse_links(value: str) -> list[str]: 16 | """Find all the links in a string and returns a list of them. 17 | 18 | Args: 19 | value: A string containing links. 20 | 21 | Returns: 22 | The links found in the string. 23 | 24 | """ 25 | return list(link_pattern.findall(value)) 26 | 27 | 28 | class QuestParser(BaseParser): 29 | """Parser for quests.""" 30 | 31 | model = Quest 32 | table = QuestTable 33 | template_name = "Infobox_Quest" 34 | attribute_map: ClassVar = { 35 | "name": AttributeParser.required("name", html.unescape), 36 | "location": AttributeParser.optional("location", clean_links), 37 | "is_rookgaard_quest": AttributeParser.optional("rookgaardquest", parse_boolean, False), 38 | "type": AttributeParser.optional("type"), 39 | "quest_log": AttributeParser.optional("log", parse_boolean), 40 | "legend": AttributeParser.optional("legend", clean_links), 41 | "level_required": AttributeParser.optional("lvl", parse_integer), 42 | "level_recommended": AttributeParser.optional("lvlrec", parse_integer), 43 | "active_time": AttributeParser.optional("time"), 44 | "estimated_time": AttributeParser.optional("timealloc"), 45 | "is_premium": AttributeParser.required("premium", parse_boolean), 46 | "version": AttributeParser.optional("implemented"), 47 | "status": AttributeParser.status(), 48 | } 49 | 50 | @classmethod 51 | def parse_attributes(cls, article: Article) -> dict[str, Any]: 52 | row = super().parse_attributes(article) 53 | if not row: 54 | return row 55 | cls._parse_quest_rewards(row) 56 | cls._parse_quest_dangers(row) 57 | return row 58 | 59 | # region Auxiliary Functions 60 | 61 | @classmethod 62 | def _parse_quest_rewards(cls, row: dict[str, Any]) -> None: 63 | raw_attributes = row["_raw_attributes"] 64 | if not raw_attributes.get("reward"): 65 | return 66 | rewards = parse_links(raw_attributes["reward"]) 67 | row["rewards"] = [ItemReward( 68 | item_title=reward.strip(), 69 | ) for reward in rewards] 70 | 71 | @classmethod 72 | def _parse_quest_dangers(cls, row: dict[str, Any]) -> None: 73 | raw_attributes = row["_raw_attributes"] 74 | if not raw_attributes.get("dangers"): 75 | return 76 | dangers = parse_links(raw_attributes["dangers"]) 77 | row["dangers"] = [QuestCreature(creature_title=danger.strip()) for danger in dangers] 78 | 79 | # endregion 80 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/spell.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from tibiawikisql.api import Article 4 | from tibiawikisql.models.spell import Spell 5 | import tibiawikisql.schema 6 | from tibiawikisql.parsers.base import AttributeParser 7 | from tibiawikisql.parsers import BaseParser 8 | from tibiawikisql.utils import clean_links, parse_boolean, parse_integer 9 | 10 | 11 | class SpellParser(BaseParser): 12 | """Parser for spells.""" 13 | 14 | model = Spell 15 | table = tibiawikisql.schema.SpellTable 16 | template_name = "Infobox_Spell" 17 | attribute_map: ClassVar = { 18 | "name": AttributeParser.required("name"), 19 | "effect": AttributeParser.required("effect", clean_links), 20 | "words": AttributeParser.optional("words"), 21 | "spell_type": AttributeParser.required("type"), 22 | "group_spell": AttributeParser.required("subclass"), 23 | "group_secondary": AttributeParser.optional("secondarygroup"), 24 | "group_rune": AttributeParser.optional("runegroup"), 25 | "element": AttributeParser.optional("damagetype"), 26 | "mana": AttributeParser.optional("mana", parse_integer), 27 | "soul": AttributeParser.optional("soul", parse_integer, 0), 28 | "price": AttributeParser.optional("spellcost", parse_integer), 29 | "cooldown": AttributeParser.required("cooldown"), 30 | "cooldown2": AttributeParser.optional("cooldown2"), 31 | "cooldown3": AttributeParser.optional("cooldown3"), 32 | "cooldown_group": AttributeParser.optional("cooldowngroup"), 33 | "cooldown_group_secondary": AttributeParser.optional("cooldowngroup2"), 34 | "level": AttributeParser.optional("levelrequired", parse_integer), 35 | "is_premium": AttributeParser.optional("premium",parse_boolean, False), 36 | "is_promotion": AttributeParser.optional("promotion", parse_boolean, False), 37 | "is_wheel_spell": AttributeParser.optional("wheelspell", parse_boolean, False), 38 | "is_passive": AttributeParser.optional("passivespell", parse_boolean, False), 39 | "version": AttributeParser.optional("implemented"), 40 | "status": AttributeParser.status(), 41 | } 42 | 43 | @classmethod 44 | def parse_attributes(cls, article: Article): 45 | row = super().parse_attributes(article) 46 | for vocation in ["knight", "sorcerer", "druid", "paladin", "monk"]: 47 | if vocation in row["_raw_attributes"].get("voc", "").lower(): 48 | row[vocation] = True 49 | return row 50 | 51 | 52 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/update.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from tibiawikisql.models.update import Update 4 | from tibiawikisql.parsers import BaseParser 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.schema import UpdateTable 7 | from tibiawikisql.utils import clean_links, parse_date, parse_integer 8 | 9 | 10 | class UpdateParser(BaseParser): 11 | """Parser for game updates.""" 12 | model = Update 13 | table = UpdateTable 14 | template_name = "Infobox_Update" 15 | attribute_map: ClassVar = { 16 | "name": AttributeParser.optional("name"), 17 | "type_primary": AttributeParser.required("primarytype"), 18 | "type_secondary": AttributeParser.optional("secondarytype"), 19 | "release_date": AttributeParser.required("date", parse_date), 20 | "news_id": AttributeParser.optional("newsid", parse_integer), 21 | "previous": AttributeParser.optional("previous"), 22 | "next": AttributeParser.optional("next"), 23 | "summary": AttributeParser.optional("summary", clean_links), 24 | "changes": AttributeParser.optional("changelist", clean_links), 25 | "version": AttributeParser.version(), 26 | } 27 | -------------------------------------------------------------------------------- /tibiawikisql/parsers/world.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import tibiawikisql.schema 4 | from tibiawikisql.models.world import World 5 | from tibiawikisql.parsers.base import AttributeParser 6 | from tibiawikisql.parsers import BaseParser 7 | from tibiawikisql.utils import parse_boolean, parse_date, parse_integer 8 | 9 | 10 | class WorldParser(BaseParser): 11 | """Parser for game worlds (servers).""" 12 | 13 | table = tibiawikisql.schema.WorldTable 14 | model = World 15 | template_name = "Infobox_World" 16 | attribute_map: ClassVar = { 17 | "name": AttributeParser.required("name"), 18 | "location": AttributeParser.required("location"), 19 | "pvp_type": AttributeParser.required("type"), 20 | "is_preview": AttributeParser.optional("preview", parse_boolean, False), 21 | "is_experimental": AttributeParser.optional("experimental", parse_boolean, False), 22 | "online_since": AttributeParser.required("online", parse_date), 23 | "offline_since": AttributeParser.optional("offline", parse_date), 24 | "merged_into": AttributeParser.optional("mergedinto"), 25 | "battleye": AttributeParser.optional("battleye", parse_boolean, False), 26 | "battleye_type": AttributeParser.optional("battleyetype"), 27 | "protected_since": AttributeParser.optional("protectedsince", parse_date), 28 | "world_board": AttributeParser.optional("worldboardid", parse_integer), 29 | "trade_board": AttributeParser.optional("tradeboardid", parse_integer), 30 | } 31 | -------------------------------------------------------------------------------- /tibiawikisql/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sqlite3 5 | from typing import Annotated, TYPE_CHECKING 6 | 7 | from fastapi import APIRouter, Depends, FastAPI 8 | from starlette.requests import Request 9 | from starlette.responses import JSONResponse 10 | 11 | from tibiawikisql.api import WikiClient 12 | from tibiawikisql.models import Achievement, Book, Charm, Creature, House, Imbuement, Item, Key, Mount, Npc, Outfit, \ 13 | Quest, \ 14 | Spell, \ 15 | Update, \ 16 | World 17 | from tibiawikisql.parsers import AchievementParser 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Generator 21 | 22 | logging.basicConfig(level=logging.DEBUG) 23 | 24 | sql_logger = logging.getLogger("sqlite3") 25 | 26 | wiki_client = WikiClient() 27 | 28 | app = FastAPI( 29 | title="TibiaWikiSQL", 30 | ) 31 | db_router = APIRouter( 32 | prefix="/db", 33 | tags=["db"], 34 | ) 35 | wiki_router = APIRouter( 36 | prefix="/wiki", 37 | tags=["wiki"], 38 | ) 39 | 40 | 41 | @app.exception_handler(Exception) 42 | async def exception_handler(request: Request, exc: Exception): 43 | return JSONResponse( 44 | status_code=500, 45 | content={ 46 | "title": exc.__class__.__name__, 47 | "message": str(exc), 48 | }, 49 | ) 50 | 51 | 52 | def get_db_connection() -> Generator[sqlite3.Connection]: 53 | conn = sqlite3.connect("tibiawiki.db") 54 | conn.set_trace_callback(sql_logger.info) 55 | try: 56 | yield conn 57 | finally: 58 | conn.close() 59 | 60 | 61 | Conn = Annotated[sqlite3.Connection, Depends(get_db_connection)] 62 | 63 | 64 | @app.get("/healthcheck", tags=["General"]) 65 | def healthcheck() -> bool: 66 | return True 67 | 68 | 69 | @db_router.get("/achievements/{title}") 70 | def get_achievement( 71 | conn: Conn, 72 | title: str, 73 | ) -> Achievement | None: 74 | return Achievement.get_by_title(conn, title) 75 | 76 | 77 | @wiki_router.get("/achievements/{title}") 78 | def get_wiki_achievement( 79 | title: str, 80 | ) -> Achievement | None: 81 | article = wiki_client.get_article(title) 82 | if not article: 83 | return None 84 | return AchievementParser.from_article(article) 85 | 86 | @db_router.get("/books/{title}") 87 | def get_book( 88 | conn: Conn, 89 | title: str, 90 | ) -> Book | None: 91 | return Book.get_by_title(conn, title) 92 | 93 | 94 | 95 | @db_router.get("/charms/{title}") 96 | def get_charm( 97 | conn: Conn, 98 | title: str, 99 | ) -> Charm | None: 100 | return Charm.get_by_title(conn, title) 101 | 102 | 103 | @db_router.get("/creatures/{title}") 104 | def get_creature( 105 | conn: Conn, 106 | title: str, 107 | ) -> Creature | None: 108 | return Creature.get_by_title(conn, title) 109 | 110 | 111 | @db_router.get("/houses/{title}") 112 | def get_house( 113 | conn: Conn, 114 | title: str, 115 | ) -> House | None: 116 | return House.get_by_title(conn, title) 117 | 118 | 119 | @db_router.get("/imbuements/{title}") 120 | def get_imbuement( 121 | conn: Conn, 122 | title: str, 123 | ) -> Imbuement | None: 124 | return Imbuement.get_by_title(conn, title) 125 | 126 | 127 | @db_router.get("/items/{title}") 128 | def get_item( 129 | conn: Conn, 130 | title: str, 131 | ) -> Item | None: 132 | return Item.get_by_title(conn, title) 133 | 134 | 135 | @db_router.get("/keys/{title}") 136 | def get_key( 137 | conn: Conn, 138 | title: str, 139 | ) -> Key | None: 140 | return Key.get_by_title(conn, title) 141 | 142 | 143 | @db_router.get("/mounts/{title}") 144 | def get_mount( 145 | conn: Conn, 146 | title: str, 147 | ) -> Mount | None: 148 | return Mount.get_by_title(conn, title) 149 | 150 | 151 | @db_router.get("/npcs/{title}") 152 | def get_npc( 153 | conn: Conn, 154 | title: str, 155 | ) -> Npc | None: 156 | return Npc.get_by_title(conn, title) 157 | 158 | 159 | @db_router.get("/outfits/{title}") 160 | def get_outfit( 161 | conn: Conn, 162 | title: str, 163 | ) -> Outfit | None: 164 | return Outfit.get_by_title(conn, title) 165 | 166 | 167 | @db_router.get("/quests/{title}") 168 | def get_quest( 169 | conn: Conn, 170 | title: str, 171 | ) -> Quest | None: 172 | return Quest.get_by_title(conn, title) 173 | 174 | 175 | @db_router.get("/spells/{title}") 176 | def get_spell( 177 | conn: Conn, 178 | title: str, 179 | ) -> Spell | None: 180 | return Spell.get_by_title(conn, title) 181 | 182 | 183 | @db_router.get("/updates/byVersion/{version}") 184 | def get_update_by_version( 185 | conn: Conn, 186 | version: str, 187 | ) -> Update | None: 188 | return Update.get_one_by_field(conn, "version", version) 189 | 190 | 191 | @db_router.get("/updates/{title:path}") 192 | def get_update( 193 | conn: Conn, 194 | title: str, 195 | ) -> Update | None: 196 | return Update.get_by_title(conn, title) 197 | 198 | 199 | @db_router.get("/worlds/{title}") 200 | def get_world( 201 | conn: Conn, 202 | title: str, 203 | ) -> World | None: 204 | return World.get_by_title(conn, title) 205 | 206 | 207 | app.include_router(db_router) 208 | app.include_router(wiki_router) 209 | --------------------------------------------------------------------------------