├── .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 |
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 |
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)
--------------------------------------------------------------------------------