├── .github └── workflows │ └── release.yml ├── .gitignore ├── Dockerfile ├── Dockerfile_Api ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── src └── erdb │ ├── __init__.py │ ├── __main__.py │ ├── app_api │ ├── __init__.py │ ├── common.py │ ├── endpoints.py │ └── main.py │ ├── app_wiki │ └── __init__.py │ ├── data │ ├── contrib │ │ ├── Armament │ │ │ └── Lance.json │ │ ├── Armor │ │ │ └── Albinauric Mask.json │ │ ├── AshOfWar │ │ │ └── Ash of War Lion's Claw.json │ │ ├── SpiritAsh │ │ │ └── Black Knife Tiche.json │ │ └── Talisman │ │ │ └── Radagon's Soreseal.json │ ├── gamedata │ │ ├── 1.02.1.zip │ │ ├── 1.02.2.zip │ │ ├── 1.02.3.zip │ │ ├── 1.03.1.zip │ │ ├── 1.03.2.zip │ │ ├── 1.03.3.zip │ │ ├── 1.04.1.zip │ │ ├── 1.04.2.zip │ │ ├── 1.05.0.zip │ │ ├── 1.06.0.zip │ │ ├── 1.07.0.zip │ │ ├── 1.07.1.zip │ │ ├── 1.08.0.zip │ │ ├── 1.08.1.zip │ │ ├── 1.09.0.zip │ │ ├── 1.10.0.zip │ │ └── manifest.json │ ├── thirdparty │ │ └── README.md │ └── wiki │ │ ├── scripts │ │ └── ar_calculator.py │ │ └── templates │ │ ├── ar-calculator.html.jinja │ │ ├── armaments-index.html.jinja │ │ └── armaments-single.html.jinja │ ├── effect_parser │ ├── __init__.py │ ├── aggregator.py │ ├── attribute_fields.py │ ├── hardcoded.py │ └── parsers.py │ ├── loaders │ ├── __init__.py │ ├── contrib.py │ └── params.py │ ├── main │ ├── __init__.py │ ├── app.py │ ├── args.py │ └── cli.py │ ├── py.typed │ ├── shop │ ├── __init__.py │ └── shop_typing.py │ ├── table │ ├── __init__.py │ ├── _common.py │ ├── _retrievers.py │ ├── ammo.py │ ├── armaments.py │ ├── armor.py │ ├── ashes_of_war.py │ ├── bolstering_materials.py │ ├── correction_attack.py │ ├── correction_graph.py │ ├── crafting_materials.py │ ├── gestures.py │ ├── info.py │ ├── keys.py │ ├── reinforcements.py │ ├── shop.py │ ├── spells.py │ ├── spirit_ashes.py │ ├── talismans.py │ └── tools.py │ ├── typing │ ├── __init__.py │ ├── api_version.py │ ├── categories.py │ ├── effects.py │ ├── enums.py │ ├── game_version.py │ ├── models │ │ ├── __init__.py │ │ ├── ammo.py │ │ ├── armament.py │ │ ├── armor.py │ │ ├── ash_of_war.py │ │ ├── bolstering_material.py │ │ ├── common.py │ │ ├── correction_attack.py │ │ ├── correction_graph.py │ │ ├── crafting_material.py │ │ ├── effect.py │ │ ├── gesture.py │ │ ├── info.py │ │ ├── item.py │ │ ├── key.py │ │ ├── location.py │ │ ├── reinforcement.py │ │ ├── shop.py │ │ ├── spells.py │ │ ├── spirit_ash.py │ │ ├── talisman.py │ │ └── tool.py │ ├── params.py │ └── sp_effect.py │ └── utils │ ├── __init__.py │ ├── attack_power.py │ ├── changelog.py │ ├── cloudflare_images_client.py │ ├── common.py │ ├── fetch_attack_power_data.py │ ├── find_used_effects.py │ ├── find_valid_values.py │ └── sourcer.py └── tests ├── __init__.py ├── attack_power_test.py ├── common_test.py └── game_version_test.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | build-version: 7 | required: true 8 | description: "Release version and its future tag (ex. 1.0.0)" 9 | release-pypi: 10 | required: true 11 | default: true 12 | type: boolean 13 | description: "Release the Python package to PyPI" 14 | release-ghcr: 15 | required: true 16 | default: true 17 | type: boolean 18 | description: "Release the Docker image to GHCR" 19 | release-github: 20 | required: true 21 | default: true 22 | type: boolean 23 | description: "Draft release on GitHub" 24 | 25 | env: 26 | REGISTRY: ghcr.io 27 | IMAGE_NAME: ${{ github.repository }} 28 | 29 | jobs: 30 | pypi: 31 | name: Publish Python package on PyPI 32 | if: ${{ inputs.release-pypi }} 33 | runs-on: ubuntu-22.04 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v3 37 | 38 | - name: Setup Python 3.11 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: "3.11" 42 | 43 | - name: Install Flit 44 | run: python -m pip install flit 45 | 46 | - name: Build package 47 | run: python -m flit build 48 | env: 49 | BUILD_VERSION: ${{ inputs.build-version }} 50 | 51 | - name: Publish package 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | password: ${{ secrets.PYPI_API_TOKEN }} 55 | 56 | ghcr: 57 | name: Publish Docker image on GHCR 58 | if: ${{ inputs.release-ghcr }} 59 | runs-on: ubuntu-22.04 60 | permissions: 61 | contents: read 62 | packages: write 63 | steps: 64 | - name: Checkout repository 65 | uses: actions/checkout@v3 66 | 67 | - name: Log in to the Container registry 68 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 69 | with: 70 | registry: ${{ env.REGISTRY }} 71 | username: ${{ github.actor }} 72 | password: ${{ secrets.GITHUB_TOKEN }} 73 | 74 | - name: Extract generic image metadata for Docker 75 | id: meta 76 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 77 | with: 78 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 79 | tags: | 80 | type=semver,pattern={{version}},value=${{ inputs.build-version }} 81 | 82 | - name: Build and push generic Docker image 83 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 84 | with: 85 | context: . 86 | file: ./Dockerfile 87 | push: true 88 | tags: ${{ steps.meta.outputs.tags }} 89 | labels: ${{ steps.meta.outputs.labels }} 90 | build-args: BUILD_VERSION=${{ inputs.build-version }} 91 | 92 | - name: Extract API image metadata for Docker 93 | id: meta-api 94 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 95 | with: 96 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-api 97 | tags: | 98 | type=semver,pattern={{version}},value=${{ inputs.build-version }} 99 | 100 | - name: Build and push API Docker image 101 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 102 | with: 103 | context: . 104 | file: ./Dockerfile_Api 105 | push: true 106 | tags: ${{ steps.meta-api.outputs.tags }} 107 | labels: ${{ steps.meta-api.outputs.labels }} 108 | build-args: BUILD_VERSION=${{ inputs.build-version }} 109 | 110 | nyasu: 111 | name: Deploy API image 112 | if: ${{ inputs.release-ghcr }} 113 | needs: ghcr 114 | runs-on: ubuntu-22.04 115 | steps: 116 | - name: Trigger deployment 117 | uses: indiesdev/curl@v1.1 118 | with: 119 | method: "PATCH" 120 | url: https://k8s.nyasu.business/deployments/erdb/version 121 | bearer-token: ${{ secrets.NYASU_DEPLOY_TOKEN }} 122 | body: '{ "version": "${{ inputs.build-version }}" }' 123 | 124 | - name: Delay 30s 125 | run: sleep 30s 126 | shell: bash 127 | 128 | - name: Show deployment status 129 | uses: indiesdev/curl@v1.1 130 | with: 131 | method: "GET" 132 | url: https://k8s.nyasu.business/deployments/erdb/status 133 | bearer-token: ${{ secrets.NYASU_DEPLOY_TOKEN }} 134 | log-response: true 135 | 136 | 137 | github: 138 | name: Draft release on GitHub 139 | if: ${{ inputs.release-github }} 140 | needs: [pypi, ghcr] 141 | runs-on: ubuntu-22.04 142 | steps: 143 | - name: Release 144 | uses: softprops/action-gh-release@v1 145 | with: 146 | draft: true 147 | name: "Release v${{ inputs.build-version }}" 148 | tag_name: v${{ inputs.build-version }} 149 | env: 150 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | .vs/ 3 | .vscode/ 4 | venv/ 5 | dist/ 6 | __pycache__/ 7 | .pytest_cache/ 8 | erdb-ap-fetcher-*.json 9 | thirdparty/* 10 | !thirdparty/README.md 11 | src/erdb/data/thirdparty/* 12 | !src/erdb/data/thirdparty/README.md 13 | *.egg-info/ 14 | *.egg 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | ARG BUILD_VERSION 4 | 5 | # Flit fails otherwise 6 | ARG FLIT_ROOT_INSTALL=1 7 | 8 | COPY requirements.txt requirements.txt 9 | RUN pip3 install -r requirements.txt 10 | 11 | COPY . . 12 | 13 | # install ERDB package locally, uses BUILD_VERSION env var 14 | RUN python3 -m flit install 15 | 16 | ENTRYPOINT [ "python3", "-m", "erdb" ] 17 | CMD [ "-h" ] -------------------------------------------------------------------------------- /Dockerfile_Api: -------------------------------------------------------------------------------- 1 | ARG BUILD_VERSION 2 | 3 | FROM ghcr.io/eldenringdatabase/erdb:${BUILD_VERSION} 4 | 5 | EXPOSE 8107 6 | 7 | ENTRYPOINT [ "python3", "-m", "erdb" ] 8 | CMD [ "serve-api", "--port", "8107", "--precache" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Filip Tomaszewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ERDB: tool for parsing ELDEN RING files 2 | 3 | ### What this is 4 | 5 | ERDB is a CLI tool for parsing an ELDEN RING installation and converting it into a more display-friendly structure in JSON format. 6 | For example, it can collect data from multiple game params about an item and compile it to a single JSON object holding all the properties you could find on a Wikipedia page about it. 7 | This is only the main functionality and it covers all in-game items and more, with a possibility to extend the features thanks to the mini-framework for interacting with ELDEN RING files. 8 | 9 | ### Who this is for 10 | 11 | ERDB is primarily targeted at people developing tools and mods for ELDEN RING. 12 | You can generate data from your installation (native or modded), or use the public REST API (outlined in [Usage](#usage) section) for any existing version of the game. 13 | If you ever created a build planner or any sort of a tool which needs to deal with in-game items, you know how painful it is to get a hold of and store the data and assets. 14 | This is an attempt at unifying that data into something that is easily accessible and parsed by anyone, with instant updates when a new game version releases. 15 | 16 | ## Usage 17 | 18 | There are several ways to use ERDB, and you're probably looking for the last one: 19 | 20 | 1. **Python package**: `pip install erdb` makes it accessible from CLI by calling `erdb` ([CLI manual](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual)). 21 | 1. **Docker image**: Python package in form of a Docker image accepting same arguments ([package page](https://github.com/EldenRingDatabase/erdb/pkgs/container/erdb)). 22 | 1. **Docker API image**: built on the regular image, only serves the REST API on port `8107` ([package page](https://github.com/EldenRingDatabase/erdb/pkgs/container/erdb-api)). 23 | 1. **Public REST service**: REST API is available without a need to use ERDB directly ([docs](https://api.erdb.wiki/v1/docs), [alt docs](https://api.erdb.wiki/v1/redoc)). 24 | 25 | ## Tool features 26 | 27 | This is a short summary of all subcommands ERDB provides as a CLI tool: 28 | 29 | * [`generate`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-generate): Generate JSON data for specified tables. 30 | * [`find-values`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-find-values): Find all possible values of a field per param name. 31 | * [`calculate-ar`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-calculate-ar): Calculate attack power of an armament. 32 | * [`changelog`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-changelog): Create a changelog of ERDB-detectable differences between specified versions. 33 | * [`source`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-source): Extract gamedata from an UXM-unpacked ELDEN RING installation (Windows only). 34 | * [`map`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-map): Extract world map image from an UXM-unpacked ELDEN RING installation (Windows only). 35 | * [`icons`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-icons): Extract item images from an UXM-unpacked ELDEN RING installation (Windows only). 36 | * [`serve-api`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-serve-api): Begin serving the API web server. 37 | * [`generate-wiki`](https://github.com/EldenRingDatabase/erdb/wiki/CLI-Interface-Manual#erdb-generate-wiki): Generate a static Wikipedia website. 38 | 39 | ## Data features 40 | 41 | Adopting the database in a project is beneficial beyond just providing the data itself: 42 | 43 | ### Reliable sources 44 | 45 | * All the data is ripped straight from the game files using [Yabber](https://github.com/JKAnderson/Yabber) and [ERExporter](https://github.com/EldenRingDatabase/ERExporter). 46 | * Mild dependency on Yapped Rune Bear's [Paramdex](https://github.com/vawser/Yapped-Rune-Bear/tree/main/Paramdex/ER) for naming of a few properties. 47 | 48 | ### 99%1 of data is autogenerated 49 | 50 | * Values are free from human error and always up-to-date. 51 | * Values are effortlessly regenerated with new game releases. 52 | * Convoluted game parameters are converted into a coherent layout, harmless2 information loss. 53 | 54 | ### User contributed information 55 | 56 | * Made in mind with user contributions, new findings can be available in every adaptation of the database. 57 | * Interesting remarks about an item or a weapon's true combo list is kept up to date. 58 | * Designed for easy PRs where every item is contained within its own file. 59 | * Ability to specify game version ranges for which a particular information is valid. 60 | * Each human change is checked for correctness (by accepting a PR) and validity (by CI) [COMING SOON]. 61 | 62 | ### Thought-through structure 63 | 64 | * Structure of JSON designed as shallow and straightforward to parse as possible. 65 | * Digging into nested fields is avoided, while retaining a logically sensible layout. 66 | * Layout adheres to a strictly defined schema with the help of [Pydantic](https://docs.pydantic.dev/), ensuring integrity of all values. 67 | * [FastAPI](https://fastapi.tiangolo.com/) sets up REST endpoints with OpenAPI-compatible documentation. 68 | 69 | ### Availablility 70 | 71 | * Public webserver providing a REST API for remote data retrieval (see [Usage](#usage) section). 72 | * Generate the data yourself and embed it in your app. 73 | * Every released game version is supported, from 1.02.1 and up ([sources](https://github.com/EldenRingDatabase/erdb/tree/master/src/erdb/data/gamedata)). 74 | 75 | 1 Some values or means of retrieving them are hardcoded in very specific cases, all listed [here](https://github.com/EldenRingDatabase/erdb/wiki/Data-Generation-Completeness). \ 76 | 2 Only unnecessary information is lost, ex. armor pieces have separate values for bleed/frostbite, but are treated as a single "robustness" value in ERDB (as are in-game). 77 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "erdb" 7 | description = "parse ELDEN RING files into JSON" 8 | authors = [{name = "Filip Tomaszewski", email = "phil25@pm.me"}] 9 | readme = "README.md" 10 | license = {file = "LICENSE"} 11 | classifiers = [ 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3 :: Only", 15 | "Programming Language :: Python :: 3.11", 16 | ] 17 | requires-python = ">=3.11" 18 | dependencies = [ 19 | "Pillow >= 9.2", 20 | "deepdiff >= 6.2", 21 | "requests >= 2.28", 22 | "fastapi >= 0.87", 23 | "fastapi-versioning >= 0.10", 24 | "uvicorn >= 0.19", 25 | "jinja2 >= 3.1.2", 26 | "htmlmin >= 0.1.12", 27 | ] 28 | dynamic = ["version"] 29 | 30 | [project.optional-dependencies] 31 | test = [ 32 | "pytest >= 7.1" 33 | ] 34 | 35 | [project.urls] 36 | Home = "https://github.com/EldenRingDatabase/erdb" 37 | 38 | [project.scripts] 39 | erdb = "erdb.main.cli:entrypoint" 40 | 41 | [tool.mypy] 42 | plugins = "pydantic.mypy" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow==9.2.0 2 | mypy==0.991 3 | pytest==7.1.2 4 | deepdiff==6.2.1 5 | requests==2.28.1 6 | fastapi==0.87 7 | fastapi-versioning==0.10.0 8 | uvicorn==0.19 9 | flit==3.8.0 10 | jinja2==3.1.2 11 | htmlmin==0.1.12 -------------------------------------------------------------------------------- /src/erdb/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | __version__ = os.getenv("BUILD_VERSION", "0.0.0") -------------------------------------------------------------------------------- /src/erdb/__main__.py: -------------------------------------------------------------------------------- 1 | from erdb.main.cli import entrypoint 2 | 3 | 4 | if __name__ == "__main__": 5 | raise SystemExit(entrypoint()) -------------------------------------------------------------------------------- /src/erdb/app_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/app_api/__init__.py -------------------------------------------------------------------------------- /src/erdb/app_api/common.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from tempfile import TemporaryDirectory 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | from functools import cache, lru_cache 6 | from enum import Enum 7 | from typing import IO, Any, Generator, NamedTuple, Self 8 | 9 | from erdb.table import Table 10 | from erdb.loaders import GAME_VERSIONS 11 | from erdb.typing.game_version import GameVersion 12 | from erdb.typing.api_version import ApiVersion 13 | 14 | 15 | GameVersionEnum = Enum("GameVersionEnum", {"latest": "latest"} | {str(v).replace(".", "_"): str(v) for v in GAME_VERSIONS}) 16 | LATEST_VERSION = list(GameVersionEnum)[1] 17 | 18 | class DataProxy(NamedTuple): 19 | cache_dir: Path 20 | 21 | def precache(self): 22 | for game_version in GameVersionEnum: 23 | print(f">>> Precaching version {game_version.value}:", flush=True) 24 | 25 | for tb in Table.effective(): 26 | for api in tb.spec.model.keys(): 27 | print(f"> {tb.title} [v{api}]", flush=True) 28 | self.generate(api, game_version, tb) 29 | 30 | print(flush=True) 31 | 32 | def generate(self, api: ApiVersion, game_version: GameVersionEnum, table: Table) -> dict: # type: ignore 33 | return \ 34 | self._generate_latest(api, table) if game_version == GameVersionEnum.latest else \ 35 | self._generate_specific(api, game_version, table) 36 | 37 | @cache # always in memory 38 | def _generate_latest(self, api: ApiVersion, table: Table) -> dict: 39 | return self._generate_specific(api, LATEST_VERSION, table) 40 | 41 | @lru_cache(maxsize=8) # up to 8 tables, single item access caches an entire table 42 | def _generate_specific(self, api: ApiVersion, game_version: GameVersionEnum, table: Table) -> dict: # type: ignore 43 | try: 44 | with self._open_cache(api, game_version, table, mode="rb") as f: 45 | return pickle.load(f) 46 | 47 | except FileNotFoundError: 48 | ver = GameVersion.from_string(game_version.value) 49 | data = table.make_generator(ver).generate(api) 50 | 51 | with self._open_cache(api, game_version, table, mode="wb") as f: 52 | pickle.dump(data, f) 53 | 54 | return data 55 | 56 | def _open_cache(self, api: ApiVersion, game_version: GameVersionEnum, table: Table, mode: str) -> IO[Any]: # type: ignore 57 | return open(self.cache_dir / f"{api}-{game_version.value}-{table}.bin", mode=mode) 58 | 59 | @classmethod 60 | @contextmanager 61 | def in_temp_dir(cls, **kwargs) -> Generator[Self, None, None]: 62 | with TemporaryDirectory(**kwargs) as temp_dir: 63 | yield cls(Path(temp_dir)) -------------------------------------------------------------------------------- /src/erdb/app_api/endpoints.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import Query, status 3 | from fastapi.responses import JSONResponse 4 | from pydantic.dataclasses import dataclass 5 | 6 | from erdb.table import Table 7 | from erdb.app_api.common import DataProxy, GameVersionEnum 8 | from erdb.utils.common import getattrstr 9 | from erdb.typing.api_version import ApiVersion 10 | 11 | 12 | @dataclass 13 | class _Detail: 14 | detail: str 15 | 16 | class DataEndpoint: 17 | data_proxy: DataProxy 18 | api: ApiVersion 19 | table: Table 20 | 21 | def __init__(self, data_proxy: DataProxy, api: ApiVersion, table: Table) -> None: 22 | self.data_proxy = data_proxy 23 | self.api = api 24 | self.table = table 25 | 26 | @property 27 | def route(self) -> str: 28 | return "/" 29 | 30 | @property 31 | def model(self) -> Any: 32 | return dict[str, self.table.spec.model[self.api]] # type: ignore 33 | 34 | @property 35 | def summary(self) -> str: 36 | return "multiple items" 37 | 38 | @property 39 | def description(self) -> str: 40 | return "Retrieve a dictionary of item's ascii name -> item properties." 41 | 42 | @property 43 | def responses(self) -> dict[int, dict]: 44 | return { 45 | status.HTTP_400_BAD_REQUEST: { 46 | "model": _Detail, 47 | "description": "Bad Request: `query` format is valid, but the specified field does not exist for this model." 48 | } 49 | } 50 | 51 | def __call__(self, 52 | game_version: GameVersionEnum, 53 | keys: list[str] | None = Query(None, alias="k", description="Specify a list of keys (ascii names) to retrieve specific items."), 54 | query: str | None = Query(None, description="Filter elements by field in format \"{field}:{value}\".", regex=r"^\w+\:.+$"), 55 | ) -> Any: 56 | data = self.data_proxy.generate(self.api, game_version, self.table) 57 | 58 | if keys is not None: 59 | data = {k: v for k, v in data.items() if k in keys} 60 | 61 | if query is not None: 62 | field, value = query.split(":", maxsplit=1) 63 | 64 | try: 65 | data = {k: v for k, v in data.items() if getattrstr(v, field) == value} 66 | 67 | except AttributeError: 68 | return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": f"{self.table.title} has no field: \"{field}\"."}) 69 | 70 | return data 71 | 72 | class ItemEndpoint: 73 | data_proxy: DataProxy 74 | api: ApiVersion 75 | table: Table 76 | 77 | def __init__(self, data_proxy: DataProxy, api: ApiVersion, table: Table) -> None: 78 | self.data_proxy = data_proxy 79 | self.api = api 80 | self.table = table 81 | 82 | @property 83 | def route(self) -> str: 84 | return "/{key}" 85 | 86 | @property 87 | def model(self) -> Any: 88 | return self.table.spec.model[self.api] 89 | 90 | @property 91 | def summary(self) -> str: 92 | return "single item" 93 | 94 | @property 95 | def description(self) -> str: 96 | return "Retrieve properties of a single item." 97 | 98 | @property 99 | def responses(self) -> dict[int, dict]: 100 | return { 101 | status.HTTP_404_NOT_FOUND: {"model": _Detail} 102 | } 103 | 104 | def __call__(self, game_version: GameVersionEnum, key: str) -> Any: 105 | try: 106 | return self.data_proxy.generate(self.api, game_version, self.table)[key] 107 | 108 | except KeyError: 109 | return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"detail": f"{self.table.title} has no key: \"{key}\""}) -------------------------------------------------------------------------------- /src/erdb/app_api/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI, APIRouter, Depends 3 | from fastapi_versioning import VersionedFastAPI, versioned_api_route 4 | from fastapi.middleware.cors import CORSMiddleware 5 | 6 | from erdb.app_api.endpoints import DataEndpoint, ItemEndpoint 7 | from erdb.app_api.common import DataProxy 8 | from erdb.typing.api_version import ApiVersion 9 | from erdb.table import Table 10 | 11 | 12 | def _get_router(data_proxy: DataProxy, api: ApiVersion, table: Table) -> APIRouter: 13 | router = APIRouter( 14 | prefix="/{game_version}/" + table.value, 15 | route_class=versioned_api_route(api), 16 | tags=[table.title], 17 | ) 18 | 19 | for Endpoint in [DataEndpoint, ItemEndpoint]: 20 | endpoint = Endpoint(data_proxy, api, table) 21 | router.add_api_route( 22 | endpoint.route, 23 | lambda dep = Depends(endpoint): dep, 24 | response_model=endpoint.model, 25 | response_model_exclude_none=True, 26 | responses=endpoint.responses, 27 | summary=endpoint.summary, 28 | description=endpoint.description 29 | ) 30 | 31 | return router 32 | 33 | def serve(port: int, *, bind: str = "0.0.0.0", precache: bool = False): 34 | with DataProxy.in_temp_dir(prefix="erdb-cache-") as data_proxy: 35 | if precache: 36 | data_proxy.precache() 37 | 38 | app = FastAPI(title="ERDB API Docs", description="RESTful API documentation for ERDB.") 39 | 40 | for tb in sorted(Table.effective()): 41 | for api in tb.spec.model.keys(): 42 | app.include_router(_get_router(data_proxy, api, tb)) 43 | 44 | app = VersionedFastAPI(app, version_format="API v{major}", prefix_format="/v{major}") 45 | app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["GET"], allow_headers=["*"]) 46 | 47 | uvicorn.run(app, host=bind, port=port) 48 | -------------------------------------------------------------------------------- /src/erdb/app_wiki/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import shutil 4 | from typing import Iterable, NamedTuple 5 | import requests 6 | from io import BytesIO, TextIOWrapper 7 | from pathlib import Path 8 | from zipfile import ZipFile 9 | from jinja2 import Environment, Template, FileSystemLoader 10 | from htmlmin import minify as minifyhtml 11 | 12 | import erdb.utils.attack_power as attack_power_module 13 | from erdb.loaders import PKG_DATA_PATH 14 | from erdb.utils.common import scaling_grade, to_somber 15 | from erdb.table import Table 16 | 17 | 18 | _ITEMS = { 19 | Table.ARMAMENTS: ["reinforcements"] 20 | } 21 | 22 | class ThirdpartyLibrary(NamedTuple): 23 | name: str 24 | destination: Path 25 | freeze_version: str 26 | files: Iterable[str] 27 | 28 | def _current_version(self) -> str | None: 29 | assert False, "not implemented" 30 | 31 | def _download(self, version: str): 32 | assert False, "not implemented" 33 | 34 | def ensure_version(self, desired: str | None): 35 | current = self._current_version() 36 | 37 | if current is None: # local not found 38 | self._download(self.freeze_version if desired is None else desired) 39 | 40 | elif desired is not None and desired != current: 41 | print(f"> Current {self.name} version ({current}) doesn't match desired ({desired}).", flush=True) 42 | self._download(desired) 43 | 44 | class UIkit(ThirdpartyLibrary): 45 | def _current_version(self) -> str | None: 46 | versions: set[str] = set() 47 | 48 | for loc in self.files: 49 | if not (self.destination / loc).exists(): 50 | return None 51 | 52 | with open(self.destination / loc, mode="r") as f: 53 | content = f.read(64) # version string is contained at the start 54 | 55 | if version_lookup := re.search(r"UIkit (\d+\.\d+\.\d+)", content): 56 | versions.add(version_lookup.group(1)) 57 | 58 | else: 59 | return None # a file's wrong 60 | 61 | # every version must be the same 62 | return next(iter(versions)) if len(versions) == 1 else None 63 | 64 | def _download(self, version: str): 65 | print(f"> Downloading {self.name} {version}...", flush=True) 66 | 67 | shutil.rmtree(self.destination, ignore_errors=True) 68 | self.destination.mkdir(parents=True, exist_ok=False) 69 | 70 | resp = requests.get(f"https://github.com/uikit/uikit/releases/download/v{version}/uikit-{version}.zip") 71 | z = ZipFile(BytesIO(resp.content)) 72 | 73 | for loc in self.files: 74 | z.extract(loc, self.destination) 75 | 76 | print(f"> {self.name} {version} installed at {self.destination}.", flush=True) 77 | 78 | class PyScript(ThirdpartyLibrary): 79 | def _current_version(self) -> str | None: 80 | for loc in [*self.files, "version.txt"]: 81 | if not (self.destination / loc).exists(): 82 | return None 83 | 84 | with open(self.destination / "version.txt", mode="r") as f: 85 | version = f.read() 86 | 87 | return version if len(version) > 0 else None 88 | 89 | def _download(self, version: str): 90 | print(f"> Downloading {self.name} {version}...", flush=True) 91 | 92 | shutil.rmtree(self.destination, ignore_errors=True) 93 | self.destination.mkdir(parents=True, exist_ok=False) 94 | 95 | for loc in self.files: 96 | with open(self.destination / loc, mode="wb") as f: 97 | resp = requests.get(f"https://pyscript.net/releases/{version}/{loc}") 98 | 99 | if resp.status_code != 200: 100 | return 101 | 102 | f.write(resp.content) 103 | 104 | with open(self.destination / "version.txt", mode="w") as f: 105 | f.write(version) 106 | 107 | print(f"> {self.name} {version} installed at {self.destination}.", flush=True) 108 | 109 | def _read_json(file: Path): 110 | with open(file, mode="r", encoding="utf-8") as f: 111 | return json.load(f) 112 | 113 | def _write_html(template: Template, root: Path, file: Path, minimize: bool, **data): 114 | def relative_root(): 115 | depth = len(file.parent.relative_to(root).parts) 116 | return "../" * depth 117 | 118 | def write(f: TextIOWrapper, data: str): 119 | f.write(minifyhtml(data, remove_all_empty_space=True, remove_comments=True) if minimize else data) 120 | 121 | with open(file, mode="w", encoding="utf-8") as f: 122 | write(f, template.render(site_root=relative_root(), **data)) 123 | 124 | def _generate_items(env: Environment, data_path: Path, minimize: bool, out: Path): 125 | items_path = out / "items" 126 | 127 | for table, dependencies in _ITEMS.items(): 128 | print(f">>> Generating {table} wiki pages", flush=True) 129 | 130 | item_path = items_path / table 131 | item_path.mkdir(parents=True, exist_ok=True) 132 | 133 | with open(data_path / f"{table}.json", mode="r", encoding="utf-8") as f: 134 | main_data = json.load(f) 135 | 136 | data = {} 137 | 138 | for dep in dependencies: 139 | print(f"Reading dependent table {dep}", flush=True) 140 | with open(data_path / f"{dep}.json", mode="r", encoding="utf-8") as f: 141 | data[dep] = json.load(f) 142 | 143 | cur, count = 0, len(main_data) 144 | 145 | for key, item in main_data.items(): 146 | print(f"[{(cur := cur + 1)}/{count}] {key}", flush=True) 147 | 148 | data["item"] = item 149 | 150 | single = env.get_template(f"{table}-single.html.jinja") 151 | _write_html(single, out, item_path / f"{key}.html", minimize, **data) 152 | 153 | print(f"Generating index page", flush=True) 154 | 155 | index = env.get_template(f"{table}-index.html.jinja") 156 | _write_html(index, out, item_path / "index.html", minimize, items=main_data) 157 | 158 | print(flush=True) 159 | 160 | def _generate_tools(env: Environment, data_path: Path, out: Path): 161 | tools_path = out / "tools" 162 | tools_path.mkdir(parents=True, exist_ok=True) 163 | 164 | print(">>> Generating AR calculator", flush=True) 165 | 166 | data = { 167 | "armaments": _read_json(data_path / "armaments.json"), 168 | "correction_attack": _read_json(data_path / "correction-attack.json"), 169 | "correction_graph": _read_json(data_path / "correction-graph.json"), 170 | "reinforcements": _read_json(data_path / "reinforcements.json"), 171 | } 172 | 173 | shutil.copy(attack_power_module.__file__, out / "scripts") 174 | 175 | ar_calculator = env.get_template("ar-calculator.html.jinja") 176 | _write_html(ar_calculator, out, tools_path / "ar-calculator.html", minimize=False, **data) 177 | 178 | def generate(uikit_version: str | None, pyscript_version: str | None, data_path: Path, minimize: bool, out: Path): 179 | thirdparty = out / "thirdparty" 180 | thirdparty.mkdir(parents=True, exist_ok=True) 181 | 182 | UIkit( 183 | "UIkit", thirdparty / "uikit", 184 | freeze_version="3.15.18", 185 | files=("css/uikit.min.css", "js/uikit.min.js", "js/uikit-icons.min.js") 186 | ).ensure_version(uikit_version) 187 | 188 | PyScript( 189 | "pyscript", thirdparty / "pyscript", 190 | freeze_version="2022.12.1", 191 | files=("pyscript.css", "pyscript.js") 192 | ).ensure_version(pyscript_version) 193 | 194 | env = Environment(loader=FileSystemLoader(PKG_DATA_PATH / "wiki" / "templates")) 195 | env.filters["scaling_grade"] = scaling_grade 196 | env.filters["to_somber"] = to_somber 197 | env.trim_blocks = True 198 | env.lstrip_blocks = True 199 | 200 | print(f">>> Copying scripts", flush=True) 201 | shutil.copytree(PKG_DATA_PATH / "wiki" / "scripts", out / "scripts", dirs_exist_ok=True) 202 | 203 | _generate_items(env, data_path, minimize, out) 204 | _generate_tools(env, data_path, out) -------------------------------------------------------------------------------- /src/erdb/data/contrib/Armament/Lance.json: -------------------------------------------------------------------------------- 1 | { 2 | "any version": { 3 | "locations": [ 4 | { 5 | "summary": "on top of a ruin by a camp West of Deathtouched Catacombs", 6 | "directions": "Head down the road East from Warmaster's Shack to find a camp between two large cubical ruins. Lance is on top of the northwestern one.", 7 | "region": "Limgrave" 8 | } 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /src/erdb/data/contrib/Armor/Albinauric Mask.json: -------------------------------------------------------------------------------- 1 | { 2 | "any version": { 3 | "locations": [ 4 | { 5 | "summary": "hidden room with a lone Omenkiller", 6 | "directions": "Head to the rooftop of the building with Guest Hall site of grace, drop from the western side onto a balcony to find a hidden room.", 7 | "location": "Volcano Manor", 8 | "region": "Mt. Gelmir" 9 | } 10 | ] 11 | } 12 | } -------------------------------------------------------------------------------- /src/erdb/data/contrib/AshOfWar/Ash of War Lion's Claw.json: -------------------------------------------------------------------------------- 1 | { 2 | "any version": { 3 | "locations": [ 4 | { 5 | "summary": "dropped by Lion Guardian", 6 | "location": "Fort Gael", 7 | "region": "Caelid" 8 | } 9 | ] 10 | } 11 | } -------------------------------------------------------------------------------- /src/erdb/data/contrib/SpiritAsh/Black Knife Tiche.json: -------------------------------------------------------------------------------- 1 | { 2 | "any version": { 3 | "summon_quantity": 1, 4 | "abilities": [ 5 | "Highly mobile", 6 | "Casts Blade of Death", 7 | "Jump and charged attacks can knock enemies down" 8 | ], 9 | "locations": [ 10 | { 11 | "summary": "dropped by Alecto, Black Knife Ringleader", 12 | "location": "Ringleader's Evergaol", 13 | "region": "Liurnia of the Lakes", 14 | "requirements": ["Dark Moon Ring is required to reach Ringleader's Evergaol."] 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /src/erdb/data/contrib/Talisman/Radagon's Soreseal.json: -------------------------------------------------------------------------------- 1 | { 2 | "any version": { 3 | "locations": [ 4 | { 5 | "summary": "hidden on ground floor behind wooden walls", 6 | "directions": "From the rooftop area, drop down onto wooden mid-floor, jump over to another one diagonally, drop down again behind the giant rat.", 7 | "location": "Fort Faroth", 8 | "region": "Dragonbarrow" 9 | } 10 | ] 11 | } 12 | } -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.02.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.02.1.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.02.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.02.2.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.02.3.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.02.3.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.03.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.03.1.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.03.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.03.2.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.03.3.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.03.3.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.04.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.04.1.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.04.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.04.2.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.05.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.05.0.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.06.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.06.0.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.07.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.07.0.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.07.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.07.1.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.08.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.08.0.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.08.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.08.1.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.09.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.09.0.zip -------------------------------------------------------------------------------- /src/erdb/data/gamedata/1.10.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/data/gamedata/1.10.0.zip -------------------------------------------------------------------------------- /src/erdb/data/thirdparty/README.md: -------------------------------------------------------------------------------- 1 | This directory is where thirdparty tools are stored (Yabber, etc). It is only used when sourcing new gamedata using `erdb.py source` command. 2 | 3 | For a detailed description on which tool sources what, refer to the `gamedata/_Extracted/manifest.json` file. -------------------------------------------------------------------------------- /src/erdb/data/wiki/templates/armaments-index.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Armaments Index 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% set fields_attribs = {"STR": "strength", "DEX": "dexterity", "INT": "intelligence", "FTH": "faith", "ARC": "arcane"} %} 16 | 17 | {% set armament_categories = [ 18 | "Dagger", "Straight Sword", "Greatsword", "Colossal Sword", "Curved Sword", 19 | "Curved Greatsword", "Katana", "Twinblade", "Thrusting Sword", "Heavy Thrusting Sword", 20 | "Axe", "Greataxe", "Hammer", "Great Hammer", "Flail", 21 | "Spear", "Great Spear", "Halberd", "Reaper", "Fist", 22 | "Claw", "Whip", "Colossal Weapon", "Light Bow", "Bow", 23 | "Greatbow", "Crossbow", "Ballista", "Glintstone Staff", "Sacred Seal", 24 | "Small Shield", "Medium Shield", "Greatshield", "Torch", 25 | ] %} 26 | 27 | {% set armament_attack_attributes = ["Standard", "Strike", "Slash", "Pierce"] %} 28 | 29 | {% macro armament_card(item, icon_profile="icon") %} 30 |
31 |
32 | 33 |
34 |
35 |
36 |

{{ item.name }}

37 |

{{ item.category }} [{{ item.attack_attributes | join(' / ') }}]

38 |
39 |
40 | {% endmacro %} 41 | 42 | {% macro print_optional(dictionary, property_name, default="-") %} 43 | {% if property_name in dictionary %} 44 | {{ caller(dictionary[property_name]) }} 45 | {% else %} 46 | {{ default }} 47 | {% endif %} 48 | {% endmacro %} 49 | 50 | 51 | 52 |
53 | 54 |
55 | 56 |
57 |
58 |
59 | 60 |
61 |
62 |
63 |
64 | 65 | 66 | 67 | 68 | 69 |
70 |
71 |
72 |
73 | 74 |
    75 |
  • 76 | Classes 77 |
    78 |
    79 | {% for name in armament_categories %} 80 |
    81 | 82 |
    83 | {% endfor %} 84 |
    85 |
    86 |
  • 87 |
  • 88 | Attack Attributes 89 |
    90 |
    91 | {% for name in armament_attack_attributes %} 92 |
    93 | 94 |
    95 | {% endfor %} 96 |
    97 |
    98 |
  • 99 |
100 | 101 |
102 | {% for key, item in items.items() %} 103 |
104 | 105 | {{ armament_card(item) }} 106 | 107 |
108 | {% endfor %} 109 |
110 | 111 |
112 | 113 |
114 |
115 | 168 | -------------------------------------------------------------------------------- /src/erdb/effect_parser/__init__.py: -------------------------------------------------------------------------------- 1 | import erdb.effect_parser.attribute_fields as attrib_fields 2 | import erdb.effect_parser.parsers as parse 3 | import erdb.effect_parser.hardcoded as hardcoded_effects 4 | from erdb.effect_parser.aggregator import aggregate_effects 5 | from erdb.typing.models.effect import StatusEffects 6 | from erdb.typing.params import ParamRow, ParamDict 7 | from erdb.typing.enums import SpEffectType, AttackCondition 8 | from erdb.typing.effects import SchemaEffect 9 | 10 | 11 | _REFERENCE_EFFECT_PARAMS: list[str] = ["cycleOccurrenceSpEffectId", "applyIdOnGetSoul"] 12 | 13 | _SP_EFFECT_TO_FIELD: dict[SpEffectType, str] = { 14 | SpEffectType.HEMORRHAGE: "bloodAttackPower", 15 | SpEffectType.FROSTBITE: "freezeAttackPower", 16 | SpEffectType.POISON: "poizonAttackPower", 17 | SpEffectType.SCARLET_ROT: "diseaseAttackPower", 18 | SpEffectType.SLEEP: "sleepAttackPower", 19 | SpEffectType.MADNESS: "madnessAttackPower", 20 | SpEffectType.BLIGHT: "curseAttackPower", 21 | } 22 | 23 | _SP_EFFECT_TO_STR: dict[SpEffectType, str] = { 24 | SpEffectType.HEMORRHAGE: "bleed", 25 | SpEffectType.FROSTBITE: "frostbite", 26 | SpEffectType.POISON: "poison", 27 | SpEffectType.SCARLET_ROT: "scarlet_rot", 28 | SpEffectType.SLEEP: "sleep", 29 | SpEffectType.MADNESS: "madness", 30 | SpEffectType.BLIGHT: "death_blight", 31 | } 32 | 33 | def get_effects(sp_effect: ParamRow, sp_effect_type: SpEffectType, triggeree: ParamRow | None = None, init_conditions: list[str] | None = None) -> list[SchemaEffect]: 34 | effects = hardcoded_effects.get(sp_effect.index, sp_effect_type) 35 | 36 | for field, attrib_field in attrib_fields.get().items(): 37 | if sp_effect[field] == str(attrib_field.default_value): 38 | continue 39 | 40 | effect = SchemaEffect.from_attribute_field(sp_effect[field].as_float, attrib_field) 41 | 42 | effect.conditions = init_conditions 43 | if conds := parse.conditions(sp_effect, triggeree): 44 | effect.conditions = conds if effect.conditions is None else effect.conditions + conds 45 | 46 | effect.tick_interval = parse.interval(sp_effect) 47 | effect.value_pvp = parse.value_pvp(sp_effect, field, attrib_fields.get()) 48 | 49 | effects.append(effect) 50 | 51 | return effects 52 | 53 | def get_effects_nested(sp_effect: ParamRow, sp_effects: ParamDict, add_condition: AttackCondition | None) -> list[SchemaEffect]: 54 | sp_effect_type = SpEffectType(sp_effect["stateInfo"]) 55 | effects = get_effects(sp_effect, sp_effect_type, init_conditions=[str(add_condition)] if add_condition else None) 56 | 57 | for ref_id in (sp_effect[ref_field].as_int for ref_field in _REFERENCE_EFFECT_PARAMS): 58 | if ref_sp_effect := sp_effects.get(ref_id): 59 | if ref_sp_effect.index > 0: 60 | effects += get_effects(ref_sp_effect, sp_effect_type, sp_effect) 61 | 62 | for condition_offset in hardcoded_effects.get_conditions(sp_effect.index): 63 | ref_sp_effect = sp_effects[sp_effect.index + condition_offset.offset] 64 | init_conditions = None if condition_offset.condition is None else [str(condition_offset.condition)] 65 | effects += get_effects(ref_sp_effect, sp_effect_type, sp_effect, init_conditions) 66 | 67 | return effects 68 | 69 | def get_status_effect(sp_effect: ParamRow) -> tuple[str, int]: 70 | # NOTE: not identifying effects by values, relying on `stateInfo` to be correct at all times 71 | etype = SpEffectType(sp_effect["stateInfo"]) 72 | return _SP_EFFECT_TO_STR[etype], sp_effect[_SP_EFFECT_TO_FIELD[etype]].as_int 73 | 74 | def parse_effects(row: ParamRow, sp_effects: ParamDict, *effect_referencing_fields: str, add_condition: AttackCondition | None = None) -> list[dict]: 75 | effects: list[SchemaEffect] = [] 76 | 77 | for effect_id in (row[ref_field].as_int for ref_field in effect_referencing_fields): 78 | if effect_id in hardcoded_effects.get_status_effect_ranges(): 79 | continue 80 | 81 | if effect_id in sp_effects: 82 | effects += get_effects_nested(sp_effects[effect_id], sp_effects, add_condition) 83 | 84 | return [e.to_dict() for e in aggregate_effects(effects)] 85 | 86 | def parse_status_effects(effect_ids: list[int], sp_effects: ParamDict) -> StatusEffects: 87 | # Getting 0th effect if value no found, bug with Antspur Rapier -- get anything to return a 0 status effect 88 | effects = [sp_effects.get(i, sp_effects[0]) for i in effect_ids if i != -1] 89 | status_effects = hardcoded_effects.get_status_effect_ranges() 90 | return StatusEffects(**dict([get_status_effect(e) for e in effects if e.index in status_effects])) 91 | 92 | def parse_weapon_effects(weapon: ParamRow) -> list[dict]: 93 | effects: list[SchemaEffect] = [] 94 | 95 | for field, attrib_field in attrib_fields.get(weapon=True).items(): 96 | if weapon[field] != str(attrib_field.default_value): 97 | effects.append(SchemaEffect.from_attribute_field(weapon[field].as_float, attrib_field)) 98 | 99 | return [e.to_dict() for e in effects] -------------------------------------------------------------------------------- /src/erdb/effect_parser/aggregator.py: -------------------------------------------------------------------------------- 1 | import operator as op 2 | from types import SimpleNamespace 3 | from typing import NamedTuple, Self 4 | 5 | from erdb.typing.effects import AttributeName, SchemaEffect 6 | 7 | 8 | _A = AttributeName 9 | 10 | class AttributeAggregatorHint(NamedTuple): 11 | base: set[AttributeName] 12 | effective: AttributeName 13 | 14 | class AggregatedSchemaEffect(SimpleNamespace): 15 | attribute_names: set[AttributeName] 16 | example_effect: SchemaEffect 17 | 18 | @classmethod 19 | def from_effect(cls, effect: SchemaEffect) -> Self: 20 | return cls(attribute_names={effect.attribute}, example_effect=effect) 21 | 22 | """ 23 | Specifies which attribute sets can be collapsed into their effective attribute. 24 | ORDER IS IMPORTANT because effective attributes are taken into consideration in 25 | consecutive iterations. 26 | """ 27 | _AGGREGATOR_HINTS: list[AttributeAggregatorHint] = [ 28 | AttributeAggregatorHint( 29 | base={_A.STANDARD_ABSORPTION, _A.STRIKE_ABSORPTION, _A.SLASH_ABSORPTION, _A.PIERCE_ABSORPTION}, 30 | effective=_A.PHYSICAL_ABSORPTION 31 | ), 32 | AttributeAggregatorHint( 33 | base={_A.MAGIC_ABSORPTION, _A.FIRE_ABSORPTION, _A.LIGHTNING_ABSORPTION, _A.HOLY_ABSORPTION}, 34 | effective=_A.ELEMENTAL_ABSORPTION 35 | ), 36 | AttributeAggregatorHint( 37 | base={_A.PHYSICAL_ABSORPTION, _A.ELEMENTAL_ABSORPTION}, 38 | effective=_A.ABSORPTION 39 | ), 40 | AttributeAggregatorHint( 41 | base={_A.STANDARD_ATTACK_POWER, _A.STRIKE_ATTACK_POWER, _A.SLASH_ATTACK_POWER, _A.PIERCE_ATTACK_POWER}, 42 | effective=_A.PHYSICAL_ATTACK_POWER 43 | ), 44 | AttributeAggregatorHint( 45 | base={_A.MAGIC_ATTACK_POWER, _A.FIRE_ATTACK_POWER, _A.LIGHTNING_ATTACK_POWER, _A.HOLY_ATTACK_POWER}, 46 | effective=_A.ELEMENTAL_ATTACK_POWER 47 | ), 48 | AttributeAggregatorHint( 49 | base={_A.PHYSICAL_ATTACK_POWER, _A.ELEMENTAL_ATTACK_POWER}, 50 | effective=_A.ATTACK_POWER 51 | ), 52 | AttributeAggregatorHint( 53 | base={_A.POISON_RESISTANCE, _A.SCARLET_ROT_RESISTANCE}, 54 | effective=_A.IMMUNITY 55 | ), 56 | AttributeAggregatorHint( 57 | base={_A.BLEED_RESISTANCE, _A.FROSTBITE_RESISTANCE}, 58 | effective=_A.ROBUSTNESS 59 | ), 60 | AttributeAggregatorHint( 61 | base={_A.SLEEP_RESISTANCE, _A.MADNESS_RESISTANCE}, 62 | effective=_A.FOCUS 63 | ), 64 | AttributeAggregatorHint( 65 | base={_A.DEATH_BLIGHT_RESISTANCE}, 66 | effective=_A.VITALITY 67 | ), 68 | AttributeAggregatorHint( 69 | base={_A.SORCERY_FOCUS_CONSUMPTION, _A.INCANTATION_FOCUS_CONSUMPTION, _A.PYROMANCY_FOCUS_CONSUMPTION}, 70 | effective=_A.SPELL_FOCUS_CONSUMPTION 71 | ), 72 | AttributeAggregatorHint( 73 | base={_A.SORCERY_FOCUS_CONSUMPTION, _A.INCANTATION_FOCUS_CONSUMPTION}, # since pyromancies are unused 74 | effective=_A.SPELL_FOCUS_CONSUMPTION 75 | ), 76 | ] 77 | 78 | def _get_aggregated_effects(effects: list[SchemaEffect]) -> dict[int, AggregatedSchemaEffect]: 79 | aggregated_effects: dict[int, AggregatedSchemaEffect] = dict() 80 | 81 | for effect in effects: 82 | if (key := effect.get_values_hash()) in aggregated_effects: 83 | aggregated_effects[key].attribute_names.add(effect.attribute) 84 | else: 85 | aggregated_effects[key] = AggregatedSchemaEffect.from_effect(effect) 86 | 87 | return aggregated_effects 88 | 89 | def _aggregate_attributes(attributes: set[AttributeName], hints: list[AttributeAggregatorHint]) -> set[AttributeName]: 90 | for hint in hints: 91 | if hint.base.issubset(attributes): 92 | attributes.difference_update(hint.base) 93 | attributes.add(hint.effective) 94 | 95 | return attributes 96 | 97 | def _aggregated_effects_to_effects(aggregated_effects: dict[int, AggregatedSchemaEffect]) -> list[SchemaEffect]: 98 | effects = [] 99 | 100 | for aggregated_effect in aggregated_effects.values(): 101 | for attribute_name in aggregated_effect.attribute_names: 102 | effects.append(aggregated_effect.example_effect.clone(attribute_name)) 103 | 104 | return effects 105 | 106 | def aggregate_effects(base_effects: list[SchemaEffect]) -> list[SchemaEffect]: 107 | aggregated_effects = _get_aggregated_effects(base_effects) 108 | 109 | for key, aggregated_effect in aggregated_effects.items(): 110 | aggregated_effects[key].attribute_names = _aggregate_attributes(aggregated_effect.attribute_names, _AGGREGATOR_HINTS) 111 | 112 | return sorted(_aggregated_effects_to_effects(aggregated_effects), key=op.attrgetter("attribute")) -------------------------------------------------------------------------------- /src/erdb/effect_parser/hardcoded.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, NamedTuple, Self, Tuple, Union 2 | 3 | from erdb.typing.effects import EffectModel, EffectType, AttributeName, SchemaEffect 4 | from erdb.typing.enums import SpEffectType, AttackType, AttackCondition 5 | 6 | 7 | class SpEffectConditionOffset(NamedTuple): 8 | condition: Union[None, SpEffectType, AttackType, AttackCondition] 9 | offset: int 10 | 11 | class SpEffectRanges(NamedTuple): 12 | class IntRange(NamedTuple): 13 | begin: int 14 | end: int 15 | 16 | def __contains__(self, __x: object) -> bool: 17 | assert isinstance(__x, int) 18 | return self.begin <= __x <= self.end 19 | 20 | ranges: List[IntRange] 21 | 22 | def __contains__(self, __x: object) -> bool: 23 | if isinstance(__x, str): 24 | return int(__x) in self 25 | 26 | if not isinstance(__x, int): 27 | return False 28 | 29 | return any(__x in r for r in self.ranges) 30 | 31 | @classmethod 32 | def construct(cls, *ranges: Tuple[int, int]) -> Self: 33 | return cls([cls.IntRange(r[0], r[1]) for r in ranges]) 34 | 35 | """ 36 | Some SpEffects don't seem to have anything that identify what they do (Greatshield Talisman) 37 | and/or are simply too difficult to parse (Winged Sword Insignia). They are listed here for visibility. 38 | 39 | The list for each SpEffect ID should contain only the problematic effect(s). If any effect is parsable, 40 | it will be appended to this list automatically during generation. 41 | 42 | !! THIS LIST MUST BE CONFIRMED AFTER EACH UPDATE !! 43 | Last confirmed ER version: 1.04.1 44 | """ 45 | _FROM_ID: Dict[int, List[SchemaEffect]] = { 46 | # >> Greatshield Talisman 47 | # This effect does not seem to utilize `guardStaminaCutRate`, unlike other shield buffs. 48 | 341000: [ 49 | SchemaEffect( 50 | attribute=AttributeName.STABILITY, 51 | effect_model=EffectModel.MULTIPLICATIVE, 52 | effect_type=EffectType.POSITIVE, 53 | value=1.1 54 | ) 55 | ], 56 | # >> Concealing Veil 57 | # Its only identifying property is the unique `invocationConditionsStateChange1` "Trigger on Crouch" 58 | 360100: [ 59 | SchemaEffect( 60 | attribute=AttributeName.INVISIBLE_AT_DISTANCE, 61 | conditions=[str(SpEffectType.TRIGGER_ON_CROUCH)], 62 | effect_model=EffectModel.ADDITIVE, 63 | effect_type=EffectType.POSITIVE, 64 | value=1 65 | ) 66 | 67 | ], 68 | # >> Furled Finger's Trick-Mirror 69 | # Nothing is identifying this SpEffect besides its ID 70 | 360800: [ 71 | SchemaEffect( 72 | attribute=AttributeName.APPEAR_AS_HOST, 73 | effect_model=EffectModel.ADDITIVE, 74 | effect_type=EffectType.NEUTRAL, 75 | value=1 76 | ) 77 | ], 78 | # >> Host's Trick-Mirror 79 | # Nothing is identifying this SpEffect besides its ID 80 | 360900: [ 81 | SchemaEffect( 82 | attribute=AttributeName.APPEAR_AS_COOPERATOR, 83 | effect_model=EffectModel.ADDITIVE, 84 | effect_type=EffectType.NEUTRAL, 85 | value=1 86 | ) 87 | ], 88 | # >> Shabriri's Woe 89 | # Nothing is identifying this SpEffect besides its ID 90 | 360500: [ 91 | SchemaEffect( 92 | attribute=AttributeName.ATTRACT_ENEMY_AGGRESSION, 93 | effect_model=EffectModel.ADDITIVE, 94 | effect_type=EffectType.NEUTRAL, 95 | value=1 96 | ) 97 | ], 98 | } 99 | 100 | """ 101 | Some SpEffects reference other SpEffects by themselves or their stateInfo. Some of them can be parsed, 102 | while others can be problematic. This is the list of problematic SpEffects which hardcodes the offsets 103 | of the effects they are referencing, along with a potential condition which activates them. 104 | """ 105 | _FROM_OFFSET: Dict[int, list[SpEffectConditionOffset]] = { 106 | 350400: [ # Godskin Swaddling Cloth 107 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_HITS, 1) 108 | ], 109 | 312500: [ # Millicent's Prosthesis 110 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_3_HITS, 5), 111 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_6_HITS, 6), 112 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_9_HITS, 7), 113 | ], 114 | 320800: [ # Winged Sword Insignia 115 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_3_HITS, 4), 116 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_6_HITS, 5), 117 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_9_HITS, 6), 118 | ], 119 | 320810: [ # Rotten Winged Sword Insignia 120 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_3_HITS, 4), 121 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_6_HITS, 5), 122 | SpEffectConditionOffset(AttackCondition.SUCCESSIVE_9_HITS, 6), 123 | ], 124 | 350500: [ # Assassin's Crimson Dagger 125 | SpEffectConditionOffset(None, 2) # None -- the referenced effect contains conditions 126 | ], 127 | 350600: [ # Assassin's Cerulean Dagger 128 | SpEffectConditionOffset(None, 2) # None -- the referenced effect contains conditions 129 | ] 130 | } 131 | 132 | """ 133 | Some SpEffects types define effects themselves without affecting any attributes. 134 | """ 135 | _FROM_TYPE: Dict[SpEffectType, List[SchemaEffect]] = { 136 | # >> Crucible Knot Talisman + IDs: 9648, 6044000 137 | # Crucible Knot Talisman does not seem to define exactly how much the damage is reduced. 138 | SpEffectType.REDUCE_HEADSHOT_IMPACT: [ 139 | SchemaEffect( 140 | attribute=AttributeName.REDUCE_HEADSHOT_IMPACT, 141 | effect_model=EffectModel.ADDITIVE, 142 | effect_type=EffectType.POSITIVE, 143 | value=1 144 | ) 145 | ], 146 | # >> Entwining Umbilical Cord 147 | SpEffectType.SWITCH_ANIMATION_GENDER: [ 148 | SchemaEffect( 149 | attribute=AttributeName.SWITCH_ANIMATION_GENDER, 150 | effect_model=EffectModel.ADDITIVE, 151 | effect_type=EffectType.NEUTRAL, 152 | value=1 153 | ) 154 | ], 155 | # >> Sacrificial Twig 156 | SpEffectType.DESTROY_ACCESSORY_BUT_SAVE_RUNES: [ 157 | SchemaEffect( 158 | attribute=AttributeName.PRESERVE_RUNES_ON_DEATH, 159 | effect_model=EffectModel.ADDITIVE, 160 | effect_type=EffectType.POSITIVE, 161 | value=1 162 | ), 163 | SchemaEffect( 164 | attribute=AttributeName.DESTROY_ITEM_ON_DEATH, 165 | effect_model=EffectModel.ADDITIVE, 166 | effect_type=EffectType.NEGATIVE, 167 | value=1 168 | ), 169 | ], 170 | } 171 | 172 | def get(index: int, sp_effect_type) -> List[SchemaEffect]: 173 | return _FROM_ID.get(index, []) + _FROM_TYPE.get(sp_effect_type, []) 174 | 175 | def get_conditions(index: int) -> List[SpEffectConditionOffset]: 176 | return _FROM_OFFSET.get(index, []) 177 | 178 | def get_status_effect_ranges() -> SpEffectRanges: 179 | return SpEffectRanges.construct((6400, 6810), (105000, 109000)) -------------------------------------------------------------------------------- /src/erdb/effect_parser/parsers.py: -------------------------------------------------------------------------------- 1 | import math 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import SpEffectType, AttackType 4 | from erdb.typing.effects import AttributeField, EffectModel 5 | 6 | 7 | _TRIGGER_FIELDS = [ 8 | "stateInfo", "invocationConditionsStateChange1", 9 | "invocationConditionsStateChange2", "invocationConditionsStateChange3" 10 | ] 11 | 12 | _CONDITION_FIELDS = [ 13 | "magicSubCategoryChange1", "magicSubCategoryChange2", "magicSubCategoryChange3" 14 | ] 15 | 16 | _PVE_TO_PVP = { 17 | "defEnemyDmgCorrectRate_Physics": "defPlayerDmgCorrectRate_Physics", 18 | "defEnemyDmgCorrectRate_Magic": "defPlayerDmgCorrectRate_Magic", 19 | "defEnemyDmgCorrectRate_Fire": "defPlayerDmgCorrectRate_Fire", 20 | "defEnemyDmgCorrectRate_Thunder": "defPlayerDmgCorrectRate_Thunder", 21 | "defEnemyDmgCorrectRate_Dark": "defPlayerDmgCorrectRate_Dark", 22 | "atkEnemyDmgCorrectRate_Physics": "atkPlayerDmgCorrectRate_Physics", 23 | "atkEnemyDmgCorrectRate_Magic": "atkPlayerDmgCorrectRate_Magic", 24 | "atkEnemyDmgCorrectRate_Fire": "atkPlayerDmgCorrectRate_Fire", 25 | "atkEnemyDmgCorrectRate_Thunder": "atkPlayerDmgCorrectRate_Thunder", 26 | "atkEnemyDmgCorrectRate_Dark": "atkPlayerDmgCorrectRate_Dark", 27 | } 28 | 29 | def _floor_decimal_2(value: float) -> float: 30 | value = round(value, 6) # do not floor cases like 1.89999999999 31 | return math.floor(value * 100) / 100.0 32 | 33 | def conditions(sp_effect: ParamRow, triggeree: ParamRow | None = None) -> list[str] | None: 34 | conds = set() 35 | 36 | def _append_triggers(source: ParamRow): 37 | for trigger in _TRIGGER_FIELDS: 38 | effect_type = SpEffectType(source[trigger]) 39 | 40 | if not effect_type.is_passive(): 41 | conds.add(str(effect_type)) 42 | 43 | if effect_type == SpEffectType.SPELL_POWER_BOOST: 44 | boostSorcery = ("magParamChange", "Affects Sorceries") 45 | boostIncantation = ("miracleParamChange", "Affects Incantations") 46 | for field, cond in [boostSorcery, boostIncantation]: 47 | if sp_effect[field].as_bool: 48 | conds.add(cond) 49 | 50 | def _append_conditions(source: ParamRow): 51 | for cond in _CONDITION_FIELDS: 52 | attack_type_str = source[cond] 53 | if (cond := AttackType(attack_type_str)) != AttackType.NONE: 54 | conds.add(str(cond)) 55 | 56 | for field, direction in [("conditionHp", "below"), ("conditionHpRate", "above")]: 57 | if cond := sp_effect[field].get_int(): 58 | conds.add(f"HP {direction} {cond}%") 59 | 60 | _append_triggers(sp_effect) 61 | _append_conditions(sp_effect) 62 | 63 | if triggeree: 64 | _append_triggers(triggeree) 65 | _append_conditions(triggeree) 66 | 67 | return None if len(conds) == 0 else sorted(list(conds)) 68 | 69 | def interval(sp_effect: ParamRow) -> float | None: 70 | interv = sp_effect["motionInterval"] 71 | return None if interv == "0" else float(interv) 72 | 73 | def value_pvp(sp_effect: ParamRow, field_pve: str, attrib_fields: dict[str, AttributeField]) -> float | None: 74 | if not (field_pvp := _PVE_TO_PVP.get(field_pve, None)): 75 | return None 76 | 77 | attrib_field = attrib_fields[field_pve] 78 | val = sp_effect[field_pvp].as_float 79 | 80 | return attrib_field.parser(val, attrib_field.effect_model) 81 | 82 | def generic(value: float, model: EffectModel) -> float: 83 | return value 84 | 85 | def generic_inverse(value: float, model: EffectModel) -> float: 86 | """ 87 | Some sp_effect fields are hardcoded to be subtractable/divisible. Because of this, 88 | their values are negative in the params. Specifying this parser will reverse 89 | these values on per effect basis to have them make more sense. 90 | 91 | Examples: 92 | - Blessed Dew Talisman's `changeHpPoint` is set to -2 when it should heal 93 | - Malenia - Scarlet Rot's `changeHpPoint` is set to 26 when it should damage 94 | """ 95 | return -value if model == EffectModel.ADDITIVE else _floor_decimal_2(2 - value) 96 | 97 | def generic_inverse_percentage(value: float, model: EffectModel) -> float: 98 | """ 99 | Flat percentage value, but also inverse. Turn -10 into 1.1 (ex. Assassin's Crimson Dagger). 100 | """ 101 | return 1 + (-value / 100.0) 102 | 103 | def poise(value: float, model: EffectModel) -> float: 104 | """ 105 | Poise value is given as a "poise damage absorption" value, eg. `0.75`. 106 | The actual poise increase that is shown in game is the inverse of that: 107 | 1 / .75 -> +33% poise. 108 | """ 109 | return math.floor(1 / value * 100) / 100.0 110 | 111 | def item_discovery(value: float, model: EffectModel) -> float: 112 | return value * 100 -------------------------------------------------------------------------------- /src/erdb/loaders/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.resources 2 | from pathlib import Path 3 | 4 | from erdb.typing.game_version import GameVersion 5 | 6 | 7 | TOP_LEVEL_PKG = __name__.split(".")[0] 8 | PKG_DATA_PATH = Path(str(importlib.resources.files(TOP_LEVEL_PKG))) / "data" 9 | GAME_VERSIONS = sorted( 10 | [GameVersion.from_string(p.stem) for p in (PKG_DATA_PATH / "gamedata").glob("*zip")], 11 | reverse=True 12 | ) -------------------------------------------------------------------------------- /src/erdb/loaders/contrib.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from erdb.typing.game_version import GameVersion, GameVersionRange 5 | from erdb.loaders import PKG_DATA_PATH 6 | 7 | 8 | def _overlay_properties(ret: dict, source: dict): 9 | """ 10 | Append to lists and sets, override scalars and dictionaries 11 | """ 12 | for key, value in source.items(): 13 | if isinstance(value, list): 14 | ret[key] = ret.get(key, list()) + value 15 | elif isinstance(value, set): 16 | ret[key] = ret.get(key, set()) + value # type: ignore 17 | else: 18 | ret[key] = value 19 | 20 | def _parse_user_file(path: Path, version: GameVersion) -> dict: 21 | assert path.is_file 22 | assert path.suffix == ".json" 23 | 24 | with open(path, "r", encoding="utf-8") as f: 25 | data = json.load(f) 26 | 27 | ret = dict() 28 | 29 | for version_range, properties in data.items(): 30 | if version in GameVersionRange.from_string(version_range): 31 | _overlay_properties(ret, properties) 32 | 33 | return ret 34 | 35 | def load(element: str, version: GameVersion) -> dict[str, dict]: 36 | path = PKG_DATA_PATH / "contrib" / element 37 | files = path.iterdir() if path.is_dir() else [] 38 | return {f.stem: _parse_user_file(f, version) for f in files} -------------------------------------------------------------------------------- /src/erdb/loaders/params.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | import xml.etree.ElementTree as xmltree 3 | from csv import DictReader 4 | from zipfile import Path as ZipPath 5 | 6 | from erdb.loaders import PKG_DATA_PATH 7 | from erdb.typing.game_version import GameVersion 8 | from erdb.typing.params import ParamRow, ParamDict 9 | from erdb.typing.enums import ItemIDFlag 10 | 11 | 12 | def _load(param: str, version: GameVersion) -> Iterator[dict[str, str]]: 13 | archive = PKG_DATA_PATH / "gamedata" / f"{version}.zip" 14 | with ZipPath(archive, at=f"{param}.csv").open(mode="r") as f: 15 | yield from DictReader(f, delimiter=";") # type: ignore 16 | 17 | def load(param: str, version: GameVersion, item_id_flag: ItemIDFlag) -> ParamDict: 18 | return {int(row["Row ID"]): ParamRow.make(row, item_id_flag) for row in _load(param, version)} 19 | 20 | # optimal variant for params with a lot of IDs like spEffects 21 | def load_ids(param: str, version: GameVersion, item_id_flag: ItemIDFlag, id_min: int, id_max: int = 999999999) -> ParamDict: 22 | return {int(row["Row ID"]): ParamRow.make(row, item_id_flag) for row in _load(param, version) if id_min <= int(row["Row ID"]) <= id_max} 23 | 24 | def load_msg(filename: str, version: GameVersion) -> dict[int, str]: 25 | archive = PKG_DATA_PATH / "gamedata" / f"{version}.zip" 26 | with ZipPath(archive, at=f"{filename}.fmg.xml").open(mode="r", encoding="utf-8") as f: 27 | tree = xmltree.fromstring(f.read()) 28 | entries = tree.findall(".//text") 29 | 30 | return {int(str(e.get("id"))): str(e.text) for e in entries if e.text != "%null%"} -------------------------------------------------------------------------------- /src/erdb/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/main/__init__.py -------------------------------------------------------------------------------- /src/erdb/main/app.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Sequence 4 | 5 | from erdb.main.args import parse_args 6 | from erdb.table import Table 7 | from erdb.loaders import GAME_VERSIONS 8 | from erdb.app_api.main import serve as serve_app_api 9 | from erdb.app_wiki import generate as generate_app_wiki 10 | from erdb.utils.attack_power import Attributes, CalculatorData, ArmamentCalculator 11 | from erdb.utils.changelog import generate as generate_changelog 12 | from erdb.utils.find_valid_values import find_valid_values 13 | from erdb.utils.sourcer import source_gamedata, source_map, source_icons 14 | from erdb.utils.common import Destination, pydantic_encoder_no_nulls 15 | from erdb.typing.game_version import GameVersion, GameVersionRange 16 | 17 | 18 | class App: 19 | args: dict 20 | 21 | def __init__(self, argv: Sequence[str]) -> None: 22 | self.args = parse_args(argv, handlers={ 23 | "generate": self.generate, 24 | "find-values": self.find_values, 25 | "calculate-ar": self.calculate_ar, 26 | "changelog": self.changelog, 27 | "source": self.source, 28 | "map": self.source_map, 29 | "icons": self.source_icons, 30 | "serve-api": self.serve_api, 31 | "generate-wiki": self.generate_wiki, 32 | }) 33 | 34 | def run(self) -> int: 35 | handler = self.args.pop("handler") 36 | return handler(**self.args) 37 | 38 | @staticmethod 39 | def generate(tables: list[Table], gamedata: GameVersionRange, minimize: bool, out: Path | None) -> int: 40 | if out is None: 41 | out = Path.cwd() 42 | else: 43 | out = out.resolve() 44 | 45 | for version in gamedata.iterate(GAME_VERSIONS): 46 | destination = out / str(version) 47 | destination.mkdir(parents=True, exist_ok=True) 48 | 49 | for tb, gen in ((tb, tb.make_generator(version)) for tb in tables): 50 | print(f"\n>>> Generating \"{tb}\" from version {version}", flush=True) 51 | 52 | output_file = destination / f"{tb}.json" 53 | print(f"Output file: {output_file}", flush=True) 54 | 55 | if output_file.exists(): 56 | print(f"Output file exists and will be overridden", flush=True) 57 | 58 | data = gen.generate() 59 | print(f"Generated {len(data)} elements", flush=True) 60 | 61 | with open(output_file, mode="w", encoding="utf-8") as f: 62 | kwargs = {"separators": (",", ":")} if minimize else {"indent": 4} 63 | json.dump(data, f, ensure_ascii=False, default=pydantic_encoder_no_nulls, allow_nan=False, **kwargs) 64 | 65 | return 0 66 | 67 | @staticmethod 68 | def find_values(param: str, field: str, limit: int, gamedata: GameVersionRange) -> int: 69 | for game_version in gamedata.iterate(GAME_VERSIONS): 70 | print(f"\n>>> Finding values for version {game_version}") 71 | find_valid_values(param, str(game_version), field, limit) 72 | 73 | return 0 74 | 75 | @staticmethod 76 | def calculate_ar(attribs: str, armament: str, affinity: str, level: int, data_path: Path) -> int: 77 | def load_data_file(name: str): 78 | with open(data_path / name) as f: 79 | return json.load(f) 80 | 81 | print(f"\n>>> Calculating AR for {affinity} {armament} +{level} at {attribs}") 82 | 83 | armaments = load_data_file("armaments.json") 84 | reinforcements = load_data_file("reinforcements.json") 85 | correction_attack = load_data_file("correction-attack.json") 86 | correction_graph = load_data_file("correction-graph.json") 87 | 88 | data = CalculatorData(armaments, reinforcements, correction_attack, correction_graph) 89 | calc = ArmamentCalculator(data, armament, affinity, level) 90 | attr = Attributes.from_string(attribs) 91 | 92 | for attack_type, value in calc.attack_power(attr).items(): 93 | print(f"{attack_type}: {value.base} +{value.scaling} ({value.total})") 94 | 95 | for effect_type, value in calc.status_effects(attr).items(): 96 | print(f"{effect_type}: {value.base} +{value.scaling} ({value.total})") 97 | 98 | return 0 99 | 100 | @staticmethod 101 | def changelog(version: GameVersion, from_version: GameVersion | None, formatter: str, out: Path | None) -> int: 102 | assert version in GAME_VERSIONS, f"No {version} version found" 103 | assert from_version is None or from_version in GAME_VERSIONS, f"No {from_version} version found" 104 | 105 | if out is not None: 106 | out = out.resolve() 107 | 108 | if from_version is None: 109 | prev_id = GAME_VERSIONS.index(version) + 1 110 | assert prev_id < len(GAME_VERSIONS), f"No version found before {version}" 111 | 112 | from_version = GAME_VERSIONS[prev_id] 113 | 114 | generate_changelog(from_version, version, out, formatter) 115 | return 0 116 | 117 | @staticmethod 118 | def source(version: GameVersion | None, game_dir: Path, ignore_checksum: bool, keep_cache: bool) -> int: 119 | game_dir = game_dir.resolve() 120 | 121 | print(f"\n>>> Sourcing gamedata from \"{game_dir}\".") 122 | 123 | try: 124 | source_gamedata(game_dir, ignore_checksum, version) 125 | 126 | except AssertionError as e: 127 | print("Sourcing gamedata failed:", *e.args) 128 | return 1 129 | 130 | return 0 131 | 132 | @staticmethod 133 | def source_map(lod: int, underground: bool, game_dir: Path, ignore_checksum: bool, keep_cache: bool, out: Path | None) -> int: 134 | game_dir = game_dir.resolve() 135 | 136 | if out is not None: 137 | out = out.resolve() 138 | 139 | print(f"\n>>> Extracting map from \"{game_dir}\".") 140 | 141 | try: 142 | source_map(game_dir, out, lod, underground, ignore_checksum, keep_cache) 143 | 144 | except AssertionError as e: 145 | print("Sourcing map failed:", *e.args) 146 | return 1 147 | 148 | return 0 149 | 150 | @staticmethod 151 | def source_icons(types: list[Table], size: int, file_format: str, game_dir: Path, ignore_checksum: bool, keep_cache: bool, out: Destination | None) -> int: 152 | game_dir = game_dir.resolve() 153 | 154 | if out is None: 155 | out = Destination.from_str(str(Path.cwd())) 156 | 157 | print(f"\n>>> Extracting {', '.join(map(str, types))} icons from \"{game_dir}\".") 158 | 159 | try: 160 | source_icons(game_dir, types, size, file_format, out, ignore_checksum, keep_cache) 161 | 162 | except AssertionError as e: 163 | print("Sourcing icons failed:", *e.args) 164 | return 1 165 | 166 | return 0 167 | 168 | @staticmethod 169 | def serve_api(port: int, bind: str, precache: bool) -> int: 170 | serve_app_api(port, bind=bind, precache=precache) 171 | return 0 172 | 173 | @staticmethod 174 | def generate_wiki(uikit_version: str | None, pyscript_version: str | None, data_path: Path, minimize: bool, out: Path | None) -> int: 175 | data_path = data_path.resolve() 176 | out = Path.cwd() / "erdb.wiki" if out is None else out.resolve() 177 | 178 | generate_app_wiki(uikit_version, pyscript_version, data_path, minimize, out) 179 | return 0 -------------------------------------------------------------------------------- /src/erdb/main/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Sequence 3 | 4 | from erdb.main.app import App 5 | 6 | 7 | def entrypoint(argv: Sequence[str] | None = None) -> int: 8 | app = App(sys.argv[1:] if argv is None else argv) 9 | return app.run() -------------------------------------------------------------------------------- /src/erdb/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/py.typed -------------------------------------------------------------------------------- /src/erdb/shop/__init__.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.params import ParamDict 2 | from erdb.shop.shop_typing import Material, Lineup, Product 3 | 4 | 5 | """ 6 | Helper class for looking up any sort of item exchanges (purchases, alterations, crafting...) 7 | """ 8 | class Lookup(object): 9 | _shop_lineup: ParamDict 10 | _material_sets: ParamDict 11 | 12 | def __init__(self, shop_lineup: ParamDict, material_sets: ParamDict) -> None: 13 | self._shop_lineup = shop_lineup 14 | self._material_sets = material_sets 15 | 16 | def get_lineups_from_material(self, material: Material) -> list[Lineup]: 17 | lineups: list[Lineup] = [] 18 | 19 | for lineup_param in self._shop_lineup.values(): 20 | if mat_id := lineup_param["mtrlId"].get_int(): 21 | lineup = Lineup.from_params(lineup_param, self._material_sets[mat_id]) 22 | if material in lineup.materials.keys(): 23 | lineups.append(lineup) 24 | 25 | return lineups -------------------------------------------------------------------------------- /src/erdb/shop/shop_typing.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import Dict, List, NamedTuple, Self 3 | 4 | from erdb.typing.params import ParamRow 5 | 6 | 7 | class Material(NamedTuple): 8 | class Category(IntEnum): 9 | NONE = 0 10 | PROTECTOR = 1 11 | GOOD = 4 12 | UNKNOWN = 15 13 | 14 | index: int 15 | category: Category 16 | 17 | def __hash__(self) -> int: 18 | return hash((self.index, self.category)) 19 | 20 | def __eq__(self, __o: object) -> bool: 21 | if not isinstance(__o, Material): 22 | return False 23 | return self.index == __o.index and self.category == __o.category 24 | 25 | class Product(NamedTuple): 26 | class Category(IntEnum): 27 | WEAPON = 0 28 | PROTECTOR = 1 29 | ACCESSORY = 2 30 | GOOD = 3 31 | ASHES = 4 32 | 33 | index: int 34 | category: Category 35 | 36 | def __hash__(self) -> int: 37 | return hash((self.index, self.category)) 38 | 39 | def __eq__(self, __o: object) -> bool: 40 | if not isinstance(__o, Product): 41 | return False 42 | return self.index == __o.index and self.category == __o.category 43 | 44 | class Currency(IntEnum): 45 | RUNES = 0 46 | DRAGON_HEART = 1 47 | STARLIGHT_SHARD = 2 48 | UNKNOWN = 3 49 | LOST_ASHES_OF_WAR = 4 50 | 51 | class MaterialSetParams(NamedTuple): 52 | index: str 53 | category: str 54 | quantity: str 55 | 56 | _MATERIAL_SET_PARAM_LIST: List[MaterialSetParams] = [ 57 | MaterialSetParams("materialId01", "materialCate01", "itemNum01"), 58 | MaterialSetParams("materialId02", "materialCate02", "itemNum02"), 59 | MaterialSetParams("materialId03", "materialCate03", "itemNum03"), 60 | MaterialSetParams("materialId04", "materialCate04", "itemNum04"), 61 | MaterialSetParams("materialId05", "materialCate05", "itemNum05"), 62 | MaterialSetParams("materialId06", "materialCate06", "itemNum06"), 63 | ] 64 | 65 | class Lineup(NamedTuple): 66 | product: Product 67 | price: int=0 68 | materials: Dict[Material, int]={} # material -> quantity 69 | currency: Currency=Currency.RUNES 70 | 71 | @classmethod 72 | def from_params(cls, lineup_param: ParamRow, material_set: ParamRow) -> Self: 73 | product = Product(lineup_param["equipId"].as_int, Product.Category(lineup_param["equipType"].as_int)) 74 | materials: Dict[Material, int] = {} 75 | 76 | for param in _MATERIAL_SET_PARAM_LIST: 77 | if mat_id := material_set[param.index].get_int(): 78 | category = Material.Category(material_set[param.category].as_int) 79 | materials[Material(mat_id, category)] = material_set[param.quantity].as_int 80 | 81 | return cls( 82 | product=product, 83 | price=lineup_param["value"].as_int, 84 | materials=materials, 85 | currency=Currency(lineup_param["costType"].as_int) 86 | ) -------------------------------------------------------------------------------- /src/erdb/table/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from typing import Any, Self, NamedTuple 3 | 4 | from erdb.table._retrievers import RetrieverData 5 | from erdb.table._common import TableSpec 6 | from erdb.table.ammo import AmmoTableSpec 7 | from erdb.table.armaments import ArmamentTableSpec 8 | from erdb.table.armor import ArmorTableSpec 9 | from erdb.table.ashes_of_war import AshOfWarTableSpec 10 | from erdb.table.bolstering_materials import BolsteringMaterialTableSpec 11 | from erdb.table.correction_attack import CorrectionAttackTableSpec 12 | from erdb.table.correction_graph import CorrectionGraphTableSpec 13 | from erdb.table.crafting_materials import CraftingMaterialTableSpec 14 | from erdb.table.gestures import GestureTableSpec 15 | from erdb.table.info import InfoTableSpec 16 | from erdb.table.keys import KeyTableSpec 17 | from erdb.table.reinforcements import ReinforcementTableSpec 18 | from erdb.table.shop import ShopTableSpec 19 | from erdb.table.spells import SpellTableSpec 20 | from erdb.table.spirit_ashes import SpiritAshTableSpec 21 | from erdb.table.talismans import TalismanTableSpec 22 | from erdb.table.tools import ToolTableSpec 23 | from erdb.typing.game_version import GameVersion 24 | from erdb.typing.api_version import ApiVersion 25 | from erdb.typing.params import ParamRow 26 | 27 | 28 | class Generator(NamedTuple): 29 | spec: TableSpec 30 | data: RetrieverData 31 | 32 | def generate(self, api: ApiVersion | None = None) -> dict: 33 | api = self.spec.latest_api() if api is None else api 34 | 35 | def key(row: ParamRow) -> Any: 36 | return self.spec.get_pk(self.data, row) 37 | 38 | def value(row: ParamRow) -> Any: 39 | return self.spec.make_object(api, self.data, row) 40 | 41 | def valid(row: ParamRow) -> bool: 42 | return all(pred(row) for pred in self.spec.predicates) 43 | 44 | rows = self.data.main_param.values() 45 | return {key(row): value(row) for row in rows if valid(row)} 46 | 47 | @classmethod 48 | def create(cls, spec: TableSpec, version: GameVersion) -> Self: 49 | def retrieve_dict(retrievers: dict): 50 | return {field: retrievers[field].get(version) for field in retrievers.keys()} 51 | 52 | return cls( 53 | spec, 54 | RetrieverData( 55 | spec.main_param_retriever.get(version), 56 | retrieve_dict(spec.param_retrievers), 57 | retrieve_dict(spec.msg_retrievers), 58 | retrieve_dict(spec.shop_retrievers), 59 | spec.contrib_retriever.get(spec.title(), version), 60 | ) 61 | ) 62 | 63 | class Table(StrEnum): 64 | ALL = "all" 65 | AMMO = "ammo" 66 | ARMAMENTS = "armaments" 67 | ARMOR = "armor" 68 | ASHES_OF_WAR = "ashes-of-war" 69 | BOLSTERING_MATERIALS = "bolstering-materials" 70 | CORRECTION_ATTACK = "correction-attack" 71 | CORRECTION_GRAPH = "correction-graph" 72 | CRAFTING_MATERIALS = "crafting-materials" 73 | GESTURES = "gestures" 74 | INFO = "info" 75 | KEYS = "keys" 76 | REINFORCEMENTS = "reinforcements" 77 | SHOP = "shop" 78 | SPELLS = "spells" 79 | SPIRIT_ASHES = "spirit-ashes" 80 | TASLISMANS = "talismans" 81 | TOOLS = "tools" 82 | 83 | def __str__(self): 84 | return self.value 85 | 86 | def __lt__(self, other: Self): 87 | assert isinstance(other, Table) 88 | return self.value < other.value 89 | 90 | def make_generator(self, version: GameVersion) -> Generator: 91 | return Generator.create(self.spec, version) 92 | 93 | @property 94 | def spec(self) -> TableSpec: 95 | return { 96 | Table.AMMO: AmmoTableSpec, 97 | Table.ARMAMENTS: ArmamentTableSpec, 98 | Table.ARMOR: ArmorTableSpec, 99 | Table.ASHES_OF_WAR: AshOfWarTableSpec, 100 | Table.BOLSTERING_MATERIALS: BolsteringMaterialTableSpec, 101 | Table.CORRECTION_ATTACK: CorrectionAttackTableSpec, 102 | Table.CORRECTION_GRAPH: CorrectionGraphTableSpec, 103 | Table.CRAFTING_MATERIALS: CraftingMaterialTableSpec, 104 | Table.GESTURES: GestureTableSpec, 105 | Table.INFO: InfoTableSpec, 106 | Table.KEYS: KeyTableSpec, 107 | Table.REINFORCEMENTS: ReinforcementTableSpec, 108 | Table.SHOP: ShopTableSpec, 109 | Table.SPELLS: SpellTableSpec, 110 | Table.SPIRIT_ASHES: SpiritAshTableSpec, 111 | Table.TASLISMANS: TalismanTableSpec, 112 | Table.TOOLS: ToolTableSpec, 113 | }[self] 114 | 115 | @property 116 | def param_name(self) -> str: 117 | return self.spec.main_param_retriever.param_name 118 | 119 | @property 120 | def title(self) -> str: 121 | return str(self).replace("-", " ").title() 122 | 123 | @classmethod 124 | def effective(cls) -> list[Self]: 125 | s = set(Table) 126 | s.remove(Table.ALL) 127 | return list(s) -------------------------------------------------------------------------------- /src/erdb/table/_common.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Protocol 2 | from unicodedata import normalize, combining 3 | 4 | from erdb.utils.common import get_filename 5 | from erdb.typing.enums import GoodsRarity 6 | from erdb.typing.params import ParamRow 7 | from erdb.typing.api_version import ApiVersion 8 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, ShopRetriever, ContribRetriever, RetrieverData 9 | 10 | 11 | RowPredicate = Callable[[ParamRow], bool] 12 | 13 | def _remove_accents(string: str) -> str: 14 | nfkd_form = normalize("NFKD", string) 15 | return "".join(c for c in nfkd_form if not combining(c)) 16 | 17 | class TableSpec(Protocol): 18 | model: dict[ApiVersion, Any] 19 | 20 | predicates: list[RowPredicate] 21 | 22 | main_param_retriever: ParamDictRetriever 23 | param_retrievers: dict[str, ParamDictRetriever] 24 | msg_retrievers: dict[str, MsgsRetriever] 25 | shop_retrievers: dict[str, ShopRetriever] 26 | contrib_retriever: ContribRetriever 27 | 28 | @classmethod 29 | def title(cls) -> str: 30 | ... 31 | 32 | @classmethod 33 | def has_icons(cls) -> bool: 34 | ... 35 | 36 | @classmethod 37 | def latest_api(cls) -> ApiVersion: 38 | ... 39 | 40 | @classmethod 41 | def get_pk(cls, data: RetrieverData, row: ParamRow) -> str: 42 | ... 43 | 44 | @classmethod 45 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow) -> Any: 46 | ... 47 | 48 | class TableSpecContext: 49 | # specify defaults so providing everything isn't always necessary 50 | predicates: list[RowPredicate] = [] 51 | param_retrievers: dict[str, ParamDictRetriever] = {} 52 | msg_retrievers: dict[str, MsgsRetriever] = {} 53 | shop_retrievers: dict[str, ShopRetriever] = {} 54 | contrib_retriever: ContribRetriever = ContribRetriever() 55 | 56 | @classmethod # override 57 | def title(cls) -> str: 58 | return cls.__name__.removesuffix("TableSpec") 59 | 60 | @classmethod 61 | def has_icons(cls: TableSpec) -> bool: 62 | for model in cls.model.values(): 63 | return hasattr(model, "icon") 64 | assert False, "TableSpec model dict really should not be empty..." 65 | 66 | @classmethod # override 67 | def latest_api(cls: TableSpec) -> ApiVersion: 68 | return list(cls.model.keys())[-1] 69 | 70 | @classmethod # override 71 | def get_pk(cls, data: RetrieverData, row: ParamRow) -> str: 72 | assert "names" in data.msgs, "names were not parsed, override get_pk() for non-standard pk" 73 | return _remove_accents(cls.parse_name(data.msgs["names"][row.index])) 74 | 75 | @classmethod 76 | def parse_name(cls, name: str) -> str: 77 | return name.removeprefix("[ERROR]").strip() 78 | 79 | @classmethod 80 | def make_item(cls, data: RetrieverData, row: ParamRow, *, summary: bool = True, description: bool = True) -> dict[str, Any]: 81 | assert "names" in data.msgs, "make_item() cannot be called without names parsed" 82 | assert not summary or "summaries" in data.msgs, "Summary specified, yet no summaries were parsed" 83 | assert not description or "descriptions" in data.msgs, "Description specified, yet no descriptions were parsed" 84 | 85 | # individual items might not have summaries or descriptions 86 | summary = summary and row.index in data.msgs["summaries"] 87 | description = description and row.index in data.msgs["descriptions"] 88 | 89 | return { 90 | "full_hex_id": row.index_hex, 91 | "id": row.index, 92 | "name": cls.parse_name(data.msgs["names"][row.index]), 93 | "summary": data.msgs["summaries"][row.index] if summary else "no summary", 94 | "description": data.msgs["descriptions"][row.index].split("\n") if description else ["no description"], 95 | "is_tradable": not row["disableMultiDropShare"].as_bool, # assumption this exists for every param table 96 | "price_sold": row["sellValue"].get_int(0), # assumption this exists for every param table 97 | "rarity": GoodsRarity.from_id(row["rarity"].as_int), # assumption this exists for every param table 98 | "icon": row["iconId"].as_int if "iconId" in row else row["iconIdM"].as_int, 99 | "max_held": row["maxNum"].as_int if "maxNum" in row else 999, 100 | "max_stored": row["maxRepositoryNum"].as_int if "maxRepositoryNum" in row else 999, 101 | } 102 | 103 | @classmethod 104 | def make_contrib(cls, data: RetrieverData, row: ParamRow, *fields: str) -> dict[str, Any]: 105 | row_name = cls.get_pk(data, row) 106 | 107 | def get_user_value(field: str): 108 | return data.contrib.get(get_filename(row_name), {}).get(field) 109 | 110 | user_data = {field: get_user_value(field) for field in fields} 111 | user_data = {k: v for k, v in user_data.items() if v is not None} 112 | 113 | return user_data -------------------------------------------------------------------------------- /src/erdb/table/_retrievers.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | from erdb.typing.game_version import GameVersion 4 | from erdb.loaders.params import load as load_params, load_ids as load_param_ids, load_msg 5 | from erdb.loaders.contrib import load as load_contrib 6 | from erdb.typing.params import ParamDict 7 | from erdb.typing.enums import ItemIDFlag 8 | from erdb.shop import Lookup 9 | 10 | 11 | class RetrieverData(NamedTuple): 12 | main_param: ParamDict 13 | params: dict[str, ParamDict] 14 | msgs: dict[str, dict[int, str]] 15 | shops: dict[str, Lookup] 16 | contrib: dict[str, dict] 17 | 18 | class ParamDictRetriever(NamedTuple): 19 | param_name: str 20 | item_id_flag: ItemIDFlag 21 | id_min: int | None = None 22 | id_max: int | None = None 23 | 24 | def get(self, version: GameVersion) -> ParamDict: 25 | args = [self.param_name, version, self.item_id_flag] 26 | args += [arg for arg in [self.id_min, self.id_max] if arg is not None] 27 | func = load_params if len(args) <= 3 else load_param_ids 28 | return func(*args) # type: ignore 29 | 30 | def __contains__(self, __x: object) -> bool: 31 | assert isinstance(__x, int), f"Can only check for integer range" 32 | return (not self.id_min or self.id_min <= __x) \ 33 | and (not self.id_max or self.id_max >= __x) 34 | 35 | class MsgsRetriever(NamedTuple): 36 | file_name: str 37 | 38 | def get(self, version: GameVersion) -> dict[int, str]: 39 | return load_msg(self.file_name, version) 40 | 41 | class ShopRetriever(NamedTuple): 42 | shop_lineup_id_min: int | None 43 | shop_lineup_id_max: int | None 44 | material_set_id_min: int | None 45 | material_set_id_max: int | None 46 | recipe: bool = False 47 | 48 | def get(self, version: GameVersion) -> Lookup: 49 | F = ParamDictRetriever 50 | shop_param = "ShopLineupParam_Recipe" if self.recipe else "ShopLineupParam" 51 | shop = F(shop_param, ItemIDFlag.NON_EQUIPABBLE, self.shop_lineup_id_min, self.shop_lineup_id_max) 52 | mats = F("EquipMtrlSetParam", ItemIDFlag.NON_EQUIPABBLE, self.material_set_id_min, self.material_set_id_max) 53 | return Lookup(shop.get(version), mats.get(version)) 54 | 55 | class ContribRetriever(NamedTuple): 56 | def get(self, element_name: str, version: GameVersion) -> dict[str, dict]: 57 | return load_contrib(element_name, version) -------------------------------------------------------------------------------- /src/erdb/table/ammo.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.ammo import Ammo 2 | from erdb.typing.models.common import Damage 3 | from erdb.typing.models.effect import Effect 4 | from erdb.typing.categories import AmmoCategory 5 | from erdb.typing.enums import ItemIDFlag 6 | from erdb.typing.params import ParamRow 7 | from erdb.typing.api_version import ApiVersion 8 | from erdb.utils.common import remove_nulls 9 | from erdb.effect_parser import parse_status_effects, parse_weapon_effects 10 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 11 | from erdb.table._common import RowPredicate, TableSpecContext 12 | 13 | 14 | _BEHAVIOR_EFFECTS_FIELDS: list[str] = ["spEffectBehaviorId0", "spEffectBehaviorId1", "spEffectBehaviorId2"] 15 | 16 | def _get_damages(row: ParamRow) -> Damage: 17 | data = { 18 | "physical": row["attackBasePhysics"].get_int(null_value="0"), 19 | "magic": row["attackBaseMagic"].get_int(null_value="0"), 20 | "fire": row["attackBaseFire"].get_int(null_value="0"), 21 | "lightning": row["attackBaseThunder"].get_int(null_value="0"), 22 | "holy": row["attackBaseDark"].get_int(null_value="0"), 23 | "stamina": row["attackBaseStamina"].get_int(null_value="0"), 24 | } 25 | 26 | return Damage(**remove_nulls(data)) 27 | 28 | class AmmoTableSpec(TableSpecContext): 29 | model = { 30 | ApiVersion.VER_1: Ammo, 31 | } 32 | 33 | main_param_retriever = ParamDictRetriever("EquipParamWeapon", ItemIDFlag.WEAPONS) 34 | 35 | predicates: list[RowPredicate] = [ 36 | lambda row: 1 <= row["sortId"].as_int < 9999999, 37 | lambda row: AmmoCategory.get(row) is not None, 38 | ] 39 | 40 | param_retrievers = { 41 | "effects": ParamDictRetriever("SpEffectParam", ItemIDFlag.NON_EQUIPABBLE), 42 | } 43 | 44 | msg_retrievers = { 45 | "names": MsgsRetriever("WeaponName"), 46 | "descriptions": MsgsRetriever("WeaponCaption"), 47 | } 48 | 49 | @classmethod 50 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 51 | effects = data.params["effects"] 52 | effect_ids = [row[f].as_int for f in _BEHAVIOR_EFFECTS_FIELDS] 53 | 54 | return Ammo( 55 | **cls.make_item(data, row, summary=False), 56 | **cls.make_contrib(data, row, "locations", "remarks"), 57 | damage=_get_damages(row), 58 | category=AmmoCategory.from_row(row), 59 | effects=[Effect(**eff) for eff in parse_weapon_effects(row)], 60 | status_effects=parse_status_effects(effect_ids, effects), 61 | ) -------------------------------------------------------------------------------- /src/erdb/table/armor.py: -------------------------------------------------------------------------------- 1 | from erdb.shop import Material 2 | from erdb.typing.models.effect import Effect 3 | from erdb.typing.models.armor import Armor, Absorptions, Resistances 4 | from erdb.typing.params import ParamRow 5 | from erdb.typing.enums import ItemIDFlag 6 | from erdb.typing.categories import ArmorCategory 7 | from erdb.typing.api_version import ApiVersion 8 | from erdb.effect_parser import parse_effects 9 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData, ShopRetriever 10 | from erdb.table._common import RowPredicate, TableSpecContext 11 | 12 | 13 | def _get_absorptions(row: ParamRow) -> Absorptions: 14 | def parse(val: float): 15 | return round((1 - val) * 100, 1) 16 | 17 | return Absorptions( 18 | physical=parse(row["neutralDamageCutRate"].as_float), 19 | strike=parse(row["blowDamageCutRate"].as_float), 20 | slash=parse(row["slashDamageCutRate"].as_float), 21 | pierce=parse(row["thrustDamageCutRate"].as_float), 22 | magic=parse(row["magicDamageCutRate"].as_float), 23 | fire=parse(row["fireDamageCutRate"].as_float), 24 | lightning=parse(row["thunderDamageCutRate"].as_float), 25 | holy=parse(row["darkDamageCutRate"].as_float), 26 | ) 27 | 28 | def _get_resistances(row: ParamRow) -> Resistances: 29 | def check_equal(val1: int, val2: int) -> int: 30 | if val1 != val2: 31 | print(f"WARNING: Values mismatch for {row.name} resistances ({val1} != {val2}), displaying the latter.", flush=True) 32 | return val2 33 | 34 | return Resistances( 35 | immunity=check_equal(row["resistPoison"].as_int, row["resistDisease"].as_int), 36 | robustness=check_equal(row["resistFreeze"].as_int, row["resistBlood"].as_int), 37 | focus=check_equal(row["resistSleep"].as_int, row["resistMadness"].as_int), 38 | vitality=row["resistCurse"].as_int, 39 | poise=round(row["toughnessCorrectRate"].as_float * 1000) 40 | ) 41 | 42 | class ArmorTableSpec(TableSpecContext): 43 | model = { 44 | ApiVersion.VER_1: Armor, 45 | } 46 | 47 | main_param_retriever = ParamDictRetriever("EquipParamProtector", ItemIDFlag.PROTECTORS) 48 | 49 | predicates: list[RowPredicate] = [ 50 | lambda row: row.index >= 40000, 51 | lambda row: len(row.name) > 0, 52 | ] 53 | 54 | param_retrievers = { 55 | "effects": ParamDictRetriever("SpEffectParam", ItemIDFlag.NON_EQUIPABBLE), 56 | } 57 | 58 | msg_retrievers = { 59 | "names": MsgsRetriever("ProtectorName"), 60 | "summaries": MsgsRetriever("ProtectorInfo"), 61 | "descriptions": MsgsRetriever("ProtectorCaption"), 62 | } 63 | 64 | shop_retrievers = { 65 | "armor_shop": ShopRetriever( 66 | shop_lineup_id_min=110000, shop_lineup_id_max=112000, 67 | material_set_id_min=900100, material_set_id_max=901000, 68 | ) 69 | } 70 | 71 | @classmethod 72 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 73 | names = data.msgs["names"] 74 | effects = data.params["effects"] 75 | armor_shop = data.shops["armor_shop"] 76 | 77 | material = Material(row.index, Material.Category.PROTECTOR) 78 | lineups = armor_shop.get_lineups_from_material(material) 79 | assert len(lineups) in [0, 2], "Each armor should have either none or self-/boc-made alterations" 80 | altered = "" if len(lineups) == 0 else cls.parse_name(names[lineups[0].product.index]) 81 | 82 | armor_effects = parse_effects(row, effects, "residentSpEffectId", "residentSpEffectId2", "residentSpEffectId3") 83 | 84 | return Armor( 85 | **cls.make_item(data, row, summary=False), 86 | **cls.make_contrib(data, row, "locations", "remarks"), 87 | category=ArmorCategory.from_row(row), 88 | altered=altered, 89 | weight=row["weight"].as_float, 90 | icon_fem=row["iconIdF"].as_int, 91 | absorptions=_get_absorptions(row), 92 | resistances=_get_resistances(row), 93 | effects=[Effect(**eff) for eff in armor_effects] 94 | ) -------------------------------------------------------------------------------- /src/erdb/table/ashes_of_war.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.ash_of_war import AshOfWar 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import ItemIDFlag, Affinity 4 | from erdb.typing.categories import ArmamentCategory 5 | from erdb.typing.api_version import ApiVersion 6 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 7 | from erdb.table._common import TableSpecContext 8 | 9 | 10 | def _get_categories(row: ParamRow) -> list[ArmamentCategory]: 11 | return [a for a in list(ArmamentCategory) if row[f"canMountWep_{a.ingame}"].as_bool] 12 | 13 | def _get_affinities(row: ParamRow) -> list[Affinity]: 14 | return [a for a in Affinity if row[f"configurableWepAttr{str(a.id).zfill(2)}"].as_bool] 15 | 16 | class AshOfWarTableSpec(TableSpecContext): 17 | model = { 18 | ApiVersion.VER_1: AshOfWar, 19 | } 20 | 21 | main_param_retriever = ParamDictRetriever("EquipParamGem", ItemIDFlag.ACCESSORIES, id_min=10000) 22 | 23 | msg_retrievers = { 24 | "names": MsgsRetriever("GemName"), 25 | "summaries": MsgsRetriever("GemInfo"), 26 | "descriptions": MsgsRetriever("GemCaption") 27 | } 28 | 29 | @classmethod 30 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 31 | return AshOfWar( 32 | **cls.make_item(data, row), 33 | **cls.make_contrib(data, row, "locations", "remarks"), 34 | armament_categories=_get_categories(row), 35 | default_affinity=Affinity.from_id(row["defaultWepAttr"].as_int), 36 | possible_affinities=_get_affinities(row), 37 | skill_id=row["swordArtsParamId"].as_int, 38 | ) -------------------------------------------------------------------------------- /src/erdb/table/bolstering_materials.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.bolstering_material import BolsteringMaterial 2 | from erdb.typing.categories import BolsteringMaterialCategory 3 | from erdb.typing.params import ParamRow 4 | from erdb.typing.enums import GoodsType, ItemIDFlag 5 | from erdb.typing.api_version import ApiVersion 6 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 7 | from erdb.table._common import RowPredicate, TableSpecContext 8 | 9 | 10 | class BolsteringMaterialTableSpec(TableSpecContext): 11 | model = { 12 | ApiVersion.VER_1: BolsteringMaterial, 13 | } 14 | 15 | predicates: list[RowPredicate] = [ 16 | lambda row: row["goodsType"] == GoodsType.REINFORCEMENT_MATERIAL, 17 | ] 18 | 19 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 20 | 21 | msg_retrievers = { 22 | "names": MsgsRetriever("GoodsName"), 23 | "summaries": MsgsRetriever("GoodsInfo"), 24 | "descriptions": MsgsRetriever("GoodsCaption") 25 | } 26 | 27 | @classmethod 28 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 29 | return BolsteringMaterial( 30 | **cls.make_item(data, row), 31 | **cls.make_contrib(data, row, "locations", "remarks"), 32 | category=BolsteringMaterialCategory.from_row(row), 33 | ) -------------------------------------------------------------------------------- /src/erdb/table/correction_attack.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from erdb.typing.models.correction_attack import CorrectionAttack, Correction, Override, Ratio 4 | from erdb.typing.params import ParamRow 5 | from erdb.typing.enums import ItemIDFlag 6 | from erdb.typing.api_version import ApiVersion 7 | from erdb.table._retrievers import ParamDictRetriever, RetrieverData 8 | from erdb.table._common import TableSpecContext 9 | 10 | 11 | _DAMAGE_TYPE: dict[str, str] = { 12 | "physical": "Physics", 13 | "magic": "Magic", 14 | "fire": "Fire", 15 | "lightning": "Thunder", 16 | "holy": "Dark", 17 | } 18 | 19 | _ATTRIBUTE: dict[str, str] = { 20 | "strength": "Strength", 21 | "dexterity": "Dexterity", 22 | "intelligence": "Magic", 23 | "faith": "Faith", 24 | "arcane": "Luck", 25 | } 26 | 27 | def _get_attributes(row: ParamRow, damage_type: str, cls: Any) -> Any: 28 | def get_field(attribute: str, damage_type: str) -> int: 29 | field = cls.get_property(_ATTRIBUTE[attribute], _DAMAGE_TYPE[damage_type]) 30 | 31 | return { 32 | Correction: row[field].as_bool, 33 | Override: value / 100.0 if (value := row[field].get_float()) else None, 34 | Ratio: row[field].as_float / 100.0, 35 | }[cls] 36 | 37 | ret = {attrib: get_field(attrib, damage_type) for attrib in _ATTRIBUTE.keys()} 38 | ret = {k: v for k, v in ret.items() if v is not None} 39 | return cls.get_field_type()(**ret) 40 | 41 | def _get_damage_types(row: ParamRow, cls: Any) -> Any: 42 | data = {damage: _get_attributes(row, damage, cls) for damage in _DAMAGE_TYPE.keys()} 43 | return cls(**data) 44 | 45 | class CorrectionAttackTableSpec(TableSpecContext): 46 | model = { 47 | ApiVersion.VER_1: CorrectionAttack, 48 | } 49 | 50 | main_param_retriever = ParamDictRetriever("AttackElementCorrectParam", ItemIDFlag.NON_EQUIPABBLE) 51 | 52 | @classmethod # override 53 | def get_pk(cls, data: RetrieverData, row: ParamRow) -> str: 54 | return str(row.index) 55 | 56 | @classmethod 57 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 58 | return CorrectionAttack( 59 | correction=_get_damage_types(row, Correction), 60 | override=_get_damage_types(row, Override), 61 | ratio=_get_damage_types(row, Ratio), 62 | ) -------------------------------------------------------------------------------- /src/erdb/table/correction_graph.py: -------------------------------------------------------------------------------- 1 | from itertools import repeat 2 | from typing import NamedTuple, Self 3 | 4 | from erdb.typing.models.correction_graph import CorrectionGraph 5 | from erdb.typing.params import ParamRow 6 | from erdb.typing.enums import ItemIDFlag 7 | from erdb.typing.api_version import ApiVersion 8 | from erdb.table._retrievers import ParamDictRetriever, RetrieverData 9 | from erdb.table._common import RowPredicate, TableSpecContext 10 | 11 | 12 | def calc_output(stage_min: float, stage_max: float, val_min: float, val_max: float, mult_val_min: float, mult_val_max: float, input_val: float) -> float: 13 | input_ratio = (input_val - stage_min) / (stage_max - stage_min) 14 | 15 | if mult_val_min > 0: 16 | growth_val = input_ratio ** mult_val_min 17 | else: 18 | growth_val = 1 - ((1 - input_ratio) ** abs(mult_val_min)) 19 | 20 | return val_min + ((val_max - val_min) * growth_val) 21 | 22 | class CorrectionRange(NamedTuple): 23 | threshold_left: int 24 | threshold_right: int 25 | coefficient_left: float 26 | coefficient_right: float 27 | adjustment: float 28 | 29 | def get_correction(self, level: int) -> float: 30 | """ 31 | Calculate the correction value given level and CalcCorrectGraph, shamelessly stolen from: 32 | https://github.com/kingborehaha/CalcCorrectGraph-Calculation-Tool 33 | """ 34 | level_ratio = (level - self.threshold_left) / (self.threshold_right - self.threshold_left) 35 | 36 | growth = \ 37 | level_ratio ** self.adjustment \ 38 | if self.adjustment > 0 else \ 39 | 1 - ((1 - level_ratio) ** abs(self.adjustment)) 40 | 41 | return self.coefficient_left + ((self.coefficient_right - self.coefficient_left) * growth) 42 | 43 | @classmethod 44 | def from_row(cls, row: ParamRow, left: int, right: int) -> Self: 45 | return cls( 46 | row[f"stageMaxVal{left}"].as_int, 47 | row[f"stageMaxVal{right}"].as_int, 48 | row[f"stageMaxGrowVal{left}"].as_float, 49 | row[f"stageMaxGrowVal{right}"].as_float, 50 | row[f"adjPt_maxGrowVal{left}"].as_float, 51 | ) 52 | 53 | class CorrectionGraphTableSpec(TableSpecContext): 54 | model = { 55 | ApiVersion.VER_1: CorrectionGraph, 56 | } 57 | 58 | main_param_retriever = ParamDictRetriever("CalcCorrectGraph", ItemIDFlag.NON_EQUIPABBLE) 59 | 60 | predicates: list[RowPredicate] = [ 61 | lambda row: row.index < 17 62 | ] 63 | 64 | @classmethod # override 65 | def get_pk(cls, data: RetrieverData, row: ParamRow) -> str: 66 | return str(row.index) 67 | 68 | @classmethod 69 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 70 | points = range(0, 5) 71 | points_shift = range(1, 5) 72 | ranges = [CorrectionRange.from_row(row, left, right) for left, right in zip(points, points_shift)] 73 | 74 | values: list[float] = [0.] 75 | 76 | for r in ranges: 77 | values += [r.get_correction(v) / 100.0 for v in range(r.threshold_left + 1, r.threshold_right + 1)] 78 | 79 | values += list(repeat(values[-1], 150 - len(values))) 80 | assert len(values) == 150, "Correction values length mismatch" 81 | 82 | # 0th index is not valid, add another 0 to offset 83 | return [0.] + values -------------------------------------------------------------------------------- /src/erdb/table/crafting_materials.py: -------------------------------------------------------------------------------- 1 | from erdb.shop import Product, Material 2 | from erdb.typing.models.crafting_material import CraftingMaterial 3 | from erdb.typing.models import NonEmptyStr 4 | from erdb.typing.params import ParamRow 5 | from erdb.typing.enums import GoodsType, ItemIDFlag 6 | from erdb.typing.categories import CraftingMaterialCategory 7 | from erdb.typing.api_version import ApiVersion 8 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData, ShopRetriever 9 | from erdb.table._common import RowPredicate, TableSpecContext 10 | 11 | 12 | class CraftingMaterialTableSpec(TableSpecContext): 13 | model = { 14 | ApiVersion.VER_1: CraftingMaterial, 15 | } 16 | 17 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 18 | 19 | predicates: list[RowPredicate] = [ 20 | lambda row: row["goodsType"] == GoodsType.CRAFTING_MATERIAL 21 | ] 22 | 23 | msg_retrievers = { 24 | "names": MsgsRetriever("GoodsName"), 25 | "armament_names": MsgsRetriever("WeaponName"), 26 | "summaries": MsgsRetriever("GoodsInfo"), 27 | "hints": MsgsRetriever("GoodsInfo2"), 28 | "descriptions": MsgsRetriever("GoodsCaption") 29 | } 30 | 31 | shop_retrievers = { 32 | "crafting_shop": ShopRetriever( 33 | shop_lineup_id_min=None, shop_lineup_id_max=None, 34 | material_set_id_min=300000, material_set_id_max=400000, 35 | recipe=True 36 | ) 37 | } 38 | 39 | @classmethod 40 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 41 | crafting_shop = data.shops["crafting_shop"] 42 | 43 | names = { 44 | Product.Category.GOOD: data.msgs["names"], 45 | Product.Category.WEAPON: data.msgs["armament_names"] 46 | } 47 | 48 | material = Material(row.index, Material.Category.GOOD) 49 | lineups = crafting_shop.get_lineups_from_material(material) 50 | assert len(lineups) > 0 51 | 52 | return CraftingMaterial( 53 | **cls.make_item(data, row), 54 | **cls.make_contrib(data, row, "locations", "remarks"), 55 | category=CraftingMaterialCategory.from_row(row), 56 | hint=NonEmptyStr(data.msgs["hints"].get(row.index, "")), 57 | products=[NonEmptyStr(cls.parse_name(names[l.product.category][l.product.index])) for l in lineups] 58 | ) -------------------------------------------------------------------------------- /src/erdb/table/gestures.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.gesture import Gesture 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import GoodsSortGroupID, ItemIDFlag 4 | from erdb.typing.api_version import ApiVersion 5 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 6 | from erdb.table._common import RowPredicate, TableSpecContext 7 | 8 | 9 | class GestureTableSpec(TableSpecContext): 10 | model = { 11 | ApiVersion.VER_1: Gesture, 12 | } 13 | 14 | predicates: list[RowPredicate] = [ 15 | lambda row: row["sortGroupId"].as_int == GoodsSortGroupID.GESTURES, 16 | ] 17 | 18 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 19 | 20 | msg_retrievers = { 21 | "names": MsgsRetriever("GoodsName"), 22 | "summaries": MsgsRetriever("GoodsInfo"), 23 | "descriptions": MsgsRetriever("GoodsCaption") 24 | } 25 | 26 | @classmethod 27 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 28 | return Gesture( 29 | **cls.make_item(data, row), 30 | **cls.make_contrib(data, row, "locations", "remarks") 31 | ) -------------------------------------------------------------------------------- /src/erdb/table/info.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.info import Info 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import GoodsType, ItemIDFlag 4 | from erdb.typing.categories import InfoCategory 5 | from erdb.typing.api_version import ApiVersion 6 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 7 | from erdb.table._common import RowPredicate, TableSpecContext 8 | 9 | 10 | class InfoTableSpec(TableSpecContext): 11 | model = { 12 | ApiVersion.VER_1: Info, 13 | } 14 | 15 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 16 | 17 | predicates: list[RowPredicate] = [ 18 | lambda row: 1 <= row["sortId"].as_int < 999999, 19 | lambda row: row["goodsType"] == GoodsType.INFO_ITEM, 20 | ] 21 | 22 | msg_retrievers = { 23 | "names": MsgsRetriever("GoodsName"), 24 | "summaries": MsgsRetriever("GoodsInfo"), 25 | "descriptions": MsgsRetriever("GoodsCaption") 26 | } 27 | 28 | @classmethod 29 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 30 | return Info( 31 | **cls.make_item(data, row), 32 | **cls.make_contrib(data, row, "locations", "remarks"), 33 | category=InfoCategory.from_row(row), 34 | ) -------------------------------------------------------------------------------- /src/erdb/table/keys.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.key import Key 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import GoodsSortGroupID, GoodsType, ItemIDFlag 4 | from erdb.typing.categories import KeyCategory 5 | from erdb.typing.api_version import ApiVersion 6 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 7 | from erdb.table._common import RowPredicate, TableSpecContext 8 | 9 | 10 | class KeyTableSpec(TableSpecContext): 11 | model = { 12 | ApiVersion.VER_1: Key, 13 | } 14 | 15 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 16 | 17 | predicates: list[RowPredicate] = [ 18 | lambda row: 1 <= row["sortId"].as_int < 999999, 19 | lambda row: row["goodsType"] in [GoodsType.KEY_ITEM, GoodsType.REGENERATIVE_MATERIAL], 20 | lambda row: row["sortGroupId"].as_int not in [GoodsSortGroupID.GROUP_8, GoodsSortGroupID.GROUP_9, GoodsSortGroupID.GROUP_10], 21 | lambda row: "Cookbook" not in row.name, 22 | ] 23 | 24 | msg_retrievers = { 25 | "names": MsgsRetriever("GoodsName"), 26 | "summaries": MsgsRetriever("GoodsInfo"), 27 | "descriptions": MsgsRetriever("GoodsCaption") 28 | } 29 | 30 | @classmethod 31 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 32 | return Key( 33 | **cls.make_item(data, row), 34 | **cls.make_contrib(data, row, "locations", "remarks"), 35 | category=KeyCategory.from_row(row), 36 | ) -------------------------------------------------------------------------------- /src/erdb/table/reinforcements.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.reinforcement import Reinforcement, ReinforcementLevel, DamageMultiplier, ScalingMultiplier, GuardMultiplier, ResistanceMultiplier 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import ItemIDFlag 4 | from erdb.typing.api_version import ApiVersion 5 | from erdb.utils.common import find_offset_indices 6 | from erdb.table._retrievers import ParamDictRetriever, RetrieverData 7 | from erdb.table._common import RowPredicate, TableSpecContext 8 | 9 | 10 | def _get_damages(row: ParamRow) -> DamageMultiplier: 11 | return DamageMultiplier( 12 | physical=row["physicsAtkRate"].as_float, 13 | magic=row["magicAtkRate"].as_float, 14 | fire=row["fireAtkRate"].as_float, 15 | lightning=row["thunderAtkRate"].as_float, 16 | holy=row["darkAtkRate"].as_float, 17 | stamina=row["staminaAtkRate"].as_float, 18 | ) 19 | 20 | def _get_scalings(row: ParamRow) -> ScalingMultiplier: 21 | return ScalingMultiplier( 22 | strength=row["correctStrengthRate"].as_float, 23 | dexterity=row["correctAgilityRate"].as_float, 24 | intelligence=row["correctMagicRate"].as_float, 25 | faith=row["correctFaithRate"].as_float, 26 | arcane=row["correctLuckRate"].as_float, 27 | ) 28 | 29 | def _get_guards(row: ParamRow) -> GuardMultiplier: 30 | return GuardMultiplier( 31 | physical=row["physicsGuardCutRate"].as_float, 32 | magic=row["magicGuardCutRate"].as_float, 33 | fire=row["fireGuardCutRate"].as_float, 34 | lightning=row["thunderGuardCutRate"].as_float, 35 | holy=row["darkGuardCutRate"].as_float, 36 | guard_boost=row["staminaGuardDefRate"].as_float, 37 | ) 38 | 39 | def _get_resistances(row: ParamRow) -> ResistanceMultiplier: 40 | return ResistanceMultiplier( 41 | poison=row["poisonGuardResistRate"].as_float, 42 | scarlet_rot=row["diseaseGuardResistRate"].as_float, 43 | frostbite=row["freezeGuardDefRate"].as_float, 44 | bleed=row["bloodGuardResistRate"].as_float, 45 | sleep=row["sleepGuardDefRate"].as_float, 46 | madness=row["madnessGuardDefRate"].as_float, 47 | death_blight=row["curseGuardResistRate"].as_float, 48 | ) 49 | 50 | def _get_reinforcement_level(row: ParamRow, level: int) -> ReinforcementLevel: 51 | return ReinforcementLevel( 52 | level=level, 53 | damage=_get_damages(row), 54 | scaling=_get_scalings(row), 55 | guard=_get_guards(row), 56 | resistance=_get_resistances(row) 57 | ) 58 | 59 | class ReinforcementTableSpec(TableSpecContext): 60 | model = { 61 | ApiVersion.VER_1: Reinforcement, 62 | } 63 | 64 | main_param_retriever = ParamDictRetriever("ReinforceParamWeapon", ItemIDFlag.NON_EQUIPABBLE) 65 | 66 | predicates: list[RowPredicate] = [ 67 | lambda row: row.is_base_item, 68 | lambda row: len(row.name) > 0, 69 | ] 70 | 71 | @classmethod # override 72 | def get_pk(cls, data: RetrieverData, row: ParamRow) -> str: 73 | return str(row.index) 74 | 75 | @classmethod 76 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 77 | indices, offset = find_offset_indices(row.index, data.main_param, possible_maxima=[0, 10, 25]) 78 | return Reinforcement([_get_reinforcement_level(data.main_param[i], lvl) for i, lvl in zip(indices, offset)]) -------------------------------------------------------------------------------- /src/erdb/table/shop.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.shop import Shop 2 | from erdb.typing.params import ParamRow 3 | from erdb.typing.enums import GoodsSortGroupID, GoodsType, ItemIDFlag 4 | from erdb.typing.categories import ShopCategory 5 | from erdb.typing.api_version import ApiVersion 6 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 7 | from erdb.table._common import RowPredicate, TableSpecContext 8 | 9 | 10 | class ShopTableSpec(TableSpecContext): 11 | model = { 12 | ApiVersion.VER_1: Shop, 13 | } 14 | 15 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 16 | 17 | predicates: list[RowPredicate] = [ 18 | lambda row: 1 <= row["sortId"].as_int < 999999, 19 | lambda row: row["goodsType"] == GoodsType.KEY_ITEM, 20 | lambda row: (row["sortGroupId"].as_int in [GoodsSortGroupID.GROUP_8, GoodsSortGroupID.GROUP_9, GoodsSortGroupID.GROUP_10] \ 21 | or (row["sortGroupId"].as_int == GoodsSortGroupID.GROUP_6 and "Cookbook" in row.name)), 22 | ] 23 | 24 | msg_retrievers = { 25 | "names": MsgsRetriever("GoodsName"), 26 | "summaries": MsgsRetriever("GoodsInfo"), 27 | "descriptions": MsgsRetriever("GoodsCaption") 28 | } 29 | 30 | @classmethod 31 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 32 | return Shop( 33 | **cls.make_item(data, row), 34 | **cls.make_contrib(data, row, "locations", "remarks"), 35 | category=ShopCategory.from_row(row), 36 | ) -------------------------------------------------------------------------------- /src/erdb/table/spells.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.spells import Spell 2 | from erdb.typing.models.common import StatRequirements 3 | from erdb.typing.params import ParamRow 4 | from erdb.typing.enums import GoodsType, ItemIDFlag, SpellHoldAction 5 | from erdb.typing.categories import SpellCategory 6 | from erdb.typing.api_version import ApiVersion 7 | from erdb.utils.common import remove_nulls 8 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 9 | from erdb.table._common import RowPredicate, TableSpecContext 10 | 11 | 12 | def _get_spell_requirements(row: ParamRow) -> StatRequirements: 13 | data = { 14 | "intelligence": row["requirementIntellect"].get_int(null_value=0), 15 | "faith": row["requirementFaith"].get_int(null_value=0), 16 | "arcane": row["requirementLuck"].get_int(null_value=0), 17 | } 18 | 19 | return StatRequirements(**remove_nulls(data)) 20 | 21 | class SpellTableSpec(TableSpecContext): 22 | model = { 23 | ApiVersion.VER_1: Spell, 24 | } 25 | 26 | # Spells are defined in Goods and Magic tables, correct full hex IDs are calculated from Goods 27 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 28 | 29 | predicates: list[RowPredicate] = [ 30 | lambda row: 1 <= row["sortId"].as_int < 999999, 31 | lambda row: row["goodsType"] in [GoodsType.SORCERY_1, GoodsType.INCANTATION_1, GoodsType.SORCERY_2, GoodsType.INCANTATION_2], 32 | ] 33 | 34 | param_retrievers = { 35 | "magic": ParamDictRetriever("Magic", ItemIDFlag.NON_EQUIPABBLE) 36 | } 37 | 38 | msg_retrievers = { 39 | "names": MsgsRetriever("GoodsName"), 40 | "summaries": MsgsRetriever("GoodsInfo"), 41 | "descriptions": MsgsRetriever("GoodsCaption") 42 | } 43 | 44 | @classmethod 45 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 46 | row_magic = data.params["magic"][row.index] 47 | 48 | fp_cost = row_magic["mp"].as_int 49 | fp_extra = row_magic["mp_charge"].as_int 50 | 51 | sp_cost = row_magic["stamina"].as_int 52 | sp_extra = row_magic["stamina_charge"].as_int 53 | 54 | hold_action = SpellHoldAction.NONE if fp_extra == 0 else SpellHoldAction.CHARGE 55 | hold_action = SpellHoldAction.CONTINUOUS if row_magic["consumeLoopMP_forMenu"].get_int() else hold_action 56 | 57 | return Spell( 58 | **cls.make_item(data, row), 59 | **cls.make_contrib(data, row, "locations", "remarks"), 60 | fp_cost=fp_cost, 61 | fp_cost_extra=fp_extra - fp_cost if hold_action == "Charge" else fp_extra, 62 | sp_cost=sp_cost, 63 | sp_cost_extra=sp_extra - sp_cost if hold_action == "Charge" else sp_extra, 64 | category=SpellCategory.from_row(row_magic), 65 | slots_used=row_magic["slotLength"].as_int, 66 | hold_action=hold_action, 67 | is_weapon_buff=row_magic["isEnchant"].as_bool, 68 | is_shield_buff=row_magic["isShieldEnchant"].as_bool, 69 | is_horseback_castable=row_magic["enableRiding"].as_bool, 70 | requirements=_get_spell_requirements(row_magic), 71 | ) -------------------------------------------------------------------------------- /src/erdb/table/spirit_ashes.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.spirit_ash import SpiritAsh 2 | from erdb.typing.params import ParamDict, ParamRow 3 | from erdb.typing.enums import GoodsType, ItemIDFlag, SpiritAshUpgradeMaterial 4 | from erdb.typing.api_version import ApiVersion 5 | from erdb.utils.common import find_offset_indices 6 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 7 | from erdb.table._common import RowPredicate, TableSpecContext 8 | 9 | 10 | def _find_upgrade_costs(goods: ParamDict, base_item_id: int) -> list[int]: 11 | indices, _ = find_offset_indices(base_item_id, goods, possible_maxima=[9]) # not 10, ignore last one 12 | return [goods[i]["reinforcePrice"].as_int for i in indices] 13 | 14 | class SpiritAshTableSpec(TableSpecContext): 15 | model = { 16 | ApiVersion.VER_1: SpiritAsh, 17 | } 18 | 19 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 20 | 21 | predicates: list[RowPredicate] = [ 22 | lambda row: row.is_base_item, 23 | lambda row: row["goodsType"] in [GoodsType.LESSER, GoodsType.GREATER], 24 | ] 25 | 26 | param_retrievers = { 27 | "upgrade_materials": ParamDictRetriever("EquipMtrlSetParam", ItemIDFlag.NON_EQUIPABBLE) 28 | } 29 | 30 | msg_retrievers = { 31 | "names": MsgsRetriever("GoodsName"), 32 | "summaries": MsgsRetriever("GoodsInfo"), 33 | "summon_names": MsgsRetriever("GoodsInfo2"), 34 | "descriptions": MsgsRetriever("GoodsCaption") 35 | } 36 | 37 | @classmethod 38 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 39 | upgrade_materials = data.params["upgrade_materials"] 40 | names = data.msgs["names"] 41 | summon_names = data.msgs["summon_names"] 42 | 43 | upgrade_material = upgrade_materials[row["reinforceMaterialId"].as_int] 44 | upgrade_material = names[upgrade_material["materialId01"].as_int].removesuffix("[1]").strip() 45 | upgrade_material = { 46 | "Grave Glovewort": SpiritAshUpgradeMaterial.GRAVE_GLOVEWORT, 47 | "Ghost Glovewort": SpiritAshUpgradeMaterial.GHOST_GLOVEWORT, 48 | }[upgrade_material] 49 | 50 | return SpiritAsh( 51 | **cls.make_item(data, row), 52 | **cls.make_contrib(data, row, "locations", "remarks", "summon_quantity", "abilities"), 53 | summon_name=summon_names[row.index].strip(), # sometimes trailing spaces 54 | fp_cost=row["consumeMP"].get_int(0), 55 | hp_cost=row["consumeHP"].get_int(0), 56 | upgrade_material=upgrade_material, 57 | upgrade_costs=_find_upgrade_costs(data.main_param, row.index) 58 | ) -------------------------------------------------------------------------------- /src/erdb/table/talismans.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.talisman import Talisman 2 | from erdb.typing.models.effect import Effect 3 | from erdb.typing.models import NonEmptyStr 4 | from erdb.effect_parser import parse_effects 5 | from erdb.typing.params import ParamRow 6 | from erdb.typing.enums import ItemIDFlag 7 | from erdb.typing.api_version import ApiVersion 8 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 9 | from erdb.table._common import RowPredicate, TableSpecContext 10 | 11 | 12 | class TalismanTableSpec(TableSpecContext): 13 | model = { 14 | ApiVersion.VER_1: Talisman, 15 | } 16 | 17 | main_param_retriever = ParamDictRetriever("EquipParamAccessory", ItemIDFlag.ACCESSORIES) 18 | 19 | predicates: list[RowPredicate] = [ 20 | lambda row: 1000 <= row.index < 999999, 21 | ] 22 | 23 | param_retrievers = { 24 | "effects": ParamDictRetriever("SpEffectParam", ItemIDFlag.NON_EQUIPABBLE, id_min=310000, id_max=400000) 25 | } 26 | 27 | msg_retrievers = { 28 | "names": MsgsRetriever("AccessoryName"), 29 | "summaries": MsgsRetriever("AccessoryInfo"), 30 | "descriptions": MsgsRetriever("AccessoryCaption") 31 | } 32 | 33 | @classmethod 34 | def _find_conflicts(cls, data: RetrieverData, group: int) -> list[NonEmptyStr]: 35 | def valid(row: ParamRow) -> bool: 36 | return row["accessoryGroup"].as_int == group and row.index < 9999999 37 | return [NonEmptyStr(cls.parse_name(data.msgs["names"][row.index])) for row in data.main_param.values() if valid(row)] 38 | 39 | @classmethod 40 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 41 | effects = data.params["effects"] 42 | 43 | return Talisman( 44 | **cls.make_item(data, row), 45 | **cls.make_contrib(data, row, "locations", "remarks"), 46 | weight=row["weight"].as_float, 47 | effects=[Effect(**eff) for eff in parse_effects(row, effects, "refId")], 48 | conflicts=cls._find_conflicts(data, row["accessoryGroup"].as_int), 49 | ) -------------------------------------------------------------------------------- /src/erdb/table/tools.py: -------------------------------------------------------------------------------- 1 | from erdb.typing.models.tool import Tool 2 | from erdb.typing.models.effect import Effect 3 | from erdb.typing.params import ParamRow 4 | from erdb.typing.enums import GoodsSortGroupID, GoodsType, ItemIDFlag, ToolAvailability 5 | from erdb.typing.categories import ToolCategory 6 | from erdb.typing.api_version import ApiVersion 7 | from erdb.effect_parser import parse_effects 8 | from erdb.table._retrievers import ParamDictRetriever, MsgsRetriever, RetrieverData 9 | from erdb.table._common import RowPredicate, TableSpecContext 10 | 11 | 12 | def _get_availability(row: ParamRow) -> ToolAvailability: 13 | T = ToolAvailability 14 | 15 | if row["disable_offline"].as_bool: 16 | return T.MULTIPLAYER 17 | 18 | return T.ALWAYS if row["enable_multi"].as_bool else T.SINGLEPLAYER 19 | 20 | def _is_note_item(name: str) -> bool: 21 | # 2 Weathered Maps defined in-game, one is NORMAL_ITEM 22 | return name.startswith("Note: ") or name == "Weathered Map" 23 | 24 | class ToolTableSpec(TableSpecContext): 25 | model = { 26 | ApiVersion.VER_1: Tool, 27 | } 28 | 29 | main_param_retriever = ParamDictRetriever("EquipParamGoods", ItemIDFlag.GOODS) 30 | 31 | predicates: list[RowPredicate] = [ 32 | lambda row: 1 <= row["sortId"].as_int < 999999, 33 | lambda row: row["sortGroupId"].as_int != GoodsSortGroupID.GESTURES, 34 | lambda row: row["goodsType"] in [GoodsType.NORMAL_ITEM, GoodsType.REMEMBRANCE, GoodsType.WONDROUS_PHYSICK_TEAR, GoodsType.GREAT_RUNE], 35 | lambda row: not _is_note_item(row.name), 36 | ] 37 | 38 | param_retrievers = { 39 | "effects": ParamDictRetriever("SpEffectParam", ItemIDFlag.NON_EQUIPABBLE) 40 | } 41 | 42 | msg_retrievers = { 43 | "names": MsgsRetriever("GoodsName"), 44 | "summaries": MsgsRetriever("GoodsInfo"), 45 | "descriptions": MsgsRetriever("GoodsCaption") 46 | } 47 | 48 | @classmethod 49 | def make_object(cls, api: ApiVersion, data: RetrieverData, row: ParamRow): 50 | effects = data.params["effects"] 51 | 52 | return Tool( 53 | **cls.make_item(data, row), 54 | **cls.make_contrib(data, row, "locations", "remarks"), 55 | category=ToolCategory.from_row(row), 56 | availability=_get_availability(row), 57 | fp_cost=row["consumeMP"].as_int, 58 | is_consumed=row["isConsume"].as_bool, 59 | is_ladder_usable=row["enable_Ladder"].as_bool, 60 | is_horseback_usable=row["enableRiding"].as_bool, 61 | effects=[Effect(**eff) for eff in parse_effects(row, effects, "refId_default")], 62 | ) -------------------------------------------------------------------------------- /src/erdb/typing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/typing/__init__.py -------------------------------------------------------------------------------- /src/erdb/typing/api_version.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class ApiVersion(IntEnum): 5 | VER_1 = 1 -------------------------------------------------------------------------------- /src/erdb/typing/effects.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from enum import Enum 3 | from types import SimpleNamespace 4 | from typing import Any, Callable, NamedTuple, Self 5 | 6 | 7 | class EffectType(str, Enum): 8 | POSITIVE = "positive" 9 | NEUTRAL = "neutral" 10 | NEGATIVE = "negative" 11 | 12 | class EffectModel(str, Enum): 13 | MULTIPLICATIVE = "multiplicative" 14 | ADDITIVE = "additive" 15 | 16 | """ 17 | Helper class of attributes used for effects, these are self-made 18 | and don't correspond to anything in the game. 19 | """ 20 | class AttributeName(str, Enum): 21 | MAXIMUM_HEALTH = "Maximum Health", 22 | HEALTH_POINTS = "Health Points", 23 | FLASK_HEALTH_RESTORATION = "Flask Health Restoration", 24 | MAXIMUM_FOCUS = "Maximum Focus", 25 | FOCUS_POINTS = "Focus Points", 26 | FLASK_FOCUS_RESTORATION = "Flask Focus Restoration", 27 | MAXIMUM_STAMINA = "Maximum Stamina", 28 | STAMINA_RECOVERY_SPEED = "Stamina Recovery Speed", 29 | MAXIMUM_EQUIP_LOAD = "Maximum Equip Load", 30 | POISE = "Poise", 31 | VIGOR = "Vigor", 32 | MIND = "Mind", 33 | ENDURANCE = "Endurance", 34 | STRENGHT = "Strength", 35 | DEXTERITY = "Dexterity", 36 | INTELLIGENCE = "Intelligence", 37 | FAITH = "Faith", 38 | ARCANE = "Arcane", 39 | STANDARD_ABSORPTION = "Standard Absorption", 40 | STRIKE_ABSORPTION = "Strike Absorption", 41 | SLASH_ABSORPTION = "Slash Absorption", 42 | PIERCE_ABSORPTION = "Pierce Absorption", 43 | PHYSICAL_ABSORPTION = "Physical Absorption", 44 | MAGIC_ABSORPTION = "Magic Absorption", 45 | FIRE_ABSORPTION = "Fire Absorption", 46 | LIGHTNING_ABSORPTION = "Lightning Absorption", 47 | HOLY_ABSORPTION = "Holy Absorption", 48 | ELEMENTAL_ABSORPTION = "Elemental Absorption", 49 | ABSORPTION = "Absorption", 50 | STANDARD_ATTACK_POWER = "Standard Attack Power", 51 | STRIKE_ATTACK_POWER = "Strike Attack Power", 52 | SLASH_ATTACK_POWER = "Slash Attack Power", 53 | PIERCE_ATTACK_POWER = "Pierce Attack Power", 54 | PHYSICAL_ATTACK_POWER = "Physical Attack Power", 55 | MAGIC_ATTACK_POWER = "Magic Attack Power", 56 | FIRE_ATTACK_POWER = "Fire Attack Power", 57 | LIGHTNING_ATTACK_POWER = "Lightning Attack Power", 58 | HOLY_ATTACK_POWER = "Holy Attack Power", 59 | ELEMENTAL_ATTACK_POWER = "Elemental Attack Power", 60 | ATTACK_POWER = "Attack Power", 61 | STAMINA_ATTACK_RATE = "Stamina Attack Rate" 62 | STABILITY = "Stability" 63 | IMMUNITY = "Immunity", 64 | ROBUSTNESS = "Robustness" 65 | VITALITY = "Vitality", 66 | FOCUS = "Focus", 67 | POISON_RESISTANCE = "Poison Resistance", 68 | SCARLET_ROT_RESISTANCE = "Scarlet Rot Resistance", 69 | BLEED_RESISTANCE = "Bleed Resistance", 70 | FROSTBITE_RESISTANCE = "Frostbite Resistance", 71 | SLEEP_RESISTANCE = "Sleep Resistance", 72 | MADNESS_RESISTANCE = "Madness Resistance", 73 | DEATH_BLIGHT_RESISTANCE = "Death Blight Resistance", 74 | MEMORY_SLOTS = "Memory Slots", 75 | CASTING_SPEED = "Casting Speed", 76 | SPELL_DURATION = "Spell Duration", 77 | SORCERY_FOCUS_CONSUMPTION = "Sorcery Focus Consumption", 78 | INCANTATION_FOCUS_CONSUMPTION = "Incantation Focus Consumption", 79 | PYROMANCY_FOCUS_CONSUMPTION = "Pyromancy Focus Consumption", 80 | SPELL_FOCUS_CONSUMPTION = "Spell Focus Consumption", 81 | SKILL_FOCUS_CONSUMPTION = "Skill Focus Consumption", 82 | BOW_DISTANCE = "Bow Distance", 83 | ENEMY_HEARING = "Enemy Hearing", 84 | FALL_DAMAGE = "Fall Damage", 85 | ITEM_DISCOVERY = "Item Discovery", 86 | RUNE_ACQUISITION = "Rune Acquisition", 87 | INVISIBLE_AT_DISTANCE = "Invisible at Distance" 88 | REDUCE_HEADSHOT_IMPACT = "Reduce Headshot Impact" 89 | SWITCH_ANIMATION_GENDER = "Switch Animation Gender" 90 | APPEAR_AS_COOPERATOR = "Appear as Cooperator" 91 | APPEAR_AS_HOST = "Appear as Host" 92 | PRESERVE_RUNES_ON_DEATH = "Preserve Runes on Death" 93 | DESTROY_ITEM_ON_DEATH = "Destroy Item on Death" 94 | ATTRACT_ENEMY_AGGRESSION = "Attract Enemy Aggression" 95 | 96 | class AttributeField(NamedTuple): 97 | attribute: AttributeName 98 | effect_model: EffectModel 99 | effect_type: EffectType 100 | parser: Callable 101 | conditions: list[str] | None 102 | default_value: Any 103 | 104 | def get_effective_type(self, value: Any): 105 | T = EffectType 106 | value_increase = value >= self.default_value 107 | return { 108 | T.POSITIVE: { True: T.POSITIVE, False: T.NEGATIVE, }, 109 | T.NEGATIVE: { True: T.NEGATIVE, False: T.POSITIVE, }, 110 | T.NEUTRAL: { True: T.NEUTRAL, False: T.NEUTRAL, } 111 | }[self.effect_type][value_increase] 112 | 113 | @classmethod 114 | def create(cls, attribute: AttributeName, effect_model: EffectModel, effect_type: EffectType, 115 | parser: Callable, conditions: list[str] | None = None, default_value: Any | None = None) -> Self: 116 | 117 | def _default_value_from_model(): 118 | return 1 if effect_model == EffectModel.MULTIPLICATIVE else 0 119 | 120 | default_value = _default_value_from_model() if default_value is None else default_value 121 | return cls(attribute, effect_model, effect_type, parser, conditions, default_value) 122 | 123 | class SchemaEffect(SimpleNamespace): 124 | attribute: AttributeName 125 | conditions: list[str] | None = None 126 | tick_interval: float | None = None 127 | effect_model: EffectModel 128 | effect_type: EffectType 129 | value: float 130 | value_pvp: float | None = None 131 | 132 | def to_dict(self) -> dict: 133 | d = { 134 | "attribute": self.attribute, 135 | "model": self.effect_model, 136 | "type": self.effect_type, 137 | "value": self.value, 138 | } 139 | 140 | for prop in ["conditions", "tick_interval", "value_pvp"]: 141 | if getattr(self, prop) is not None: 142 | d[prop] = getattr(self, prop) 143 | 144 | return d 145 | 146 | def get_values_hash(self) -> int: 147 | conds = None if self.conditions is None else tuple(self.conditions) 148 | return hash((conds, self.tick_interval, self.effect_model, self.effect_type, self.value, self.value_pvp)) 149 | 150 | def clone(self, new_attribute: AttributeName) -> Self: 151 | new_effect = deepcopy(self) 152 | new_effect.attribute = new_attribute 153 | return new_effect 154 | 155 | def __str__(self) -> str: 156 | conds = "" if self.conditions is None else f" (under conditions {self.conditions})" 157 | sign_val = "+" if self.value > 0 else "-" 158 | sign_model = "%" if self.effect_model == EffectModel.MULTIPLICATIVE else "+" 159 | val_pvp = "" if self.value_pvp is None else f" ({self.value_pvp} PVP)" 160 | tick = "" if self.tick_interval is None else f" on tick {self.tick_interval} s" 161 | return f"{sign_val}{self.value}{sign_model}{val_pvp} {self.attribute.value}{conds}{tick}" 162 | 163 | @classmethod 164 | def from_attribute_field(cls, value: float, attrib_field: AttributeField) -> Self: 165 | value = attrib_field.parser(value, attrib_field.effect_model) 166 | 167 | return cls( 168 | attribute=attrib_field.attribute, 169 | conditions=attrib_field.conditions, 170 | effect_model=attrib_field.effect_model, 171 | effect_type=attrib_field.get_effective_type(value), 172 | value=value) 173 | 174 | @classmethod 175 | def from_obj(cls, obj: Any) -> Self: 176 | return cls( 177 | attribute=AttributeName(getattr(obj, "attribute")), 178 | conditions=getattr(obj, "conditions", None), 179 | tick_interval=getattr(obj, "tick_interval"), 180 | effect_model=EffectModel(getattr(obj, "model")), 181 | effect_type=EffectType(getattr(obj, "type")), 182 | value=getattr(obj, "value", None), 183 | value_pvp=getattr(obj, "value_pvp", None)) -------------------------------------------------------------------------------- /src/erdb/typing/game_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import total_ordering 3 | from pathlib import Path 4 | from typing import Any, Generator, NamedTuple, Self 5 | 6 | 7 | @total_ordering 8 | class GameVersion(NamedTuple): 9 | major: str 10 | minor: str 11 | patch: str 12 | nums: list[int] 13 | 14 | @classmethod 15 | def from_nums(cls, major_int: int, minor_int: int, patch_int: int) -> Self: 16 | major = str(major_int) 17 | minor = f"{minor_int:02}" 18 | patch = str(patch_int) 19 | 20 | assert len(major) == 1 21 | assert len(minor) == 2 22 | assert len(patch) == 1 23 | 24 | return cls(major, minor, patch, [major_int, minor_int, patch_int]) 25 | 26 | @classmethod 27 | def from_string(cls, version: str) -> Self: 28 | parts = version.split(".") 29 | assert len(parts) == 3, "Invalid version string given" 30 | assert len(parts[1]) >= 2, "Minor part must be at least 2 digits" 31 | 32 | nums = [int(parts[0]), int(parts[1]), int(parts[2])] 33 | return cls(parts[0], parts[1], parts[2], nums) 34 | 35 | @classmethod 36 | def from_any(cls, obj: Any) -> Self: 37 | if isinstance(obj, GameVersion): 38 | return obj 39 | 40 | if isinstance(obj, str): 41 | return GameVersion.from_string(obj) 42 | 43 | if isinstance(obj, Path): 44 | with open(obj, "r") as f: 45 | data = f.read() 46 | return GameVersion.from_string(data) 47 | 48 | if isinstance(obj, list): 49 | assert len(obj) == 3 and all(isinstance(elem, int) for elem in obj) 50 | return GameVersion.from_nums(obj[0], obj[1], obj[2]) 51 | 52 | assert False, "Cannot parse GameVersion" 53 | 54 | @classmethod 55 | def min(cls) -> Self: 56 | return cls("0", "00", "0", [0, 0, 0]) 57 | 58 | @classmethod 59 | def max(cls) -> Self: 60 | return cls("99999", "99999", "99999", [99999, 99999, 99999]) 61 | 62 | def __str__(self) -> str: 63 | return f"{self.major}.{self.minor}.{self.patch}" 64 | 65 | def __eq__(self, __o: object) -> bool: 66 | if not isinstance(__o, GameVersion): 67 | return False 68 | return self.major == __o.major and self.minor == __o.minor and self.patch == __o.patch 69 | 70 | def __lt__(self, __o: "GameVersion") -> bool: 71 | assert isinstance(__o, GameVersion) 72 | for this_num, other_num in zip(self.nums, __o.nums): 73 | if this_num != other_num: 74 | return this_num < other_num 75 | return False 76 | 77 | class GameVersionRange(NamedTuple): 78 | begin: GameVersion # including 79 | end: GameVersion # excluding 80 | only: bool=False # only `begin` 81 | 82 | def iterate(self, versions: list[GameVersion]) -> Generator[GameVersion, None, None]: 83 | for version in versions: 84 | if version in self: 85 | yield version 86 | 87 | @classmethod 88 | def from_version(cls, version: GameVersion) -> Self: 89 | return cls(version, GameVersion.max(), only=True) 90 | 91 | @classmethod 92 | def from_string(cls, string: str) -> Self: 93 | def _ver(match: re.Match[str]) -> GameVersion: 94 | return GameVersion.from_string(match.group(1)) 95 | 96 | if string == "any version": 97 | return cls(GameVersion.min(), GameVersion.max()) 98 | 99 | if search_only := re.search(r"only (\d+\.\d\d+\.\d+)", string): 100 | return cls(_ver(search_only), GameVersion.max(), only=True) 101 | 102 | search_begin = re.search(r"from (\d+\.\d\d+\.\d+)", string) 103 | search_end = re.search(r"until (\d+\.\d\d+\.\d+)", string) 104 | assert search_begin or search_end, "Invalid version range string" 105 | 106 | begin = _ver(search_begin) if search_begin else GameVersion.min() 107 | end = _ver(search_end) if search_end else GameVersion.max() 108 | 109 | assert begin < end, "Invalid version range string" 110 | return cls(begin, end) 111 | 112 | def __contains__(self, version: GameVersion) -> bool: 113 | assert isinstance(version, GameVersion) 114 | return self.begin == version if self.only \ 115 | else self.begin <= version and version < self.end 116 | 117 | class GameVersionInstance(NamedTuple): 118 | application: GameVersion 119 | regulation: GameVersion 120 | 121 | @property 122 | def effective(self) -> GameVersion: 123 | return max(self.application, self.regulation) 124 | 125 | @classmethod 126 | def construct(cls, application: Any, regulation: Any) -> Self: 127 | return cls(GameVersion.from_any(application), GameVersion.from_any(regulation)) 128 | 129 | def __str__(self) -> str: 130 | return f"(app: {self.application}, regulation: {self.regulation})" 131 | -------------------------------------------------------------------------------- /src/erdb/typing/models/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pydantic import ConfigDict, Extra, ConstrainedStr 4 | 5 | 6 | def dt_config(title: str | None = None) -> ConfigDict: 7 | return ConfigDict( 8 | title=title, 9 | extra=Extra.forbid, 10 | allow_mutation=False, 11 | frozen=True, 12 | validate_all=True, 13 | validate_assignment=True, 14 | ) 15 | 16 | class NonEmptyStr(ConstrainedStr): 17 | min_length = 1 -------------------------------------------------------------------------------- /src/erdb/typing/models/ammo.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.models.common import Damage 7 | from erdb.typing.models.effect import Effect, StatusEffects 8 | from erdb.typing.categories import AmmoCategory 9 | 10 | 11 | @dataclass(config=dt_config()) 12 | class Ammo(Item): 13 | damage: Damage = Field(..., 14 | description="Base attack power values.", 15 | example=Damage(physical=10, fire=90, stamina=5), 16 | ) 17 | category: AmmoCategory = Field(..., 18 | description="Category of the Ammo.", 19 | example=AmmoCategory.BOLT, 20 | ) 21 | effects: list[Effect] = Field(..., 22 | description="Effects of the Ammo.", 23 | # example provided by Effect model 24 | ) 25 | status_effects: StatusEffects = Field(..., 26 | description="Status effects of the Ammo, dealt on hit." 27 | # example provided by StatusEffects model 28 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/armament.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, NonNegativeInt, NonNegativeFloat, PositiveInt 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.models.common import Damage, StatRequirements 7 | from erdb.typing.models.effect import Effect, StatusEffects 8 | from erdb.typing.categories import ArmamentCategory 9 | from erdb.typing.enums import Affinity, ArmamentUpgradeMaterial, AttackAttribute 10 | 11 | 12 | @dataclass(config=dt_config()) 13 | class CorrectionCalcID: 14 | physical: NonNegativeInt 15 | magic: NonNegativeInt 16 | fire: NonNegativeInt 17 | lightning: NonNegativeInt 18 | holy: NonNegativeInt 19 | poison: NonNegativeInt 20 | bleed: NonNegativeInt 21 | sleep: NonNegativeInt 22 | madness: NonNegativeInt 23 | 24 | @dataclass(config=dt_config()) 25 | class Scaling: 26 | strength: NonNegativeFloat | None = None 27 | dexterity: NonNegativeFloat | None = None 28 | intelligence: NonNegativeFloat | None = None 29 | faith: NonNegativeFloat | None = None 30 | arcane: NonNegativeFloat | None = None 31 | 32 | @dataclass(config=dt_config()) 33 | class Guard: 34 | physical: NonNegativeInt | None = None 35 | magic: NonNegativeInt | None = None 36 | fire: NonNegativeInt | None = None 37 | lightning: NonNegativeInt | None = None 38 | holy: NonNegativeInt | None = None 39 | guard_boost: NonNegativeInt | None = None 40 | 41 | @dataclass(config=dt_config()) 42 | class AffinityProperties: 43 | full_hex_id: str = Field(..., 44 | description="Full hex ID override for the Armament with Affinity applied.", 45 | regex=r"^[0-9A-F]+$", 46 | min_length=8, max_length=8, 47 | example="003085E0", 48 | ) 49 | id: PositiveInt = Field(..., 50 | description="ID override for the Armament with Affinity applied.", 51 | exampe=3180000, 52 | ) 53 | reinforcement_id: NonNegativeInt = Field(..., 54 | description="ID of reinforcement, refer to the `Reinforcements` table to look up value changes per level.", 55 | example=100, 56 | ) 57 | correction_attack_id: NonNegativeInt = Field(..., 58 | description="ID of attack element correction, refer to the `Correction Attack` table to look up definitions.", 59 | example=10000, 60 | ) 61 | correction_calc_id: CorrectionCalcID = Field(..., 62 | description="ID of calc correction for each damage type, refer to the `Correction Graph` table to look up value multipliers.", 63 | example=CorrectionCalcID( 64 | physical=0, magic=0, fire=0, lightning=0, holy=0, 65 | poison=6, bleed=6, sleep=6, madness=6, 66 | ) 67 | ) 68 | damage: Damage = Field(..., 69 | description="Base attack power values.", 70 | example=Damage(physical=138, stamina=63), 71 | ) 72 | scaling: Scaling = Field(..., 73 | description="Base attribute scaling values.", 74 | example=Scaling(strength=0.49, dexterity=0.34) 75 | ) 76 | guard: Guard = Field(..., 77 | description="Base guarded damage negation values.", 78 | example=Guard(physical=65, magic=35, fire=35, lightning=35, holy=35, guard_boost=42) 79 | ) 80 | resistance: StatusEffects = Field(..., 81 | description="Base guarded resistances values.", 82 | example=StatusEffects( 83 | bleed=20, frostbite=20, poison=20, scarlet_rot=20, 84 | sleep=20, madness=20, death_blight=20 85 | ), 86 | ) 87 | status_effects: StatusEffects = Field(..., 88 | description="Status effects of the Armament, dealt on hit.", 89 | example=StatusEffects(poison=80), 90 | ) 91 | status_effect_overlay: list[StatusEffects] = Field(..., 92 | description="Array of status effects per Armament level which get upgraded alongside. Given the Armament level as the index to this array, the value should be overlaid on the `status_effects` property. In practice, only a single effect is set to be upgradable, but technically game params can do up to three.", 93 | max_items=26, 94 | example=[ 95 | StatusEffects(poison=80), StatusEffects(poison=81), StatusEffects(poison=82), 96 | StatusEffects(poison=84), StatusEffects(poison=85), StatusEffects(poison=87), 97 | StatusEffects(poison=88), StatusEffects(poison=89), StatusEffects(poison=91), 98 | StatusEffects(poison=92), StatusEffects(poison=94), StatusEffects(poison=95), 99 | StatusEffects(poison=96), StatusEffects(poison=98), StatusEffects(poison=99), 100 | StatusEffects(poison=101), StatusEffects(poison=102), StatusEffects(poison=103), 101 | StatusEffects(poison=105), StatusEffects(poison=106), StatusEffects(poison=108), 102 | StatusEffects(poison=109), StatusEffects(poison=110), StatusEffects(poison=112), 103 | StatusEffects(poison=113), StatusEffects(poison=115), 104 | ] 105 | ) 106 | 107 | @dataclass(config=dt_config()) 108 | class Armament(Item): 109 | behavior_variation_id: NonNegativeInt = Field(..., 110 | description="Behavior variation ID used to identify attack params.", 111 | example=318 112 | ) 113 | category: ArmamentCategory = Field(..., 114 | description="Class of the Armament.", 115 | example=ArmamentCategory.GREATSWORD, 116 | ) 117 | weight: NonNegativeFloat = Field(..., 118 | description="Weight of the Armament.", 119 | example=9., 120 | ) 121 | default_skill_id: int = Field(..., 122 | description="Index of the default Skill the Armament comes with.", 123 | ge=10, 124 | example=100, 125 | ) 126 | allow_ash_of_war: bool = Field(..., 127 | description="Specifies whether other Ashes of War can be put on the Armament and its affinity potentially changed.", 128 | example=True, 129 | ) 130 | is_buffable: bool = Field(..., 131 | description="Specifies whether the Armament is buffable.", 132 | example=True, 133 | ) 134 | is_l1_guard: bool = Field(..., 135 | description="Specifies whether the Armament is used for guarding when equipped in left hand.", 136 | example=True, 137 | ) 138 | upgrade_material: ArmamentUpgradeMaterial = Field(..., 139 | description="Stones the Armament upgrades with, if upgradable.", 140 | example=ArmamentUpgradeMaterial.SMITHING_STONE, 141 | ) 142 | upgrade_costs: list[NonNegativeInt] = Field(..., 143 | description="Array of Rune costs to upgrade to each level, +1 starting at position 0. Empty if the Armament is non-upgradable, otherwise it contains either 10 or 25 integers. `upgrade_material` can be used to tell the actual length.", 144 | min_items=0, max_items=25, 145 | example=[530, 636, 742, 848, 954, 1060, 1166, 1272, 1378, 1484], 146 | ) 147 | attack_attributes: list[AttackAttribute] = Field(..., 148 | description="List of attack attributes the Armament can deal, usually 2.", 149 | min_items=1, max_items=2, unique_items=True, 150 | example=[AttackAttribute.PIERCE, AttackAttribute.STANDARD], 151 | ) 152 | sp_consumption_rate: NonNegativeFloat = Field(..., 153 | description="Multiplier used for calculating the effective stamina consumption from the Skill's base stamina cost.", 154 | example=1. 155 | ) 156 | requirements: StatRequirements = Field(..., 157 | description="Attribute requirements of the Armament.", 158 | example=StatRequirements(strength=16, dexterity=13), 159 | ) 160 | effects: list[Effect] = Field(..., 161 | description="Effects of the Armament.", 162 | # example provided by Effect model 163 | ) 164 | affinity: dict[Affinity, AffinityProperties] = Field(..., 165 | description="Mapping of possible affinities to their individual properties. `Standard` is always present.", 166 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/armor.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, PositiveFloat, NonNegativeInt 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.models.effect import Effect 7 | from erdb.typing.categories import ArmorCategory 8 | 9 | 10 | @dataclass(config=dt_config()) 11 | class Absorptions: 12 | physical: float 13 | strike: float 14 | slash: float 15 | pierce: float 16 | magic: float 17 | fire: float 18 | lightning: float 19 | holy: float 20 | 21 | @dataclass(config=dt_config()) 22 | class Resistances: 23 | immunity: int 24 | robustness: int 25 | focus: int 26 | vitality: int 27 | poise: int 28 | 29 | @dataclass(config=dt_config()) 30 | class Armor(Item): 31 | category: ArmorCategory = Field(..., 32 | description="Category of the Armor.", 33 | example=ArmorCategory.BODY, 34 | ) 35 | altered: str = Field(..., 36 | description="Name of the altered (or reversed) armor piece, empty if unalterable.", 37 | example="Elden Lord Armor (Altered)", 38 | ) 39 | weight: PositiveFloat = Field(..., 40 | description="Weight of the Armor.", 41 | example=9.2, 42 | ) 43 | icon_fem: NonNegativeInt = Field(..., 44 | description="Icon ID to the female version of the Armor, `icon` field specifies the male version which is usually the same.", 45 | example=584, 46 | ) 47 | absorptions: Absorptions = Field(..., 48 | description="Absorption values for the Armor.", 49 | example=Absorptions( 50 | physical=11.9, strike=10.9, slash=11.4, pierce=12.4, 51 | magic=8.8, fire=11.4, lightning=7.1, holy=8., 52 | ), 53 | ) 54 | resistances: Resistances = Field(..., 55 | description="Resistance values for the Armor.", 56 | example=Resistances(immunity=32, robustness=55, focus=18, vitality=21, poise=19), 57 | ) 58 | effects: list[Effect] = Field(..., 59 | description="Additional effects of the Armor." 60 | # example provided by Effect model 61 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/ash_of_war.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.armament import Affinity 6 | from erdb.typing.models.item import Item 7 | from erdb.typing.categories import ArmamentCategory 8 | 9 | 10 | @dataclass(config=dt_config()) 11 | class AshOfWar(Item): 12 | armament_categories: list[ArmamentCategory] = Field(..., 13 | description="Applicable weapons classes the Ash of War can be applied to.", 14 | min_items=1, 15 | example=[ArmamentCategory.GREATAXE, ArmamentCategory.GREAT_HAMMER, ArmamentCategory.COLOSSAL_WEAPON], 16 | ) 17 | default_affinity: Affinity = Field(..., 18 | description="Default Affinity the Ash of War comes with.", 19 | example=Affinity.FLAME_ART, 20 | ) 21 | possible_affinities: list[Affinity] = Field(..., 22 | description="List of Affinities the Ash of War can provide, assuming Whetblades are available.", 23 | min_items=1, 24 | example=[Affinity.STANDARD, Affinity.HEAVY, Affinity.KEEN, Affinity.QUALITY, Affinity.FIRE, Affinity.FLAME_ART] 25 | ) 26 | skill_id: int = Field(..., 27 | description="Index of the Skill the Ash of War comes with.", 28 | ge=10, 29 | example=113, 30 | ) 31 | -------------------------------------------------------------------------------- /src/erdb/typing/models/bolstering_material.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.categories import BolsteringMaterialCategory 7 | 8 | 9 | @dataclass(config=dt_config()) 10 | class BolsteringMaterial(Item): 11 | category: BolsteringMaterialCategory = Field(..., 12 | description="Bolstering Material category to discern its use.", 13 | example=BolsteringMaterialCategory.SMITHING_STONE, 14 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/common.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | 6 | 7 | @dataclass(config=dt_config()) 8 | class Damage: 9 | physical: int | None = None 10 | magic: int | None = None 11 | fire: int | None = None 12 | lightning: int | None = None 13 | holy: int | None = None 14 | stamina: int | None = None 15 | 16 | @dataclass(config=dt_config()) 17 | class StatRequirements: 18 | strength: int | None = Field(None, ge=0, le=99) 19 | dexterity: int | None = Field(None, ge=0, le=99) 20 | intelligence: int | None = Field(None, ge=0, le=99) 21 | faith: int | None = Field(None, ge=0, le=99) 22 | arcane: int | None = Field(None, ge=0, le=99) -------------------------------------------------------------------------------- /src/erdb/typing/models/correction_attack.py: -------------------------------------------------------------------------------- 1 | from pydantic.dataclasses import dataclass 2 | 3 | from erdb.typing.models import dt_config 4 | 5 | 6 | @dataclass(config=dt_config()) 7 | class AttributesCorrection: 8 | strength: bool 9 | dexterity: bool 10 | intelligence: bool 11 | faith: bool 12 | arcane: bool 13 | 14 | @dataclass(config=dt_config()) 15 | class AttributesOverride: 16 | strength: float | None = None 17 | dexterity: float | None = None 18 | intelligence: float | None = None 19 | faith: float | None = None 20 | arcane: float | None = None 21 | 22 | @dataclass(config=dt_config()) 23 | class AttributesRatio: 24 | strength: float 25 | dexterity: float 26 | intelligence: float 27 | faith: float 28 | arcane: float 29 | 30 | @dataclass(config=dt_config()) 31 | class Correction: 32 | physical: AttributesCorrection 33 | magic: AttributesCorrection 34 | fire: AttributesCorrection 35 | lightning: AttributesCorrection 36 | holy: AttributesCorrection 37 | 38 | @staticmethod 39 | def get_field_type(): 40 | return AttributesCorrection 41 | 42 | @staticmethod 43 | def get_property(attribute: str, damage_type: str) -> str: 44 | return f"is{attribute}Correct_by{damage_type}" 45 | 46 | @dataclass(config=dt_config()) 47 | class Override: 48 | physical: AttributesOverride | None = None 49 | magic: AttributesOverride | None = None 50 | fire: AttributesOverride | None = None 51 | lightning: AttributesOverride | None = None 52 | holy: AttributesOverride | None = None 53 | 54 | @staticmethod 55 | def get_field_type(): 56 | return AttributesOverride 57 | 58 | @staticmethod 59 | def get_property(attribute: str, damage_type: str) -> str: 60 | return f"overwrite{attribute}CorrectRate_by{damage_type}" 61 | 62 | @dataclass(config=dt_config()) 63 | class Ratio: 64 | physical: AttributesRatio 65 | magic: AttributesRatio 66 | fire: AttributesRatio 67 | lightning: AttributesRatio 68 | holy: AttributesRatio 69 | 70 | @staticmethod 71 | def get_property(attribute: str, damage_type: str) -> str: 72 | return f"Influence{attribute}CorrectRate_by{damage_type}" 73 | 74 | @staticmethod 75 | def get_field_type(): 76 | return AttributesRatio 77 | 78 | @dataclass(config=dt_config()) 79 | class CorrectionAttack: 80 | correction: Correction 81 | override: Override 82 | ratio: Ratio -------------------------------------------------------------------------------- /src/erdb/typing/models/correction_graph.py: -------------------------------------------------------------------------------- 1 | from pydantic import NonNegativeFloat, ConstrainedList 2 | 3 | """ 4 | `conlist` cannot be used, otherwise model is not pickable. 5 | 6 | Functional equivalent: 7 | CorrectionGraph = conlist(NonNegativeFloat, min_items=151, max_items=151) 8 | """ 9 | class CorrectionGraph(ConstrainedList): 10 | item_type = NonNegativeFloat 11 | __args__ = (NonNegativeFloat,) 12 | min_items = 151 13 | max_items = 151 -------------------------------------------------------------------------------- /src/erdb/typing/models/crafting_material.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config, NonEmptyStr 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.categories import CraftingMaterialCategory 7 | 8 | 9 | @dataclass(config=dt_config()) 10 | class CraftingMaterial(Item): 11 | category: CraftingMaterialCategory = Field(..., 12 | description="Crafting Material category to discern its use.", 13 | example=CraftingMaterialCategory.FAUNA, 14 | ) 15 | hint: NonEmptyStr = Field(..., 16 | description="In-game hint on where to find the Crafting Material.", 17 | example="Found by hunting particularly large beasts", 18 | ) 19 | products: list[NonEmptyStr] = Field(..., 20 | description="List of crafting products of the Crafting Material.", 21 | min_items=1, 22 | example=[ 23 | "Beastlure Pot", 24 | "Exalted Flesh", 25 | "Bone Great Arrow (Fletched)", 26 | "Bone Great Arrow", 27 | "Bone Ballista Bolt", 28 | ] 29 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/effect.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.effects import AttributeName, EffectModel, EffectType 6 | 7 | @dataclass(config=dt_config()) 8 | class StatusEffects: 9 | bleed: int | None = None 10 | frostbite: int | None = None 11 | poison: int | None = None 12 | scarlet_rot: int | None = None 13 | sleep: int | None = None 14 | madness: int | None = None 15 | death_blight: int | None = None 16 | 17 | @dataclass(config=dt_config()) 18 | class Effect: 19 | attribute: AttributeName = Field(..., 20 | description="Specific attribute this effect alters.", 21 | example=AttributeName.ATTACK_POWER, 22 | ) 23 | value: float = Field(..., 24 | description="Value modifying the attribute.", 25 | example=1.2, 26 | ) 27 | model: EffectModel = Field(EffectModel.MULTIPLICATIVE, 28 | description="Specifies whether the value is multiplicative (ex. rune acquisition) or additive (ex. +5 strength).", 29 | example=EffectModel.MULTIPLICATIVE, 30 | ) 31 | type: EffectType = Field(EffectType.POSITIVE, 32 | description="The kind of the effect, considering the whole context (`value` *alone* can mean different things depending on `attribute` and `model`).", 33 | example=EffectType.POSITIVE, 34 | ) 35 | value_pvp: float | None = Field(None, 36 | description="Optional modifying value when used in PvP scenario.", 37 | example=1.2, 38 | ) 39 | conditions: list[str] | None = Field(None, 40 | description="List of conditions which trigger the effect, none for passive effects.", 41 | example=["HP below 20%"], 42 | ) 43 | tick_interval: float | None = Field(None, 44 | description="Interval in seconds on how often the effect gets applied.", 45 | example=2 46 | ) 47 | -------------------------------------------------------------------------------- /src/erdb/typing/models/gesture.py: -------------------------------------------------------------------------------- 1 | from pydantic.dataclasses import dataclass 2 | 3 | from erdb.typing.models import dt_config 4 | from erdb.typing.models.item import Item 5 | 6 | 7 | @dataclass(config=dt_config()) 8 | class Gesture(Item): 9 | pass -------------------------------------------------------------------------------- /src/erdb/typing/models/info.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.categories import InfoCategory 7 | 8 | 9 | @dataclass(config=dt_config()) 10 | class Info(Item): 11 | category: InfoCategory = Field(..., 12 | description="Info Item category to discern its use.", 13 | example=InfoCategory.NOTE, 14 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/item.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, PositiveInt, NonNegativeInt 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config, NonEmptyStr 5 | from erdb.typing.models.location import LocationDetail 6 | from erdb.typing.enums import GoodsRarity 7 | 8 | 9 | @dataclass(config=dt_config()) 10 | class Item: 11 | full_hex_id: str = Field(..., 12 | description="Full ID of the Item in capital hexadecimal form. IDs differ per affinity or upgrade level.", 13 | regex=r"^[0-9A-F]+$", 14 | min_length=8, max_length=8, 15 | example="400000BE", 16 | ) 17 | id: PositiveInt = Field(..., 18 | description="ID of the Item in its individual class. IDs differ per affinity or upgrade level.", 19 | example=190, 20 | ) 21 | name: str = Field(..., 22 | description="Name of the Item.", 23 | min_length=1, 24 | example="Rune Arc", 25 | ) 26 | summary: str = Field(..., 27 | description="Short description of the Item.", 28 | min_length=1, 29 | example="Grants the blessing of an equipped Great Rune upon use", 30 | ) 31 | description: list[str] = Field(..., 32 | description="Array of lines of the in-game description, each element is separated by a new line. A line may contain multiple sentences, or be empty if in between paragraphs.", 33 | min_items=1, 34 | example=[ 35 | "A shard of the shattered Elden Ring.", 36 | "Grants the blessing of an equipped Great Rune upon use.", 37 | "", 38 | "Even if no Great Rune is equipped, it will slightly increase maximum HP upon use.", 39 | "", 40 | "The lower arc of the Elden Ring is held to be the basin in which its blessings pool. Perhaps this shard originates from that very arc.", 41 | ], 42 | ) 43 | is_tradable: bool = Field(..., 44 | description="Specifies whether the Item is visible to other players if dropped.", 45 | ) 46 | price_sold: NonNegativeInt = Field(..., 47 | description="The amount of Runes the Item is sold for, 0 if not applicabe.", 48 | example=200, 49 | ) 50 | rarity: GoodsRarity = Field(..., 51 | description="Rarity of the Item.", 52 | example=GoodsRarity.COMMON, 53 | ) 54 | icon: NonNegativeInt = Field(..., 55 | description="ID of the icon which can be shared across many items. Icons can be sourced from the game files using ERDB.", 56 | example=584, 57 | ) 58 | max_held: NonNegativeInt = Field(..., 59 | description="The maximum amount of the Item that a player can have on them.", 60 | example=99, 61 | ) 62 | max_stored: NonNegativeInt = Field(..., 63 | description="The maximum amount of the Item that can be stored in the sort chest.", 64 | example=600, 65 | ) 66 | locations: list[LocationDetail] = Field([LocationDetail()], 67 | description="List of locations in which this Item appears.", 68 | min_items=1, 69 | # example provided by LocationDetail model 70 | ) 71 | remarks: list[NonEmptyStr] = Field([], 72 | description="List of remarks and trivia about this item.", 73 | example=[ 74 | "Activates the equipped Great Rune until death.", 75 | "Use animation is long and leaves you open to attacks.", 76 | ], 77 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/key.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic.dataclasses import dataclass 3 | 4 | from erdb.typing.models import dt_config 5 | from erdb.typing.models.item import Item 6 | from erdb.typing.categories import KeyCategory 7 | 8 | 9 | @dataclass(config=dt_config()) 10 | class Key(Item): 11 | category: KeyCategory = Field(..., 12 | description="Key category to discern its use.", 13 | example=KeyCategory.QUEST, 14 | ) -------------------------------------------------------------------------------- /src/erdb/typing/models/location.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | from pydantic import Field, PositiveInt 3 | from pydantic.dataclasses import dataclass 4 | 5 | from erdb.typing.models import dt_config, NonEmptyStr 6 | from erdb.typing.enums import Region, Location, Currency 7 | 8 | 9 | @dataclass(config=dt_config()) 10 | class LocationDetail: 11 | summary: str = Field("no summary", 12 | description="Short, consice summary of the location. To help concatenating with other data, there are no capital letters or periods at the end.", 13 | min_length=1, regex=r"^.+(? str: 8 | return self 9 | 10 | @property 11 | def as_int(self) -> int: 12 | return int(self) 13 | 14 | @property 15 | def as_bool(self) -> bool: 16 | return self != "0" 17 | 18 | @property 19 | def as_float(self) -> float: 20 | return float(self) 21 | 22 | @overload 23 | def get_int(self, default: int, null_value: Any = "-1", formatter = lambda x: x) -> int: ... 24 | 25 | @overload 26 | def get_int(self, default: int | None = None, null_value: Any = "-1", formatter = lambda x: x) -> int | None: ... 27 | 28 | def get_int(self, default: int | None = None, null_value: Any = "-1", formatter = lambda x: x) -> int | None: 29 | return default if self == str(null_value) else formatter(int(self)) 30 | 31 | @overload 32 | def get_float(self, default: float, null_value: Any = "-1", formatter = lambda x: x) -> float: ... 33 | 34 | @overload 35 | def get_float(self, default: float | None = None, null_value: Any = "-1", formatter = lambda x: x) -> float | None: ... 36 | 37 | def get_float(self, default: float | None = None, null_value: Any = "-1", formatter = lambda x: x) -> float | None: 38 | return default if self == str(null_value) else formatter(float(self)) 39 | 40 | class ParamRow(NamedTuple): 41 | index: int 42 | item_id_flag: ItemIDFlag 43 | name: str 44 | field_dict: dict[str, str] 45 | 46 | @property 47 | def index_hex(self) -> str: 48 | assert self.item_id_flag != ItemIDFlag.DISABLE_CHECK 49 | return f"{self.index + self.item_id_flag:08X}" 50 | 51 | @property 52 | def is_base_item(self) -> bool: 53 | """ 54 | Retrieves whether the item is a non-upgraded version of itself. These usually differ by last two digits of the index. 55 | """ 56 | return self.index % 100 == 0 57 | 58 | def __getitem__(self, key: str) -> ParamField: 59 | assert key in self.field_dict, f"\"{key}\" not found" 60 | return ParamField(self.field_dict[key]) 61 | 62 | def __contains__(self, __x: object) -> bool: 63 | return __x in self.field_dict 64 | 65 | @classmethod 66 | def make(cls, field_dict: dict[str, str], item_id_flag: ItemIDFlag) -> Self: 67 | index = int(field_dict["Row ID"]) 68 | return cls( 69 | index=index, 70 | item_id_flag=item_id_flag, 71 | name=field_dict["Row Name"], 72 | field_dict=field_dict, 73 | ) 74 | 75 | ParamDict = dict[int, ParamRow] -------------------------------------------------------------------------------- /src/erdb/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/src/erdb/utils/__init__.py -------------------------------------------------------------------------------- /src/erdb/utils/attack_power.py: -------------------------------------------------------------------------------- 1 | from math import floor 2 | from typing import Iterator, NamedTuple 3 | 4 | 5 | """ 6 | Wrapper class for values, specifying base damage of the armament 7 | and additional scaling from player attributes. Can be unpacked with: 8 | base, scaling = ValueType(1, 2) 9 | """ 10 | class ValueType(NamedTuple): 11 | base: float 12 | scaling: float 13 | regulation: float = 1. 14 | 15 | @property 16 | def total(self) -> int: 17 | return floor((self.base + self.scaling) * self.regulation) 18 | 19 | def __iter__(self) -> Iterator[tuple[float, float]]: 20 | return iter((self.base, self.scaling)) # type: ignore 21 | 22 | def regulate(self, regulation: int) -> "ValueType": 23 | return ValueType(self.base, self.scaling, regulation / 100.) 24 | 25 | class AttackPower(NamedTuple): 26 | physical: ValueType 27 | magic: ValueType 28 | fire: ValueType 29 | lightning: ValueType 30 | holy: ValueType 31 | 32 | @property 33 | def total(self) -> int: 34 | return floor(sum((f.base + f.scaling) * f.regulation for f in self)) 35 | 36 | def items(self): 37 | return zip(self._fields, self) 38 | 39 | def regulate(self, regulations: dict[str, int]) -> "AttackPower": 40 | return AttackPower(**{f: v.regulate(regulations[f]) for f, v in self.items()}) 41 | 42 | class StatusEffects(NamedTuple): 43 | bleed: ValueType 44 | frostbite: ValueType 45 | poison: ValueType 46 | scarlet_rot: ValueType 47 | sleep: ValueType 48 | madness: ValueType 49 | 50 | def items(self): 51 | return zip(self._fields, self) 52 | 53 | class Attributes(NamedTuple): 54 | strength: int 55 | dexterity: int 56 | intelligence: int 57 | faith: int 58 | arcane: int 59 | 60 | def items(self): 61 | return zip(self._fields, self) 62 | 63 | @classmethod 64 | def from_string(cls, string: str) -> "Attributes": 65 | parts = string.split(",") 66 | 67 | assert len(parts) == 5, "Invalid Attributes string" 68 | assert all(1 <= v <= 99 for v in map(int, parts)), "Invalid Attributes string" 69 | 70 | return cls(*map(int, parts)) 71 | 72 | def __str__(self) -> str: 73 | return f"{self.strength},{self.dexterity},{self.intelligence},{self.faith},{self.arcane}" 74 | 75 | class CorrectionAttack(NamedTuple): 76 | correction: dict[str, dict] 77 | override: dict[str, dict] 78 | ratio: dict[str, dict] 79 | 80 | @classmethod 81 | def from_dict(cls, data: dict) -> "CorrectionAttack": 82 | return cls(data["correction"], data["override"], data["ratio"]) 83 | 84 | class CalculatorData(NamedTuple): 85 | armaments: dict[str, dict] 86 | reinforcements: dict[str, list[dict]] 87 | correction_attack: dict[str, dict[str, str]] 88 | correction_graph: dict[str, list[float]] 89 | 90 | class ArmamentCalculator: 91 | _name: str 92 | _affinity: str 93 | _level: int 94 | 95 | # data cache relevant for this armament, affinity and level 96 | _affinity_properties: dict 97 | _requirements: dict[str, int] 98 | _reinforcement: dict 99 | _correction_attack: CorrectionAttack 100 | _correction_graph: dict[str, list[float]] 101 | 102 | def __init__(self, data: CalculatorData, name: str, affinity: str = "Standard", level: int = 0) -> None: 103 | self._name = name 104 | self._affinity = affinity 105 | self._level = level 106 | self._cache_data(data) 107 | 108 | def _cache_data(self, data: CalculatorData): 109 | """ 110 | Cache some data we will be using for this particular armament/affinity/level combo, 111 | called every time the ArmamentCalculator instance is updated. 112 | """ 113 | self._affinity_properties = data.armaments[self._name]["affinity"][self._affinity] 114 | reinforcement_id = self._affinity_properties["reinforcement_id"] 115 | correction_attack_id = self._affinity_properties["correction_attack_id"] 116 | 117 | self._requirements = data.armaments[self._name]["requirements"] 118 | self._reinforcement = data.reinforcements[str(reinforcement_id)][self._level] 119 | self._correction_attack = CorrectionAttack.from_dict(data.correction_attack[str(correction_attack_id)]) 120 | self._correction_graph = data.correction_graph 121 | 122 | @property 123 | def name(self) -> str: 124 | return self._name 125 | 126 | @property 127 | def affinity(self) -> str: 128 | return self._affinity 129 | 130 | @property 131 | def level(self) -> int: 132 | return self._level 133 | 134 | def set_name(self, name: str, data: CalculatorData): 135 | self._name = name 136 | self._cache_data(data) 137 | 138 | def set_affinity(self, affinity: str, data: CalculatorData): 139 | self._affinity = affinity 140 | self._cache_data(data) 141 | 142 | def set_level(self, level: int, data: CalculatorData): 143 | self._level = level 144 | self._cache_data(data) 145 | 146 | """ 147 | Calculate attack power of the weapon given player attributes. 148 | """ 149 | def attack_power(self, attributes: Attributes) -> AttackPower: 150 | ret = {attack_type: self._get_base_and_scaled_damage(attack_type, attributes) for attack_type in AttackPower._fields} 151 | return AttackPower(**ret) 152 | 153 | """ 154 | Retrieve base damage and scaled damage from the specific attack type and attributes. 155 | """ 156 | def _get_base_and_scaled_damage(self, attack_type: str, attributes: Attributes) -> ValueType: 157 | base = self._affinity_properties["damage"].get(attack_type, 0.0) * self._reinforcement["damage"][attack_type] 158 | scalings = [self._get_scaling_per_attribute(attack_type, attrib_name, attrib_value) for attrib_name, attrib_value in attributes.items()] 159 | low_cap = min(scalings) # in case multiple attributes are not met, do not go below lowest scaling 160 | 161 | # return base damage and scaling which is the base * sum of scalings per every attribute 162 | return ValueType(base, base * max(low_cap, sum(scalings))) 163 | 164 | """ 165 | Retrieve scaled damage for an attack type/player attribute/attribute value combo. 166 | """ 167 | def _get_scaling_per_attribute(self, attack_type: str, attrib_name: str, attrib_value: int) -> float: 168 | # attack type does not scale with this attribute 169 | if not self._correction_attack.correction[attack_type][attrib_name]: 170 | return 0.0 171 | 172 | # get the impact ratio of the scaling for this attack type and attribute 173 | scaling_impact_ratio = self._correction_attack.ratio[attack_type][attrib_name] 174 | 175 | # requirement is not met, penalize scaling of this attack type for this attribute 176 | if attrib_value < self._requirements.get(attrib_name, 0): 177 | return 0.6 * (scaling_impact_ratio - 1) - 0.4 178 | 179 | # get scaling values for armament and its particular reinforcement level 180 | base_scaling = self._affinity_properties["scaling"].get(attrib_name, 0.0) 181 | level_scaling = self._reinforcement["scaling"][attrib_name] 182 | 183 | # override base scaling if an override is defined 184 | base_scaling = self._correction_attack.override[attack_type].get(attrib_name, base_scaling) 185 | 186 | # get correction for scaling based on the attribute value 187 | correction_id = self._affinity_properties["correction_calc_id"][attack_type] 188 | scaling_correction = self._correction_graph[str(correction_id)][attrib_value] 189 | 190 | # return actual scaled value for this attack type and attribute 191 | return scaling_impact_ratio - 1 + base_scaling * level_scaling * scaling_correction * scaling_impact_ratio 192 | 193 | """ 194 | Calculate status effects of the weapon given player attributes. 195 | """ 196 | def status_effects(self, attributes: Attributes) -> StatusEffects: 197 | ret = {effect_type: self._get_base_and_scaled_effect(effect_type, attributes) for effect_type in StatusEffects._fields} 198 | return StatusEffects(**ret) 199 | 200 | """ 201 | Retrieve base and scaled status effect values from the specific effect type and attributes. 202 | """ 203 | def _get_base_and_scaled_effect(self, effect_type: str, attributes: Attributes) -> ValueType: 204 | base = self._affinity_properties["status_effects"].get(effect_type, 0.0) 205 | overlays = self._affinity_properties["status_effect_overlay"] 206 | level = self._reinforcement["level"] 207 | 208 | # overwrite base value if the effect upgrades for the affinity 209 | if len(overlays) > level and effect_type in overlays[level]: 210 | base = overlays[level][effect_type] 211 | 212 | # retrieve scaling value if the effect can scale 213 | if correction_id := self._affinity_properties["correction_calc_id"].get(effect_type): 214 | base_scaling = self._affinity_properties["scaling"].get("arcane", 0.0) 215 | level_scaling = self._reinforcement["scaling"].get("arcane") 216 | scaling_correction = self._correction_graph[str(correction_id)][attributes.arcane] 217 | 218 | return ValueType(base, base * base_scaling * level_scaling * scaling_correction) 219 | 220 | return ValueType(base, 0.0) -------------------------------------------------------------------------------- /src/erdb/utils/cloudflare_images_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import NamedTuple 3 | 4 | 5 | class CloudflareImagesClient(NamedTuple): 6 | account_id: str 7 | api_token: str 8 | 9 | @property 10 | def endpoint(self) -> str: 11 | return f"https://api.cloudflare.com/client/v4/accounts/{self.account_id}/images/v1" 12 | 13 | @property 14 | def headers(self) -> dict: 15 | return {"Authorization": f"Bearer {self.api_token}"} 16 | 17 | def upload(self, data: bytes, image_id: str): 18 | files = { 19 | "file": data, 20 | "id": (None, image_id), 21 | } 22 | 23 | resp = requests.post(self.endpoint, headers=self.headers, files=files) 24 | 25 | if resp.status_code == 409: 26 | print(f"WARNING: {image_id} not uploaded due to conflict (409)", flush=True) 27 | return 28 | 29 | if resp.status_code != 200: 30 | print(f"Uploading {image_id} failed with response code {resp.status_code}.", flush=True) 31 | assert False, "Image upload to Cloudflare instance failed" -------------------------------------------------------------------------------- /src/erdb/utils/common.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | from operator import add 4 | from itertools import repeat 5 | from pathlib import Path 6 | from typing import Any, NamedTuple, overload, Self 7 | from urllib.parse import urlparse 8 | from pydantic.json import pydantic_encoder 9 | 10 | from erdb.typing.params import ParamDict 11 | 12 | 13 | class Destination(NamedTuple): 14 | protocol: str 15 | path: Path 16 | netloc: str | None = None 17 | username: str | None = None 18 | password: str | None = None 19 | 20 | @property 21 | def is_local(self) -> bool: 22 | return self.protocol == "file" 23 | 24 | @classmethod 25 | def from_str(cls, value: str) -> Self: 26 | data = urlparse(value) 27 | 28 | if data.scheme in ["", "file"]: 29 | return cls("file", Path(value)) 30 | 31 | return cls(data.scheme, Path(data.path), data.netloc, data.username, data.password) 32 | 33 | def find_offset_indices(base_index: int, params: ParamDict, possible_maxima: list[int], increment: int = 1) -> tuple[map, range]: 34 | """ 35 | Returns lists of valid indices from `base_index` value which offset 36 | until the highest possible maxima is reached. 37 | 38 | First list is possible indices and second is raw level integers. 39 | """ 40 | def _find_offset_maxima() -> int | None: 41 | for maxima in sorted(possible_maxima, reverse=True): # from largest 42 | if (base_index + maxima * increment) in params.keys(): 43 | return maxima 44 | return None 45 | 46 | maxima = _find_offset_maxima() 47 | assert maxima is not None 48 | 49 | levels = range(0, (maxima + 1) * increment, increment) 50 | return map(add, repeat(base_index), levels), levels 51 | 52 | def prepare_writable_path(path: Path, default_filename: str) -> Path: 53 | (path if path.suffix == "" else path.parent).mkdir(parents=True, exist_ok=True) 54 | return path / default_filename if path.is_dir() else path 55 | 56 | @overload 57 | def remove_nulls(val: dict) -> dict: ... 58 | 59 | @overload 60 | def remove_nulls(val: list) -> list: ... 61 | 62 | @overload 63 | def remove_nulls(val: Any) -> Any: ... 64 | 65 | def remove_nulls(val: dict | list | Any) -> dict | list | Any: 66 | """ 67 | Recursively remove all None values from dictionaries and lists, and returns 68 | the result as a new dictionary or list. 69 | """ 70 | if isinstance(val, list): 71 | return [remove_nulls(x) for x in val if x is not None] 72 | 73 | elif isinstance(val, dict): 74 | return {k: remove_nulls(v) for k, v in val.items() if v is not None} 75 | 76 | else: 77 | return val 78 | 79 | def pydantic_encoder_no_nulls(obj: Any) -> Any: 80 | return remove_nulls(pydantic_encoder(obj)) 81 | 82 | def get_filename(name: str) -> str: 83 | return re.sub(r"(?u)[^-\w. &\[\]']", "", name) 84 | 85 | def as_str(v: Any) -> str: 86 | return v.value if isinstance(v, Enum) else str(v) 87 | 88 | def getattrstr(obj: Any, field: str) -> str: 89 | return as_str(getattr(obj, field)) 90 | 91 | def scaling_grade(value: float, null_value: str = "-") -> str: 92 | if value >= 1.75: return "S" 93 | if value >= 1.4: return "A" 94 | if value >= 0.9: return "B" 95 | if value >= 0.6: return "C" 96 | if value >= 0.25: return "D" 97 | if value > 0.0: return "E" 98 | return null_value 99 | 100 | def to_somber(level: int) -> int: 101 | return { 102 | 0: 0, 1: 0, 103 | 2: 1, 3: 1, 4: 1, 104 | 5: 2, 6: 2, 105 | 7: 3, 8: 3, 9: 3, 106 | 10: 4, 11: 4, 107 | 12: 5, 13: 5, 14: 5, 108 | 15: 6, 16: 6, 109 | 17: 7, 18: 7, 19: 7, 110 | 20: 8, 21: 8, 111 | 22: 9, 23: 9, 24: 9, 112 | 25: 10, 113 | }[level] -------------------------------------------------------------------------------- /src/erdb/utils/find_used_effects.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from erdb.loaders.params import load as load_params 4 | from erdb.typing.params import ParamDict 5 | from erdb.typing.enums import ItemIDFlag 6 | from erdb.typing.game_version import GameVersion 7 | from erdb.typing import sp_effect 8 | 9 | 10 | _IGNORED_FIELDS = [ 11 | "Row ID", "Row Name", "iconId", "lookAtTargetPosOffset", "targetPriority", 12 | "effectTargetSelf", "effectTargetFriend", "effectTargetEnemy", "effectTargetPlayer", 13 | "effectTargetAI", "effectTargetLive", "effectTargetGhost", "effectTargetAttacker", 14 | "vfxId", "vfxId1", "vfxId2", "vfxId3", "vfxId4", "vfxId5", "vfxId6", "vfxId7" 15 | ] 16 | 17 | def get_effect_ids(rows: ParamDict, fields: list[str]) -> dict[int, set[str]]: 18 | ids = defaultdict(set) 19 | 20 | for row in rows.values(): 21 | for field in fields: 22 | if i := row[field].get_int(): 23 | ids[i].add(row.name) 24 | 25 | return ids 26 | 27 | def get_changing_fields(effect_ids: dict[int, set[str]], effects: ParamDict) -> list[str]: 28 | changing_fields = set() 29 | null_effect = effects[2] # IDs 0 and 1 seem to have some properties filled in 30 | 31 | for field in null_effect.field_dict.keys(): 32 | if field in _IGNORED_FIELDS: 33 | continue 34 | 35 | null_value = null_effect[field] 36 | items = [] 37 | 38 | for i, item_names in effect_ids.items(): 39 | if null_value != effects[i][field]: 40 | items += item_names 41 | 42 | if len(items) > 0 and len(items) != len(effect_ids): 43 | name = sp_effect.details[field]["DisplayName"] 44 | field_padded = f"{name} ({field})".ljust(64) 45 | changing_fields.add(f">> {field_padded}: {', '.join(items)}") 46 | 47 | return list(changing_fields) 48 | 49 | def main(): 50 | talismans = load_params("EquipParamAccessory", GameVersion.from_string("1.04.1"), ItemIDFlag.ACCESSORIES) 51 | # resident effects for talismans are conditials for attack increases 52 | effect_ids = get_effect_ids(talismans, ["refId"]) 53 | 54 | # protectors = er_params.load("EquipParamProtector", "1.04.1", ItemIDFlag.PROTECTORS) 55 | # effect_ids = get_effect_ids(protectors, ["residentSpEffectId", "residentSpEffectId2", "residentSpEffectId3"]) 56 | 57 | effects = load_params("SpEffectParam", GameVersion.from_string("1.04.1"), ItemIDFlag.NON_EQUIPABBLE) 58 | changing_fields = get_changing_fields(effect_ids, effects) 59 | 60 | with open("out.txt", "w") as f: 61 | for field in sorted(changing_fields): 62 | f.write(f"{field}\n") 63 | 64 | if __name__ == "__main__": 65 | main() -------------------------------------------------------------------------------- /src/erdb/utils/find_valid_values.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from erdb.loaders.params import load as load_params 4 | from erdb.typing.params import ParamDict 5 | from erdb.typing.enums import ItemIDFlag 6 | from erdb.typing.game_version import GameVersion 7 | 8 | 9 | def _get_values(effects: ParamDict, field: str, limit: int = 10) -> dict[str, list[str]]: 10 | values = defaultdict(list) 11 | 12 | for effect in effects.values(): 13 | val = effect[field] 14 | case = str(effect.index) if len(effect.name) == 0 else effect.name 15 | 16 | if len(values[val]) < limit: 17 | values[val].append(case) 18 | 19 | elif len(values[val]) == limit: 20 | values[val].append("(...)") 21 | 22 | return values 23 | 24 | def find_valid_values(param_name: str, version: str, field: str, limit: int = 8): 25 | """ 26 | Interesting param.fields overview: 27 | * SpEffectParam:conditionHp -- trigger when HP below % 28 | * SpEffectParam:conditionHpRate -- trigger when HP above % 29 | * SpEffectParam:invocationConditionsStateChange1 -- seemingly the only differentiating field for Concealing Veil 30 | * SpEffectParam:invocationConditionsStateChange1/2/3 31 | * SpEffectParam:toughnessDamageCutRate -- kinda like inverted poise 32 | * SpEffectParam:miracleConsumptionRate -- FP consumption rate for incantations 33 | * SpEffectParam:shamanConsumptionRate -- FP consumption rate for pyormancies (?) 34 | * SpEffectParam:magicConsumptionRate -- FP consumption rate for sorceries 35 | * SpEffectParam:stateInfo -- a lot of unique effects seem to use this and this only 36 | * SpEffectParam:guardStaminaCutRate -- Greatshield Talisman doesn't seem to use this 37 | * SpEffectParam:magicSubCategoryChange1/2/3 -- seem to specify conditions exclusively 38 | """ 39 | 40 | params = load_params(param_name, GameVersion.from_string(version), ItemIDFlag.NON_EQUIPABBLE) 41 | values = _get_values(params, field, limit=8 if limit < 0 else limit) 42 | 43 | for value in sorted(values.keys(), key=float): 44 | cases = values[value] 45 | value = f"{value}".ljust(8) 46 | print(f">> {value}:", ', '.join(cases)) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EldenRingDatabase/erdb/e2028a6e044b920a471388fb4e1c468b31b64350/tests/__init__.py -------------------------------------------------------------------------------- /tests/attack_power_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | import pytest 4 | 5 | from erdb.utils.attack_power import Attributes, CalculatorData, ArmamentCalculator 6 | 7 | 8 | """ 9 | Version of the game the sample data has been explicitly collected for 10 | """ 11 | _GAME_VERSION: str = "1.05.0" 12 | 13 | _TEST_DIR = Path(__file__).parent.resolve() 14 | _DATA_DIR = _TEST_DIR / "attack_values" 15 | _GAMEDATA_DIR = _TEST_DIR.parent / _GAME_VERSION 16 | _ATTRIBUTE_SETS = [Attributes.from_string(f.stem) for f in _DATA_DIR.glob("*")] 17 | 18 | def pytest_generate_tests(metafunc): 19 | assert "armament" in metafunc.fixturenames 20 | assert "attribs" in metafunc.fixturenames 21 | 22 | with open(_GAMEDATA_DIR / "armaments.json") as f: 23 | armaments = json.load(f)["Armaments"] 24 | 25 | armament_data: list[tuple[str, str]] = [] 26 | armament_ids: list[str] = [] 27 | 28 | for armament, properties in armaments.items(): 29 | for affinity in properties["affinity"].keys(): 30 | armament_data.append((affinity, armament)) 31 | armament_ids.append(f"{affinity} {armament}") 32 | 33 | metafunc.parametrize("armament", armament_data, ids=armament_ids) 34 | metafunc.parametrize("attribs", _ATTRIBUTE_SETS, ids=[*map(str, _ATTRIBUTE_SETS)]) 35 | 36 | @pytest.fixture(scope="module") 37 | def calc_data() -> CalculatorData: 38 | return CalculatorData.create(_GAMEDATA_DIR) 39 | 40 | @pytest.fixture(scope="module") 41 | def results_data() -> dict[str, dict]: 42 | def load(attribs): 43 | with open(_DATA_DIR / f"{attribs}.json") as f: 44 | return json.load(f) 45 | 46 | return {attribs: load(attribs) for attribs in _ATTRIBUTE_SETS} 47 | 48 | def test_attack_power(calc_data, armament, attribs, results_data): 49 | affinity, name = armament 50 | level = "0" if name == "Meteorite Staff" else "10" 51 | 52 | expected = results_data[attribs][name][affinity]["attack_power"] 53 | calc = ArmamentCalculator(calc_data, name, affinity, level) 54 | ap = calc.attack_power(attribs) 55 | 56 | if ap.physical.total > 0: 57 | assert len(expected["physical"]) == 4 58 | assert expected["physical"][3] == ap.physical.total 59 | 60 | if ap.magic.total > 0: 61 | assert len(expected["magic"]) == 4 62 | assert expected["magic"][3] == ap.magic.total 63 | 64 | if ap.fire.total > 0: 65 | assert len(expected["fire"]) == 4 66 | assert expected["fire"][3] == ap.fire.total 67 | 68 | if ap.lightning.total > 0: 69 | assert len(expected["lightning"]) == 4 70 | assert expected["lightning"][3] == ap.lightning.total 71 | 72 | if ap.holy.total > 0: 73 | assert len(expected["holy"]) == 4 74 | assert expected["holy"][3] == ap.holy.total -------------------------------------------------------------------------------- /tests/common_test.py: -------------------------------------------------------------------------------- 1 | from typing import List, NamedTuple 2 | import pytest 3 | 4 | from erdb.typing.params import ParamDict 5 | from erdb.utils.common import find_offset_indices 6 | 7 | 8 | class ParamDictGenerator(NamedTuple): 9 | starting_element: int 10 | element_interval: int 11 | levels: List[int] 12 | level_interval: int 13 | 14 | def generate(self) -> ParamDict: 15 | def _rangec(start: int, count: int, step: int): 16 | return range(start, start + count * step, step) 17 | 18 | element_count = len(self.levels) 19 | element_range = _rangec(self.starting_element, element_count, self.element_interval) 20 | 21 | params: ParamDict = dict() 22 | 23 | for row_id, level_count in zip(element_range, self.levels): 24 | for offset in _rangec(0, level_count, self.level_interval): 25 | params[str(row_id + offset)] = {} 26 | 27 | return params 28 | 29 | @pytest.mark.parametrize("starting_element,element_interval,levels,level_interval,results", [ 30 | (1000, 100, [11, 26, 26], 1, [range(1000, 1011), range(1100, 1126), range(1200, 1226)]), 31 | (10000, 10000, [11, 1, 26], 100, [range(10000, 11001, 100), range(20000, 20001, 100), range(30000, 32501, 100)]), 32 | ]) 33 | def test_find_offset_indices(starting_element: int, element_interval: int, levels: List[int], level_interval: int, results): 34 | possible_maxima = list(set([l - 1 for l in levels])) 35 | params = ParamDictGenerator(starting_element, element_interval, levels, level_interval).generate() 36 | 37 | for element in range(len(levels)): 38 | base = starting_element + element * element_interval 39 | ids, levels = find_offset_indices(base, params, possible_maxima, level_interval) 40 | element_offset = starting_element + element_interval * element 41 | 42 | assert list(results[element]) == list(ids) 43 | assert [index - element_offset for index in results[element]] == list(levels) -------------------------------------------------------------------------------- /tests/game_version_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from erdb.typing.game_version import GameVersion, GameVersionRange 4 | 5 | 6 | def _make(major: str, minor: str, patch: str) -> GameVersion: 7 | assert len(minor) >= 2, "Minor part must be at least 2 digits" 8 | return GameVersion(major, minor, patch, [int(major), int(minor), int(patch)]) 9 | 10 | @pytest.fixture(scope="module") 11 | def base() -> GameVersion: 12 | return _make("1", "02", "3") 13 | 14 | @pytest.mark.parametrize("left,right", [ 15 | (["1", "02", "3"], ["1", "02", "3"]), 16 | (["1", "03", "3"], ["1", "03", "3"]), 17 | (["3", "03", "3"], ["3", "03", "3"]), 18 | ]) 19 | def test_eq(left: list[str], right: list[str]): 20 | assert _make(*left) == _make(*right) 21 | 22 | @pytest.mark.parametrize("left,right", [ 23 | (["1", "02", "4"], ["1", "02", "3"]), 24 | (["1", "04", "3"], ["1", "03", "3"]), 25 | (["4", "03", "3"], ["3", "03", "3"]), 26 | ]) 27 | def test_ne(left: list[str], right: list[str]): 28 | assert _make(*left) != _make(*right) 29 | 30 | @pytest.mark.parametrize("left", [["1", "02", "2"], ["1", "01", "9"], ["0", "09", "9"]]) 31 | def test_lt(base, left): 32 | assert _make(*left) < base 33 | 34 | @pytest.mark.parametrize("left", [["1", "02", "4"], ["1", "03", "0"], ["2", "00", "0"]]) 35 | def test_gt(base, left): 36 | assert _make(*left) > base 37 | 38 | @pytest.mark.parametrize("left", [["1", "02", "2"], ["1", "02", "3"]]) 39 | def test_le(base, left): 40 | assert _make(*left) <= base 41 | 42 | @pytest.mark.parametrize("left", [["1", "02", "3"], ["1", "02", "4"]]) 43 | def test_ge(base, left): 44 | assert _make(*left) >= base 45 | 46 | @pytest.mark.parametrize("string,expected", [ 47 | ("1.04.1", _make("1", "04", "1")), 48 | ("2.64.9", _make("2", "64", "9")), 49 | ("0.03.9", _make("0", "03", "9")), 50 | ]) 51 | def test_from_string(string: str, expected: GameVersion): 52 | assert GameVersion.from_string(string) == expected 53 | 54 | @pytest.mark.parametrize("string,begin,end", [ 55 | ("from 1.04.1 until 1.05.0", _make("1", "04", "1"), _make("1", "05", "0")), 56 | ("from 1.04.1", _make("1", "04", "1"), GameVersion.max()), 57 | ("until 1.05.0", GameVersion.min(), _make("1", "05", "0")), 58 | ("only 1.04.1", _make("1", "04", "1"), GameVersion.max()), 59 | ("any version", GameVersion.min(), GameVersion.max()), 60 | ]) 61 | def test_range_from_string(string: str, begin: GameVersion, end: GameVersion): 62 | version_range = GameVersionRange.from_string(string) 63 | assert version_range.begin == begin 64 | assert version_range.end == end 65 | 66 | @pytest.mark.parametrize("string,version", [ 67 | ("from 1.04.1 until 1.05.0", _make("1", "04", "1")), 68 | ("from 1.04.1 until 1.05.0", _make("1", "04", "2")), 69 | ("from 1.04.1 until 1.05.0", _make("1", "04", "99")), 70 | ("from 1.04.1", _make("1", "04", "99")), 71 | ("from 1.04.1", _make("1", "05", "0")), 72 | ("until 1.05.0", _make("1", "04", "99")), 73 | ("until 1.05.0", _make("1", "03", "0")), 74 | ("only 1.05.0", _make("1", "05", "0")), 75 | ("any version", _make("1", "04", "99")), 76 | ("any version", _make("1", "05", "99")), 77 | ]) 78 | def test_range_contains(string: str, version: GameVersion): 79 | assert version in GameVersionRange.from_string(string) 80 | 81 | @pytest.mark.parametrize("string,version", [ 82 | ("from 1.04.1 until 1.05.0", _make("1", "04", "0")), 83 | ("from 1.04.1 until 1.05.0", _make("1", "05", "0")), 84 | ("from 1.04.1 until 1.05.0", _make("1", "05", "99")), 85 | ("from 1.04.1", _make("1", "04", "0")), 86 | ("from 1.04.1", _make("1", "03", "99")), 87 | ("until 1.05.0", _make("1", "05", "0")), 88 | ("only 1.05.0", _make("1", "04", "99")), 89 | ("only 1.05.0", _make("1", "05", "1")), 90 | ]) 91 | def test_range_not_contains(string: str, version: GameVersion): 92 | assert version not in GameVersionRange.from_string(string) --------------------------------------------------------------------------------