mirror of
https://github.com/283375/arcaea-offline.git
synced 2025-07-02 12:46:26 +00:00
Compare commits
4 Commits
0d5e21a90e
...
master
Author | SHA1 | Date | |
---|---|---|---|
908613306f
|
|||
38e0d7f8d1
|
|||
937bbe2eee
|
|||
e7398be07e
|
40
.github/workflows/main.yml
vendored
Normal file
40
.github/workflows/main.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
name: test & lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pytest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dev dependencies
|
||||||
|
run: 'pip install .[dev]'
|
||||||
|
- name: Run tests
|
||||||
|
run: 'pytest -v'
|
||||||
|
|
||||||
|
ruff:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dev dependencies
|
||||||
|
run: 'pip install .[dev]'
|
||||||
|
- name: Run linter
|
||||||
|
run: 'ruff check'
|
23
.github/workflows/test.yml
vendored
23
.github/workflows/test.yml
vendored
@ -1,23 +0,0 @@
|
|||||||
name: Run tests
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'master'
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
pytest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.8', '3.9', '3.10', '3.11']
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- run: 'pip install -r requirements.dev.txt .'
|
|
||||||
- run: 'pytest -v'
|
|
@ -4,11 +4,10 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.1.0
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.4.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: ruff
|
||||||
- repo: https://github.com/PyCQA/isort
|
args: ["--fix"]
|
||||||
rev: 5.12.0
|
- id: ruff-format
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
|
@ -10,9 +10,14 @@ English | [简体中文](./README.zh_Hans.md)
|
|||||||
|
|
||||||
## WIP
|
## WIP
|
||||||
|
|
||||||
> **Warning**
|
> [!CAUTION]
|
||||||
> This project is under active development, thus it is unstable and API may change frequently.
|
> This project is under active development, thus it is unstable and API may change frequently.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> v0.3.0 is under development, check out [this branch](https://github.com/283375/arcaea-offline/tree/0.3.0-refactor)!
|
||||||
|
>
|
||||||
|
> Once v0.3.0 is ready for release, this repository will be transferred to *[ArcaeaOffline](https://github.com/ArcaeaOffline)/core-python*
|
||||||
|
|
||||||
## What is this?
|
## What is this?
|
||||||
|
|
||||||
This is the core library of `Arcaea Offline`, designed to manage player scores, calculate their potential, and provide various useful tools.
|
This is the core library of `Arcaea Offline`, designed to manage player scores, calculate their potential, and provide various useful tools.
|
||||||
|
@ -8,9 +8,14 @@
|
|||||||
|
|
||||||
## WIP
|
## WIP
|
||||||
|
|
||||||
> **Warning**
|
> [!CAUTION]
|
||||||
> 该项目正处于早期开发阶段,不能保证稳定性,且 API 可能随时变动。
|
> 该项目正处于早期开发阶段,不能保证稳定性,且 API 可能随时变动。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> v0.3.0 正在[此分支](https://github.com/283375/arcaea-offline/tree/0.3.0-refactor)下开发!
|
||||||
|
>
|
||||||
|
> 在 v0.3.0 准备好发布后,此存储库将被迁移至 *[ArcaeaOffline](https://github.com/ArcaeaOffline)/core-python*。
|
||||||
|
|
||||||
## 这是什么?
|
## 这是什么?
|
||||||
|
|
||||||
这是 `Arcaea Offline` 的核心依赖库,用于维护分数数据库、计算潜力值,并提供一些实用工具。
|
这是 `Arcaea Offline` 的核心依赖库,用于维护分数数据库、计算潜力值,并提供一些实用工具。
|
||||||
|
@ -10,32 +10,44 @@ description = "Manage your local Arcaea score database."
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"beautifulsoup4==4.12.2",
|
||||||
"SQLAlchemy==2.0.20",
|
"SQLAlchemy==2.0.20",
|
||||||
"SQLAlchemy-Utils==0.41.1",
|
"SQLAlchemy-Utils==0.41.1",
|
||||||
|
"Whoosh==2.7.4",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["ruff~=0.4", "pre-commit~=3.3", "pytest~=7.4", "tox~=4.11"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Homepage" = "https://github.com/283375/arcaea-offline"
|
"Homepage" = "https://github.com/283375/arcaea-offline"
|
||||||
"Bug Tracker" = "https://github.com/283375/arcaea-offline/issues"
|
"Bug Tracker" = "https://github.com/283375/arcaea-offline/issues"
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
src_paths = ["src/arcaea_offline"]
|
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
ignore = ["build/"]
|
ignore = ["build/"]
|
||||||
|
|
||||||
[tool.pylint.main]
|
[tool.ruff.lint]
|
||||||
jobs = 0
|
# Full list: https://docs.astral.sh/ruff/rules
|
||||||
|
select = [
|
||||||
[tool.pylint.logging]
|
"E", # pycodestyle (Error)
|
||||||
disable = [
|
"W", # pycodestyle (Warning)
|
||||||
"missing-module-docstring",
|
"F", # pyflakes
|
||||||
"missing-class-docstring",
|
"I", # isort
|
||||||
"missing-function-docstring",
|
"PL", # pylint
|
||||||
"not-callable", # false positive to sqlalchemy `func.*`, remove this when pylint-dev/pylint(#8138) closed
|
"N", # pep8-naming
|
||||||
|
"FBT", # flake8-boolean-trap
|
||||||
|
"A", # flake8-builtins
|
||||||
|
"DTZ", # flake8-datetimez
|
||||||
|
"LOG", # flake8-logging
|
||||||
|
"Q", # flake8-quotes
|
||||||
|
"G", # flake8-logging-format
|
||||||
|
"PIE", # flake8-pie
|
||||||
|
"PT", # flake8-pytest-style
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line-too-long
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
black==23.3.0
|
ruff~=0.4
|
||||||
isort==5.12.0
|
pre-commit~=3.3
|
||||||
pre-commit==3.3.1
|
pytest~=7.4
|
||||||
pylint==3.0.2
|
tox~=4.11
|
||||||
pytest==7.4.3
|
|
||||||
tox==4.11.3
|
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
|
beautifulsoup4==4.12.2
|
||||||
SQLAlchemy==2.0.20
|
SQLAlchemy==2.0.20
|
||||||
SQLAlchemy-Utils==0.41.1
|
SQLAlchemy-Utils==0.41.1
|
||||||
|
Whoosh==2.7.4
|
||||||
|
8
src/arcaea_offline/calculate/__init__.py
Normal file
8
src/arcaea_offline/calculate/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from .b30 import calculate_b30, get_b30_calculated_list
|
||||||
|
from .score import (
|
||||||
|
calculate_constants_from_play_rating,
|
||||||
|
calculate_play_rating,
|
||||||
|
calculate_score_modifier,
|
||||||
|
calculate_score_range,
|
||||||
|
calculate_shiny_pure,
|
||||||
|
)
|
24
src/arcaea_offline/calculate/b30.py
Normal file
24
src/arcaea_offline/calculate/b30.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from ..models.scores import ScoreCalculated
|
||||||
|
|
||||||
|
|
||||||
|
def get_b30_calculated_list(
|
||||||
|
calculated_list: List[ScoreCalculated],
|
||||||
|
) -> List[ScoreCalculated]:
|
||||||
|
best_scores: Dict[str, ScoreCalculated] = {}
|
||||||
|
for calculated in calculated_list:
|
||||||
|
key = f"{calculated.song_id}_{calculated.rating_class}"
|
||||||
|
stored = best_scores.get(key)
|
||||||
|
if stored and stored.score < calculated.score or not stored:
|
||||||
|
best_scores[key] = calculated
|
||||||
|
ret_list = list(best_scores.values())
|
||||||
|
ret_list = sorted(ret_list, key=lambda c: c.potential, reverse=True)[:30]
|
||||||
|
return ret_list
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_b30(calculated_list: List[ScoreCalculated]) -> Decimal:
|
||||||
|
ptt_list = [Decimal(c.potential) for c in get_b30_calculated_list(calculated_list)]
|
||||||
|
sum_ptt_list = sum(ptt_list)
|
||||||
|
return (sum_ptt_list / len(ptt_list)) if sum_ptt_list else Decimal("0.0")
|
66
src/arcaea_offline/calculate/score.py
Normal file
66
src/arcaea_offline/calculate/score.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from math import floor
|
||||||
|
from typing import Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_score_range(notes: int, pure: int, far: int):
|
||||||
|
single_note_score = 10000000 / Decimal(notes)
|
||||||
|
|
||||||
|
actual_score = floor(
|
||||||
|
single_note_score * pure + single_note_score * Decimal(0.5) * far
|
||||||
|
)
|
||||||
|
return (actual_score, actual_score + pure)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_score_modifier(score: int) -> Decimal:
|
||||||
|
if score >= 10000000:
|
||||||
|
return Decimal(2)
|
||||||
|
if score >= 9800000:
|
||||||
|
return Decimal(1) + (Decimal(score - 9800000) / 200000)
|
||||||
|
return Decimal(score - 9500000) / 300000
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_play_rating(constant: int, score: int) -> Decimal:
|
||||||
|
score_modifier = calculate_score_modifier(score)
|
||||||
|
return max(Decimal(0), Decimal(constant) / 10 + score_modifier)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_shiny_pure(notes: int, score: int, pure: int, far: int) -> int:
|
||||||
|
single_note_score = 10000000 / Decimal(notes)
|
||||||
|
actual_score = single_note_score * pure + single_note_score * Decimal(0.5) * far
|
||||||
|
return score - floor(actual_score)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConstantsFromPlayRatingResult:
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
EXPlus: Tuple[Decimal, Decimal]
|
||||||
|
EX: Tuple[Decimal, Decimal]
|
||||||
|
AA: Tuple[Decimal, Decimal]
|
||||||
|
A: Tuple[Decimal, Decimal]
|
||||||
|
B: Tuple[Decimal, Decimal]
|
||||||
|
C: Tuple[Decimal, Decimal]
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_constants_from_play_rating(play_rating: Union[Decimal, str, float, int]):
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
|
||||||
|
play_rating = Decimal(play_rating)
|
||||||
|
|
||||||
|
ranges = []
|
||||||
|
for upper_score, lower_score in [
|
||||||
|
(10000000, 9900000),
|
||||||
|
(9899999, 9800000),
|
||||||
|
(9799999, 9500000),
|
||||||
|
(9499999, 9200000),
|
||||||
|
(9199999, 8900000),
|
||||||
|
(8899999, 8600000),
|
||||||
|
]:
|
||||||
|
upper_score_modifier = calculate_score_modifier(upper_score)
|
||||||
|
lower_score_modifier = calculate_score_modifier(lower_score)
|
||||||
|
ranges.append(
|
||||||
|
(play_rating - upper_score_modifier, play_rating - lower_score_modifier)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConstantsFromPlayRatingResult(*ranges)
|
174
src/arcaea_offline/calculate/world_step.py
Normal file
174
src/arcaea_offline/calculate/world_step.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
class PlayResult:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
play_rating: Union[Decimal, str, float, int],
|
||||||
|
partner_step: Union[Decimal, str, float, int],
|
||||||
|
):
|
||||||
|
self.__play_rating = play_rating
|
||||||
|
self.__partner_step = partner_step
|
||||||
|
|
||||||
|
@property
|
||||||
|
def play_rating(self):
|
||||||
|
return Decimal(self.__play_rating)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def partner_step(self):
|
||||||
|
return Decimal(self.__partner_step)
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerBonus:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
step_bonus: Union[Decimal, str, float, int] = Decimal("0.0"),
|
||||||
|
final_multiplier: Union[Decimal, str, float, int] = Decimal("1.0"),
|
||||||
|
):
|
||||||
|
self.__step_bonus = step_bonus
|
||||||
|
self.__final_multiplier = final_multiplier
|
||||||
|
|
||||||
|
@property
|
||||||
|
def step_bonus(self):
|
||||||
|
return Decimal(self.__step_bonus)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def final_multiplier(self):
|
||||||
|
return Decimal(self.__final_multiplier)
|
||||||
|
|
||||||
|
|
||||||
|
AwakenedIlithPartnerBonus = PartnerBonus(step_bonus="6.0")
|
||||||
|
AwakenedEtoPartnerBonus = PartnerBonus(step_bonus="7.0")
|
||||||
|
AwakenedLunaPartnerBonus = PartnerBonus(step_bonus="7.0")
|
||||||
|
|
||||||
|
|
||||||
|
class AwakenedAyuPartnerBonus(PartnerBonus):
|
||||||
|
def __init__(self, step_bonus: Union[Decimal, str, float, int]):
|
||||||
|
super().__init__(step_bonus=step_bonus)
|
||||||
|
|
||||||
|
|
||||||
|
AmaneBelowExPartnerBonus = PartnerBonus(final_multiplier="0.5")
|
||||||
|
|
||||||
|
|
||||||
|
class MithraTerceraPartnerBonus(PartnerBonus):
|
||||||
|
def __init__(self, step_bonus: int):
|
||||||
|
super().__init__(step_bonus=step_bonus)
|
||||||
|
|
||||||
|
|
||||||
|
MayaPartnerBonus = PartnerBonus(final_multiplier="2.0")
|
||||||
|
|
||||||
|
|
||||||
|
class StepBooster:
|
||||||
|
def final_value(self) -> Decimal:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyMapStepBooster(StepBooster):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stamina: Literal[2, 4, 6],
|
||||||
|
fragments: Literal[100, 250, 500, None],
|
||||||
|
):
|
||||||
|
self.stamina = stamina
|
||||||
|
self.fragments = fragments
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stamina(self):
|
||||||
|
return self.__stamina
|
||||||
|
|
||||||
|
@stamina.setter
|
||||||
|
def stamina(self, value: Literal[2, 4, 6]):
|
||||||
|
if value not in [2, 4, 6]:
|
||||||
|
raise ValueError("stamina can only be one of [2, 4, 6]")
|
||||||
|
self.__stamina = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fragments(self):
|
||||||
|
return self.__fragments
|
||||||
|
|
||||||
|
@fragments.setter
|
||||||
|
def fragments(self, value: Literal[100, 250, 500, None]):
|
||||||
|
if value not in [100, 250, 500, None]:
|
||||||
|
raise ValueError("fragments can only be one of [100, 250, 500, None]")
|
||||||
|
self.__fragments = value
|
||||||
|
|
||||||
|
def final_value(self) -> Decimal:
|
||||||
|
stamina_multiplier = Decimal(self.stamina)
|
||||||
|
fragments_multiplier = Decimal(1)
|
||||||
|
if self.fragments == 100:
|
||||||
|
fragments_multiplier = Decimal("1.1")
|
||||||
|
elif self.fragments == 250:
|
||||||
|
fragments_multiplier = Decimal("1.25")
|
||||||
|
elif self.fragments == 500:
|
||||||
|
fragments_multiplier = Decimal("1.5")
|
||||||
|
return stamina_multiplier * fragments_multiplier
|
||||||
|
|
||||||
|
|
||||||
|
class MemoriesStepBooster(StepBooster):
|
||||||
|
def final_value(self) -> Decimal:
|
||||||
|
return Decimal("4.0")
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_step_original(
|
||||||
|
play_result: PlayResult,
|
||||||
|
*,
|
||||||
|
partner_bonus: Optional[PartnerBonus] = None,
|
||||||
|
step_booster: Optional[StepBooster] = None,
|
||||||
|
) -> Decimal:
|
||||||
|
ptt = play_result.play_rating
|
||||||
|
step = play_result.partner_step
|
||||||
|
if partner_bonus:
|
||||||
|
partner_bonus_step = partner_bonus.step_bonus
|
||||||
|
partner_bonus_multiplier = partner_bonus.final_multiplier
|
||||||
|
else:
|
||||||
|
partner_bonus_step = Decimal("0")
|
||||||
|
partner_bonus_multiplier = Decimal("1.0")
|
||||||
|
|
||||||
|
result = (Decimal("2.45") * ptt.sqrt() + Decimal("2.5")) * (step / 50)
|
||||||
|
result += partner_bonus_step
|
||||||
|
result *= partner_bonus_multiplier
|
||||||
|
if step_booster:
|
||||||
|
result *= step_booster.final_value()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_step(
|
||||||
|
play_result: PlayResult,
|
||||||
|
*,
|
||||||
|
partner_bonus: Optional[PartnerBonus] = None,
|
||||||
|
step_booster: Optional[StepBooster] = None,
|
||||||
|
) -> Decimal:
|
||||||
|
result_original = calculate_step_original(
|
||||||
|
play_result, partner_bonus=partner_bonus, step_booster=step_booster
|
||||||
|
)
|
||||||
|
|
||||||
|
return round(result_original, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_play_rating_from_step(
|
||||||
|
step: Union[Decimal, str, int, float],
|
||||||
|
partner_step_value: Union[Decimal, str, int, float],
|
||||||
|
*,
|
||||||
|
partner_bonus: Optional[PartnerBonus] = None,
|
||||||
|
step_booster: Optional[StepBooster] = None,
|
||||||
|
):
|
||||||
|
step = Decimal(step)
|
||||||
|
partner_step_value = Decimal(partner_step_value)
|
||||||
|
|
||||||
|
# get original play result
|
||||||
|
if partner_bonus and partner_bonus.final_multiplier:
|
||||||
|
step /= partner_bonus.final_multiplier
|
||||||
|
if step_booster:
|
||||||
|
step /= step_booster.final_value()
|
||||||
|
|
||||||
|
if partner_bonus and partner_bonus.step_bonus:
|
||||||
|
step -= partner_bonus.step_bonus
|
||||||
|
|
||||||
|
play_rating_sqrt = (Decimal(50) * step - Decimal("2.5") * partner_step_value) / (
|
||||||
|
Decimal("2.45") * partner_step_value
|
||||||
|
)
|
||||||
|
return play_rating_sqrt**2 if play_rating_sqrt >= 0 else -(play_rating_sqrt**2)
|
@ -1,2 +0,0 @@
|
|||||||
from . import world
|
|
||||||
from .play_result import PlayResultCalculators
|
|
@ -1,105 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
from math import floor
|
|
||||||
from typing import Tuple, TypedDict, Union
|
|
||||||
|
|
||||||
from arcaea_offline.constants.play_result import ScoreLowerLimits
|
|
||||||
|
|
||||||
|
|
||||||
class PlayResultCalculators:
|
|
||||||
@staticmethod
|
|
||||||
def score_possible_range(notes: int, pure: int, far: int) -> tuple[int, int]:
|
|
||||||
"""
|
|
||||||
Returns the possible range of score based on the given values.
|
|
||||||
|
|
||||||
The first integer of returned tuple is the lower limit of the score,
|
|
||||||
and the second integer is the upper limit.
|
|
||||||
|
|
||||||
For example, ...
|
|
||||||
"""
|
|
||||||
single_note_score = 10000000 / Decimal(notes)
|
|
||||||
|
|
||||||
actual_score = floor(
|
|
||||||
single_note_score * pure + single_note_score * Decimal(0.5) * far
|
|
||||||
)
|
|
||||||
return (actual_score, actual_score + pure)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def shiny_pure(notes: int, score: int, pure: int, far: int) -> int:
|
|
||||||
single_note_score = 10000000 / Decimal(notes)
|
|
||||||
actual_score = single_note_score * pure + single_note_score * Decimal(0.5) * far
|
|
||||||
return score - floor(actual_score)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score_modifier(score: int) -> Decimal:
|
|
||||||
"""
|
|
||||||
Returns the score modifier of the given score
|
|
||||||
|
|
||||||
https://arcaea.fandom.com/wiki/Potential#Score_Modifier
|
|
||||||
|
|
||||||
:param score: The score of the play result, e.g. 9900000
|
|
||||||
:return: The modifier of the given score, e.g. Decimal("1.5")
|
|
||||||
"""
|
|
||||||
if not isinstance(score, int):
|
|
||||||
raise TypeError("score must be an integer")
|
|
||||||
if score < 0:
|
|
||||||
raise ValueError("score cannot be negative")
|
|
||||||
|
|
||||||
if score >= 10000000:
|
|
||||||
return Decimal(2)
|
|
||||||
if score >= 9800000:
|
|
||||||
return Decimal(1) + (Decimal(score - 9800000) / 200000)
|
|
||||||
return Decimal(score - 9500000) / 300000
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def play_rating(cls, score: int, constant: int) -> Decimal:
|
|
||||||
"""
|
|
||||||
Returns the play rating of the given score
|
|
||||||
|
|
||||||
https://arcaea.fandom.com/wiki/Potential#Play_Rating
|
|
||||||
|
|
||||||
:param constant: The (constant * 10) of the played chart, e.g. 120 for Testify[BYD]
|
|
||||||
:param score: The score of the play result, e.g. 10002221
|
|
||||||
:return: The play rating of the given values, e.g. Decimal("14.0")
|
|
||||||
"""
|
|
||||||
if not isinstance(score, int):
|
|
||||||
raise TypeError("score must be an integer")
|
|
||||||
if not isinstance(constant, int):
|
|
||||||
raise TypeError("constant must be an integer")
|
|
||||||
if score < 0:
|
|
||||||
raise ValueError("score cannot be negative")
|
|
||||||
if constant < 0:
|
|
||||||
raise ValueError("constant cannot be negative")
|
|
||||||
|
|
||||||
score_modifier = cls.score_modifier(score)
|
|
||||||
return max(Decimal(0), Decimal(constant) / 10 + score_modifier)
|
|
||||||
|
|
||||||
class ConstantsFromPlayRatingResult(TypedDict):
|
|
||||||
EX_PLUS: Tuple[Decimal, Decimal]
|
|
||||||
EX: Tuple[Decimal, Decimal]
|
|
||||||
AA: Tuple[Decimal, Decimal]
|
|
||||||
A: Tuple[Decimal, Decimal]
|
|
||||||
B: Tuple[Decimal, Decimal]
|
|
||||||
C: Tuple[Decimal, Decimal]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def constants_from_play_rating(
|
|
||||||
cls, play_rating: Union[Decimal, str, float, int]
|
|
||||||
) -> ConstantsFromPlayRatingResult:
|
|
||||||
play_rating = Decimal(play_rating)
|
|
||||||
|
|
||||||
def _result(score_upper: int, score_lower: int) -> Tuple[Decimal, Decimal]:
|
|
||||||
upper_score_modifier = cls.score_modifier(score_upper)
|
|
||||||
lower_score_modifier = cls.score_modifier(score_lower)
|
|
||||||
return (
|
|
||||||
play_rating - upper_score_modifier,
|
|
||||||
play_rating - lower_score_modifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"EX_PLUS": _result(10000000, ScoreLowerLimits.EX_PLUS),
|
|
||||||
"EX": _result(ScoreLowerLimits.EX_PLUS - 1, ScoreLowerLimits.EX),
|
|
||||||
"AA": _result(ScoreLowerLimits.EX - 1, ScoreLowerLimits.AA),
|
|
||||||
"A": _result(ScoreLowerLimits.AA - 1, ScoreLowerLimits.A),
|
|
||||||
"B": _result(ScoreLowerLimits.A - 1, ScoreLowerLimits.B),
|
|
||||||
"C": _result(ScoreLowerLimits.B - 1, ScoreLowerLimits.C),
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
from ._common import MemoriesStepBooster, PartnerBonus, WorldPlayResult
|
|
||||||
from .legacy import LegacyMapStepBooster
|
|
||||||
from .main import WorldMainMapCalculators
|
|
||||||
from .partners import (
|
|
||||||
AmaneBelowExPartnerBonus,
|
|
||||||
AwakenedEtoPartnerBonus,
|
|
||||||
AwakenedIlithPartnerBonus,
|
|
||||||
AwakenedLunaPartnerBonus,
|
|
||||||
MayaPartnerBonus,
|
|
||||||
MithraTerceraPartnerBonus,
|
|
||||||
)
|
|
@ -1,50 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
|
|
||||||
class WorldPlayResult:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
play_rating: Union[Decimal, str, float, int],
|
|
||||||
partner_step: Union[Decimal, str, float, int],
|
|
||||||
):
|
|
||||||
self.__play_rating = play_rating
|
|
||||||
self.__partner_step = partner_step
|
|
||||||
|
|
||||||
@property
|
|
||||||
def play_rating(self):
|
|
||||||
return Decimal(self.__play_rating)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def partner_step(self):
|
|
||||||
return Decimal(self.__partner_step)
|
|
||||||
|
|
||||||
|
|
||||||
class PartnerBonus:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
step_bonus: Union[Decimal, str, float, int] = Decimal("0.0"),
|
|
||||||
final_multiplier: Union[Decimal, str, float, int] = Decimal("1.0"),
|
|
||||||
):
|
|
||||||
self.__step_bonus = step_bonus
|
|
||||||
self.__final_multiplier = final_multiplier
|
|
||||||
|
|
||||||
@property
|
|
||||||
def step_bonus(self):
|
|
||||||
return Decimal(self.__step_bonus)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def final_multiplier(self):
|
|
||||||
return Decimal(self.__final_multiplier)
|
|
||||||
|
|
||||||
|
|
||||||
class StepBooster:
|
|
||||||
def final_value(self) -> Decimal:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
|
|
||||||
class MemoriesStepBooster(StepBooster):
|
|
||||||
def final_value(self) -> Decimal:
|
|
||||||
return Decimal("4.0")
|
|
@ -1,45 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from ._common import StepBooster
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyMapStepBooster(StepBooster):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
stamina: Literal[2, 4, 6],
|
|
||||||
fragments: Literal[100, 250, 500, None],
|
|
||||||
):
|
|
||||||
self.stamina = stamina
|
|
||||||
self.fragments = fragments
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stamina(self):
|
|
||||||
return self.__stamina
|
|
||||||
|
|
||||||
@stamina.setter
|
|
||||||
def stamina(self, value: Literal[2, 4, 6]):
|
|
||||||
if value not in [2, 4, 6]:
|
|
||||||
raise ValueError("stamina can only be one of [2, 4, 6]")
|
|
||||||
self.__stamina = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fragments(self):
|
|
||||||
return self.__fragments
|
|
||||||
|
|
||||||
@fragments.setter
|
|
||||||
def fragments(self, value: Literal[100, 250, 500, None]):
|
|
||||||
if value not in [100, 250, 500, None]:
|
|
||||||
raise ValueError("fragments can only be one of [100, 250, 500, None]")
|
|
||||||
self.__fragments = value
|
|
||||||
|
|
||||||
def final_value(self) -> Decimal:
|
|
||||||
stamina_multiplier = Decimal(self.stamina)
|
|
||||||
fragments_multiplier = Decimal(1)
|
|
||||||
if self.fragments == 100:
|
|
||||||
fragments_multiplier = Decimal("1.1")
|
|
||||||
elif self.fragments == 250:
|
|
||||||
fragments_multiplier = Decimal("1.25")
|
|
||||||
elif self.fragments == 500:
|
|
||||||
fragments_multiplier = Decimal("1.5")
|
|
||||||
return stamina_multiplier * fragments_multiplier
|
|
@ -1,57 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
from ._common import PartnerBonus, StepBooster, WorldPlayResult
|
|
||||||
|
|
||||||
|
|
||||||
class WorldMainMapCalculators:
|
|
||||||
@staticmethod
|
|
||||||
def step(
|
|
||||||
play_result: WorldPlayResult,
|
|
||||||
*,
|
|
||||||
partner_bonus: Optional[PartnerBonus] = None,
|
|
||||||
step_booster: Optional[StepBooster] = None,
|
|
||||||
) -> Decimal:
|
|
||||||
ptt = play_result.play_rating
|
|
||||||
step = play_result.partner_step
|
|
||||||
if partner_bonus:
|
|
||||||
partner_bonus_step = partner_bonus.step_bonus
|
|
||||||
partner_bonus_multiplier = partner_bonus.final_multiplier
|
|
||||||
else:
|
|
||||||
partner_bonus_step = Decimal("0")
|
|
||||||
partner_bonus_multiplier = Decimal("1.0")
|
|
||||||
|
|
||||||
result = (Decimal("2.45") * ptt.sqrt() + Decimal("2.5")) * (step / 50)
|
|
||||||
result += partner_bonus_step
|
|
||||||
result *= partner_bonus_multiplier
|
|
||||||
if step_booster:
|
|
||||||
result *= step_booster.final_value()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def play_rating_from_step(
|
|
||||||
step: Union[Decimal, str, int, float],
|
|
||||||
partner_step_value: Union[Decimal, str, int, float],
|
|
||||||
*,
|
|
||||||
partner_bonus: Optional[PartnerBonus] = None,
|
|
||||||
step_booster: Optional[StepBooster] = None,
|
|
||||||
):
|
|
||||||
step = Decimal(step)
|
|
||||||
partner_step_value = Decimal(partner_step_value)
|
|
||||||
|
|
||||||
# get original play result
|
|
||||||
if partner_bonus and partner_bonus.final_multiplier:
|
|
||||||
step /= partner_bonus.final_multiplier
|
|
||||||
if step_booster:
|
|
||||||
step /= step_booster.final_value()
|
|
||||||
|
|
||||||
if partner_bonus and partner_bonus.step_bonus:
|
|
||||||
step -= partner_bonus.step_bonus
|
|
||||||
|
|
||||||
play_rating_sqrt = (
|
|
||||||
Decimal(50) * step - Decimal("2.5") * partner_step_value
|
|
||||||
) / (Decimal("2.45") * partner_step_value)
|
|
||||||
return (
|
|
||||||
play_rating_sqrt**2 if play_rating_sqrt >= 0 else -(play_rating_sqrt**2)
|
|
||||||
)
|
|
@ -1,16 +0,0 @@
|
|||||||
from ._common import PartnerBonus
|
|
||||||
|
|
||||||
AwakenedIlithPartnerBonus = PartnerBonus(step_bonus="6.0")
|
|
||||||
AwakenedEtoPartnerBonus = PartnerBonus(step_bonus="7.0")
|
|
||||||
AwakenedLunaPartnerBonus = PartnerBonus(step_bonus="7.0")
|
|
||||||
|
|
||||||
|
|
||||||
AmaneBelowExPartnerBonus = PartnerBonus(final_multiplier="0.5")
|
|
||||||
|
|
||||||
|
|
||||||
class MithraTerceraPartnerBonus(PartnerBonus):
|
|
||||||
def __init__(self, step_bonus: int):
|
|
||||||
super().__init__(step_bonus=step_bonus)
|
|
||||||
|
|
||||||
|
|
||||||
MayaPartnerBonus = PartnerBonus(final_multiplier="2.0")
|
|
@ -1,15 +0,0 @@
|
|||||||
from .arcaea import (
|
|
||||||
ArcaeaLanguage,
|
|
||||||
ArcaeaPlayResultClearType,
|
|
||||||
ArcaeaPlayResultModifier,
|
|
||||||
ArcaeaRatingClass,
|
|
||||||
ArcaeaSongSide,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ArcaeaLanguage",
|
|
||||||
"ArcaeaPlayResultClearType",
|
|
||||||
"ArcaeaPlayResultModifier",
|
|
||||||
"ArcaeaRatingClass",
|
|
||||||
"ArcaeaSongSide",
|
|
||||||
]
|
|
@ -1,37 +0,0 @@
|
|||||||
from enum import Enum, IntEnum
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaRatingClass(IntEnum):
|
|
||||||
PAST = 0
|
|
||||||
PRESENT = 1
|
|
||||||
FUTURE = 2
|
|
||||||
BEYOND = 3
|
|
||||||
ETERNAL = 4
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaSongSide(IntEnum):
|
|
||||||
LIGHT = 0
|
|
||||||
CONFLICT = 1
|
|
||||||
COLORLESS = 2
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaPlayResultModifier(IntEnum):
|
|
||||||
NORMAL = 0
|
|
||||||
EASY = 1
|
|
||||||
HARD = 2
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaPlayResultClearType(IntEnum):
|
|
||||||
TRACK_LOST = 0
|
|
||||||
NORMAL_CLEAR = 1
|
|
||||||
FULL_RECALL = 2
|
|
||||||
PURE_MEMORY = 3
|
|
||||||
HARD_CLEAR = 4
|
|
||||||
EASY_CLEAR = 5
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaLanguage(Enum):
|
|
||||||
JA = "ja"
|
|
||||||
KO = "ko"
|
|
||||||
ZH_HANT = "zh-Hant"
|
|
||||||
ZH_HANS = "zh-Hans"
|
|
@ -1,12 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScoreLowerLimits:
|
|
||||||
EX_PLUS = 9900000
|
|
||||||
EX = 9800000
|
|
||||||
AA = 9500000
|
|
||||||
A = 9200000
|
|
||||||
B = 8900000
|
|
||||||
C = 8600000
|
|
||||||
D = 0
|
|
@ -5,16 +5,10 @@ from typing import Iterable, List, Optional, Type, Union
|
|||||||
from sqlalchemy import Engine, func, inspect, select
|
from sqlalchemy import Engine, func, inspect, select
|
||||||
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
|
||||||
|
|
||||||
from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
|
from .external.arcsong.arcsong_json import ArcSongJsonBuilder
|
||||||
from arcaea_offline.external.exports import (
|
from .external.exports import ArcaeaOfflineDEFV2_Score, ScoreExport, exporters
|
||||||
ArcaeaOfflineDEFV2_Score,
|
from .models.config import ConfigBase, Property
|
||||||
ScoreExport,
|
from .models.scores import (
|
||||||
exporters,
|
|
||||||
)
|
|
||||||
from arcaea_offline.singleton import Singleton
|
|
||||||
|
|
||||||
from .models.v4.config import ConfigBase, Property
|
|
||||||
from .models.v4.scores import (
|
|
||||||
CalculatedPotential,
|
CalculatedPotential,
|
||||||
Score,
|
Score,
|
||||||
ScoreBest,
|
ScoreBest,
|
||||||
@ -22,7 +16,7 @@ from .models.v4.scores import (
|
|||||||
ScoresBase,
|
ScoresBase,
|
||||||
ScoresViewBase,
|
ScoresViewBase,
|
||||||
)
|
)
|
||||||
from .models.v4.songs import (
|
from .models.songs import (
|
||||||
Chart,
|
Chart,
|
||||||
ChartInfo,
|
ChartInfo,
|
||||||
Difficulty,
|
Difficulty,
|
||||||
@ -34,6 +28,7 @@ from .models.v4.songs import (
|
|||||||
SongsBase,
|
SongsBase,
|
||||||
SongsViewBase,
|
SongsViewBase,
|
||||||
)
|
)
|
||||||
|
from .singleton import Singleton
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1 +0,0 @@
|
|||||||
from .db import Database
|
|
@ -1,58 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Integer
|
|
||||||
from sqlalchemy.types import TypeDecorator
|
|
||||||
|
|
||||||
from arcaea_offline.constants.enums import (
|
|
||||||
ArcaeaPlayResultClearType,
|
|
||||||
ArcaeaPlayResultModifier,
|
|
||||||
ArcaeaRatingClass,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DbRatingClass(TypeDecorator):
|
|
||||||
"""sqlalchemy rating_class type decorator"""
|
|
||||||
|
|
||||||
impl = Integer
|
|
||||||
|
|
||||||
def process_bind_param(
|
|
||||||
self, value: Optional[ArcaeaRatingClass], dialect
|
|
||||||
) -> Optional[int]:
|
|
||||||
return None if value is None else value.value
|
|
||||||
|
|
||||||
def process_result_value(
|
|
||||||
self, value: Optional[int], dialect
|
|
||||||
) -> Optional[ArcaeaRatingClass]:
|
|
||||||
return None if value is None else ArcaeaRatingClass(value)
|
|
||||||
|
|
||||||
|
|
||||||
class DbClearType(TypeDecorator):
|
|
||||||
"""sqlalchemy clear_type type decorator"""
|
|
||||||
|
|
||||||
impl = Integer
|
|
||||||
|
|
||||||
def process_bind_param(
|
|
||||||
self, value: Optional[ArcaeaPlayResultClearType], dialect
|
|
||||||
) -> Optional[int]:
|
|
||||||
return None if value is None else value.value
|
|
||||||
|
|
||||||
def process_result_value(
|
|
||||||
self, value: Optional[int], dialect
|
|
||||||
) -> Optional[ArcaeaPlayResultClearType]:
|
|
||||||
return None if value is None else ArcaeaPlayResultClearType(value)
|
|
||||||
|
|
||||||
|
|
||||||
class DbModifier(TypeDecorator):
|
|
||||||
"""sqlalchemy modifier type decorator"""
|
|
||||||
|
|
||||||
impl = Integer
|
|
||||||
|
|
||||||
def process_bind_param(
|
|
||||||
self, value: Optional[ArcaeaPlayResultModifier], dialect
|
|
||||||
) -> Optional[int]:
|
|
||||||
return None if value is None else value.value
|
|
||||||
|
|
||||||
def process_result_value(
|
|
||||||
self, value: Optional[int], dialect
|
|
||||||
) -> Optional[ArcaeaPlayResultModifier]:
|
|
||||||
return None if value is None else ArcaeaPlayResultModifier(value)
|
|
111
src/arcaea_offline/searcher.py
Normal file
111
src/arcaea_offline/searcher.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from whoosh.analysis import NgramFilter, StandardAnalyzer
|
||||||
|
from whoosh.fields import ID, KEYWORD, TEXT, Schema
|
||||||
|
from whoosh.filedb.filestore import RamStorage
|
||||||
|
from whoosh.qparser import FuzzyTermPlugin, MultifieldParser, OrGroup
|
||||||
|
|
||||||
|
from .models.songs import Song, SongLocalized
|
||||||
|
from .utils.search_title import recover_search_title
|
||||||
|
|
||||||
|
|
||||||
|
class Searcher:
|
||||||
|
def __init__(self):
|
||||||
|
self.text_analyzer = StandardAnalyzer() | NgramFilter(minsize=2, maxsize=5)
|
||||||
|
self.song_schema = Schema(
|
||||||
|
song_id=ID(stored=True, unique=True),
|
||||||
|
title=TEXT(analyzer=self.text_analyzer, spelling=True),
|
||||||
|
artist=TEXT(analyzer=self.text_analyzer, spelling=True),
|
||||||
|
source=TEXT(analyzer=self.text_analyzer, spelling=True),
|
||||||
|
keywords=KEYWORD(lowercase=True, stored=True, scorable=True),
|
||||||
|
)
|
||||||
|
self.storage = RamStorage()
|
||||||
|
self.index = self.storage.create_index(self.song_schema)
|
||||||
|
|
||||||
|
self.default_query_parser = MultifieldParser(
|
||||||
|
["song_id", "title", "artist", "source", "keywords"],
|
||||||
|
self.song_schema,
|
||||||
|
group=OrGroup,
|
||||||
|
)
|
||||||
|
self.default_query_parser.add_plugin(FuzzyTermPlugin())
|
||||||
|
|
||||||
|
def import_songs(self, session: Session):
|
||||||
|
writer = self.index.writer()
|
||||||
|
songs = list(session.scalars(select(Song)))
|
||||||
|
song_localize_stmt = select(SongLocalized)
|
||||||
|
for song in songs:
|
||||||
|
stmt = song_localize_stmt.where(SongLocalized.id == song.id)
|
||||||
|
sl = session.scalar(stmt)
|
||||||
|
song_id = song.id
|
||||||
|
possible_titles: List[Union[str, None]] = [song.title]
|
||||||
|
possible_artists: List[Union[str, None]] = [song.artist]
|
||||||
|
possible_sources: List[Union[str, None]] = [song.source]
|
||||||
|
if sl:
|
||||||
|
possible_titles.extend(
|
||||||
|
[sl.title_ja, sl.title_ko, sl.title_zh_hans, sl.title_zh_hant]
|
||||||
|
)
|
||||||
|
possible_titles.extend(
|
||||||
|
recover_search_title(sl.search_title_ja)
|
||||||
|
+ recover_search_title(sl.search_title_ko)
|
||||||
|
+ recover_search_title(sl.search_title_zh_hans)
|
||||||
|
+ recover_search_title(sl.search_title_zh_hant)
|
||||||
|
)
|
||||||
|
possible_artists.extend(
|
||||||
|
recover_search_title(sl.search_artist_ja)
|
||||||
|
+ recover_search_title(sl.search_artist_ko)
|
||||||
|
+ recover_search_title(sl.search_artist_zh_hans)
|
||||||
|
+ recover_search_title(sl.search_artist_zh_hant)
|
||||||
|
)
|
||||||
|
possible_sources.extend(
|
||||||
|
[
|
||||||
|
sl.source_ja,
|
||||||
|
sl.source_ko,
|
||||||
|
sl.source_zh_hans,
|
||||||
|
sl.source_zh_hant,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# remove empty items in list
|
||||||
|
titles = [t for t in possible_titles if t != "" and t is not None]
|
||||||
|
artists = [t for t in possible_artists if t != "" and t is not None]
|
||||||
|
sources = [t for t in possible_sources if t != "" and t is not None]
|
||||||
|
|
||||||
|
writer.update_document(
|
||||||
|
song_id=song_id,
|
||||||
|
title=" ".join(titles),
|
||||||
|
artist=" ".join(artists),
|
||||||
|
source=" ".join(sources),
|
||||||
|
keywords=" ".join([song_id] + titles + artists + sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.commit()
|
||||||
|
|
||||||
|
def did_you_mean(self, string: str):
|
||||||
|
results = set()
|
||||||
|
|
||||||
|
with self.index.searcher() as searcher:
|
||||||
|
corrector_keywords = searcher.corrector("keywords") # type: ignore
|
||||||
|
corrector_song_id = searcher.corrector("song_id") # type: ignore
|
||||||
|
corrector_title = searcher.corrector("title") # type: ignore
|
||||||
|
corrector_artist = searcher.corrector("artist") # type: ignore
|
||||||
|
corrector_source = searcher.corrector("source") # type: ignore
|
||||||
|
|
||||||
|
results.update(corrector_keywords.suggest(string))
|
||||||
|
results.update(corrector_song_id.suggest(string))
|
||||||
|
results.update(corrector_title.suggest(string))
|
||||||
|
results.update(corrector_artist.suggest(string))
|
||||||
|
results.update(corrector_source.suggest(string))
|
||||||
|
|
||||||
|
if string in results:
|
||||||
|
results.remove(string)
|
||||||
|
|
||||||
|
return list(results)
|
||||||
|
|
||||||
|
def search(self, string: str, *, limit: int = 10):
|
||||||
|
query_string = f"{string}"
|
||||||
|
query = self.default_query_parser.parse(query_string)
|
||||||
|
with self.index.searcher() as searcher:
|
||||||
|
results = searcher.search(query, limit=limit)
|
||||||
|
return [result.get("song_id") for result in results]
|
@ -1,2 +0,0 @@
|
|||||||
from .play_result import PlayResultFormatter
|
|
||||||
from .rating_class import RatingClassFormatter
|
|
@ -1,143 +0,0 @@
|
|||||||
from typing import Any, Literal, overload
|
|
||||||
|
|
||||||
from arcaea_offline.constants.enums import (
|
|
||||||
ArcaeaPlayResultClearType,
|
|
||||||
ArcaeaPlayResultModifier,
|
|
||||||
)
|
|
||||||
from arcaea_offline.constants.play_result import ScoreLowerLimits
|
|
||||||
|
|
||||||
|
|
||||||
class PlayResultFormatter:
|
|
||||||
SCORE_GRADE_FORMAT_RESULTS = Literal["EX+", "EX", "AA", "A", "B", "C", "D"]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def score_grade(score: int) -> SCORE_GRADE_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the score grade, e.g. EX+.
|
|
||||||
|
|
||||||
Raises `ValueError` if the score is negative.
|
|
||||||
"""
|
|
||||||
if not isinstance(score, int):
|
|
||||||
raise TypeError(f"Unsupported type {type(score)}, cannot format")
|
|
||||||
|
|
||||||
if score >= ScoreLowerLimits.EX_PLUS:
|
|
||||||
return "EX+"
|
|
||||||
elif score >= ScoreLowerLimits.EX:
|
|
||||||
return "EX"
|
|
||||||
elif score >= ScoreLowerLimits.AA:
|
|
||||||
return "AA"
|
|
||||||
elif score >= ScoreLowerLimits.A:
|
|
||||||
return "A"
|
|
||||||
elif score >= ScoreLowerLimits.B:
|
|
||||||
return "B"
|
|
||||||
elif score >= ScoreLowerLimits.C:
|
|
||||||
return "C"
|
|
||||||
elif score >= ScoreLowerLimits.D:
|
|
||||||
return "D"
|
|
||||||
else:
|
|
||||||
raise ValueError("score cannot be negative")
|
|
||||||
|
|
||||||
CLEAR_TYPE_FORMAT_RESULTS = Literal[
|
|
||||||
"TRACK LOST",
|
|
||||||
"NORMAL CLEAR",
|
|
||||||
"FULL RECALL",
|
|
||||||
"PURE MEMORY",
|
|
||||||
"EASY CLEAR",
|
|
||||||
"HARD CLEAR",
|
|
||||||
"UNKNOWN",
|
|
||||||
"None",
|
|
||||||
]
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def clear_type(
|
|
||||||
cls, clear_type: ArcaeaPlayResultClearType
|
|
||||||
) -> CLEAR_TYPE_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def clear_type(cls, clear_type: int) -> CLEAR_TYPE_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
|
||||||
|
|
||||||
The integer will be converted to `ArcaeaPlayResultClearType` enum,
|
|
||||||
and will return "UNKNOWN" if the convertion fails.
|
|
||||||
|
|
||||||
Raises `ValueError` if the integer is negative.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def clear_type(cls, clear_type: None) -> CLEAR_TYPE_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns "None"
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def clear_type(cls, clear_type: Any) -> CLEAR_TYPE_FORMAT_RESULTS:
|
|
||||||
if clear_type is None:
|
|
||||||
return "None"
|
|
||||||
elif isinstance(clear_type, ArcaeaPlayResultClearType):
|
|
||||||
return clear_type.name.replace("_", " ").upper() # type: ignore
|
|
||||||
elif isinstance(clear_type, int):
|
|
||||||
if clear_type < 0:
|
|
||||||
raise ValueError("clear_type cannot be negative")
|
|
||||||
try:
|
|
||||||
return cls.clear_type(ArcaeaPlayResultClearType(clear_type))
|
|
||||||
except ValueError:
|
|
||||||
return "UNKNOWN"
|
|
||||||
else:
|
|
||||||
raise TypeError(f"Unsupported type {type(clear_type)}, cannot format")
|
|
||||||
|
|
||||||
MODIFIER_FORMAT_RESULTS = Literal["NORMAL", "EASY", "HARD", "UNKNOWN", "None"]
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def modifier(cls, modifier: ArcaeaPlayResultModifier) -> MODIFIER_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def modifier(cls, modifier: int) -> MODIFIER_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
|
||||||
|
|
||||||
The integer will be converted to `ArcaeaPlayResultModifier` enum,
|
|
||||||
and will return "UNKNOWN" if the convertion fails.
|
|
||||||
|
|
||||||
Raises `ValueError` if the integer is negative.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def modifier(cls, modifier: None) -> MODIFIER_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns "None"
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def modifier(cls, modifier: Any) -> MODIFIER_FORMAT_RESULTS:
|
|
||||||
if modifier is None:
|
|
||||||
return "None"
|
|
||||||
elif isinstance(modifier, ArcaeaPlayResultModifier):
|
|
||||||
return modifier.name
|
|
||||||
elif isinstance(modifier, int):
|
|
||||||
if modifier < 0:
|
|
||||||
raise ValueError("modifier cannot be negative")
|
|
||||||
try:
|
|
||||||
return cls.modifier(ArcaeaPlayResultModifier(modifier))
|
|
||||||
except ValueError:
|
|
||||||
return "UNKNOWN"
|
|
||||||
else:
|
|
||||||
raise TypeError(f"Unsupported type {type(modifier)}, cannot format")
|
|
@ -1,83 +0,0 @@
|
|||||||
from typing import Any, Literal, overload
|
|
||||||
|
|
||||||
from arcaea_offline.constants.enums import ArcaeaRatingClass
|
|
||||||
|
|
||||||
|
|
||||||
class RatingClassFormatter:
|
|
||||||
abbreviations = {
|
|
||||||
ArcaeaRatingClass.PAST: "PST",
|
|
||||||
ArcaeaRatingClass.PRESENT: "PRS",
|
|
||||||
ArcaeaRatingClass.FUTURE: "FTR",
|
|
||||||
ArcaeaRatingClass.BEYOND: "BYD",
|
|
||||||
ArcaeaRatingClass.ETERNAL: "ETR",
|
|
||||||
}
|
|
||||||
|
|
||||||
NAME_FORMAT_RESULTS = Literal[
|
|
||||||
"Past", "Present", "Future", "Beyond", "Eternal", "Unknown"
|
|
||||||
]
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def name(cls, rating_class: ArcaeaRatingClass) -> NAME_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the capitalized rating class name, e.g. Future.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def name(cls, rating_class: int) -> NAME_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the capitalized rating class name, e.g. Future.
|
|
||||||
|
|
||||||
The integer will be converted to `ArcaeaRatingClass` enum,
|
|
||||||
and will return "Unknown" if the convertion fails.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def name(cls, rating_class: Any) -> NAME_FORMAT_RESULTS:
|
|
||||||
if isinstance(rating_class, ArcaeaRatingClass):
|
|
||||||
return rating_class.name.lower().capitalize() # type: ignore
|
|
||||||
elif isinstance(rating_class, int):
|
|
||||||
try:
|
|
||||||
return cls.name(ArcaeaRatingClass(rating_class))
|
|
||||||
except ValueError:
|
|
||||||
return "Unknown"
|
|
||||||
else:
|
|
||||||
raise TypeError(f"Unsupported type: {type(rating_class)}, cannot format")
|
|
||||||
|
|
||||||
ABBREVIATION_FORMAT_RESULTS = Literal["PST", "PRS", "FTR", "BYD", "ETR", "UNK"]
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def abbreviation(
|
|
||||||
cls, rating_class: ArcaeaRatingClass
|
|
||||||
) -> ABBREVIATION_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the uppercased rating class name, e.g. FTR.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
@classmethod
|
|
||||||
def abbreviation(cls, rating_class: int) -> ABBREVIATION_FORMAT_RESULTS:
|
|
||||||
"""
|
|
||||||
Returns the uppercased rating class name, e.g. FTR.
|
|
||||||
|
|
||||||
The integer will be converted to `ArcaeaRatingClass` enum,
|
|
||||||
and will return "UNK" if the convertion fails.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def abbreviation(cls, rating_class: Any) -> ABBREVIATION_FORMAT_RESULTS:
|
|
||||||
if isinstance(rating_class, ArcaeaRatingClass):
|
|
||||||
return cls.abbreviations[rating_class] # type: ignore
|
|
||||||
elif isinstance(rating_class, int):
|
|
||||||
try:
|
|
||||||
return cls.abbreviation(ArcaeaRatingClass(rating_class))
|
|
||||||
except ValueError:
|
|
||||||
return "UNK"
|
|
||||||
else:
|
|
||||||
raise TypeError(f"Unsupported type: {type(rating_class)}, cannot format")
|
|
25
src/arcaea_offline/utils/rating.py
Normal file
25
src/arcaea_offline/utils/rating.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
RATING_CLASS_TEXT_MAP = {
|
||||||
|
0: "Past",
|
||||||
|
1: "Present",
|
||||||
|
2: "Future",
|
||||||
|
3: "Beyond",
|
||||||
|
4: "Eternal",
|
||||||
|
}
|
||||||
|
|
||||||
|
RATING_CLASS_SHORT_TEXT_MAP = {
|
||||||
|
0: "PST",
|
||||||
|
1: "PRS",
|
||||||
|
2: "FTR",
|
||||||
|
3: "BYD",
|
||||||
|
4: "ETR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rating_class_to_text(rating_class: int) -> Optional[str]:
|
||||||
|
return RATING_CLASS_TEXT_MAP.get(rating_class)
|
||||||
|
|
||||||
|
|
||||||
|
def rating_class_to_short_text(rating_class: int) -> Optional[str]:
|
||||||
|
return RATING_CLASS_SHORT_TEXT_MAP.get(rating_class)
|
46
src/arcaea_offline/utils/score.py
Normal file
46
src/arcaea_offline/utils/score.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
SCORE_GRADE_FLOOR = [9900000, 9800000, 9500000, 9200000, 8900000, 8600000, 0]
|
||||||
|
SCORE_GRADE_TEXTS = ["EX+", "EX", "AA", "A", "B", "C", "D"]
|
||||||
|
MODIFIER_TEXTS = ["NORMAL", "EASY", "HARD"]
|
||||||
|
CLEAR_TYPE_TEXTS = [
|
||||||
|
"TRACK LOST",
|
||||||
|
"NORMAL CLEAR",
|
||||||
|
"FULL RECALL",
|
||||||
|
"PURE MEMORY",
|
||||||
|
"EASY CLEAR",
|
||||||
|
"HARD CLEAR",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def zip_score_grade(score: int, __seq: Sequence, default: Any = "__PRESERVE__"):
|
||||||
|
"""
|
||||||
|
zip_score_grade is a simple wrapper that equals to:
|
||||||
|
```py
|
||||||
|
for score_floor, val in zip(SCORE_GRADE_FLOOR, __seq):
|
||||||
|
if score >= score_floor:
|
||||||
|
return val
|
||||||
|
return seq[-1] if default == "__PRESERVE__" else default
|
||||||
|
```
|
||||||
|
Could be useful in specific cases.
|
||||||
|
"""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
val
|
||||||
|
for score_floor, val in zip(SCORE_GRADE_FLOOR, __seq)
|
||||||
|
if score >= score_floor
|
||||||
|
),
|
||||||
|
__seq[-1] if default == "__PRESERVE__" else default,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score_to_grade_text(score: int) -> str:
|
||||||
|
return zip_score_grade(score, SCORE_GRADE_TEXTS)
|
||||||
|
|
||||||
|
|
||||||
|
def modifier_to_text(modifier: int) -> str:
|
||||||
|
return MODIFIER_TEXTS[modifier]
|
||||||
|
|
||||||
|
|
||||||
|
def clear_type_to_text(clear_type: int) -> str:
|
||||||
|
return CLEAR_TYPE_TEXTS[clear_type]
|
6
src/arcaea_offline/utils/search_title.py
Normal file
6
src/arcaea_offline/utils/search_title.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import json
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def recover_search_title(db_value: Optional[str]) -> List[str]:
|
||||||
|
return json.loads(db_value) if db_value else []
|
22
tests/calculate/test_world_step.py
Normal file
22
tests/calculate/test_world_step.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from arcaea_offline.calculate.world_step import (
|
||||||
|
AwakenedAyuPartnerBonus,
|
||||||
|
LegacyMapStepBooster,
|
||||||
|
PlayResult,
|
||||||
|
calculate_step_original,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_world_step():
|
||||||
|
# the result was copied from https://arcaea.fandom.com/wiki/World_Mode_Mechanics#Calculation
|
||||||
|
# CC BY-SA 3.0
|
||||||
|
|
||||||
|
booster = LegacyMapStepBooster(6, 250)
|
||||||
|
partner_bonus = AwakenedAyuPartnerBonus("+3.6")
|
||||||
|
play_result = PlayResult(play_rating=Decimal("11.299"), partner_step=92)
|
||||||
|
result = calculate_step_original(
|
||||||
|
play_result, partner_bonus=partner_bonus, step_booster=booster
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.quantize(Decimal("0.000")) == Decimal("175.149")
|
@ -1,42 +0,0 @@
|
|||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from arcaea_offline.calculators.play_result import PlayResultCalculators
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlayResultCalculators:
|
|
||||||
def test_score_modifier(self):
|
|
||||||
# Results from https://arcaea.fandom.com/wiki/Potential#Score_Modifier
|
|
||||||
|
|
||||||
assert PlayResultCalculators.score_modifier(10000000) == Decimal("2.0")
|
|
||||||
assert PlayResultCalculators.score_modifier(9900000) == Decimal("1.5")
|
|
||||||
assert PlayResultCalculators.score_modifier(9800000) == Decimal("1.0")
|
|
||||||
assert PlayResultCalculators.score_modifier(9500000) == Decimal("0.0")
|
|
||||||
assert PlayResultCalculators.score_modifier(9200000) == Decimal("-1.0")
|
|
||||||
assert PlayResultCalculators.score_modifier(8900000) == Decimal("-2.0")
|
|
||||||
assert PlayResultCalculators.score_modifier(8600000) == Decimal("-3.0")
|
|
||||||
|
|
||||||
assert PlayResultCalculators.score_modifier(0).quantize(
|
|
||||||
Decimal("-0.00")
|
|
||||||
) == Decimal("-31.67")
|
|
||||||
|
|
||||||
pytest.raises(ValueError, PlayResultCalculators.score_modifier, -1)
|
|
||||||
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.score_modifier, "9800000")
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.score_modifier, None)
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.score_modifier, [])
|
|
||||||
|
|
||||||
def test_play_rating(self):
|
|
||||||
assert PlayResultCalculators.play_rating(10002221, 120) == Decimal("14.0")
|
|
||||||
|
|
||||||
assert PlayResultCalculators.play_rating(5500000, 120) == Decimal("0.0")
|
|
||||||
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.play_rating, "10002221", 120)
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.play_rating, 10002221, "120")
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.play_rating, "10002221", "120")
|
|
||||||
|
|
||||||
pytest.raises(TypeError, PlayResultCalculators.play_rating, 10002221, None)
|
|
||||||
|
|
||||||
pytest.raises(ValueError, PlayResultCalculators.play_rating, -1, 120)
|
|
||||||
pytest.raises(ValueError, PlayResultCalculators.play_rating, 10002221, -1)
|
|
@ -1,74 +0,0 @@
|
|||||||
from decimal import ROUND_FLOOR, Decimal
|
|
||||||
|
|
||||||
from arcaea_offline.calculators.play_result import PlayResultCalculators
|
|
||||||
from arcaea_offline.calculators.world import (
|
|
||||||
LegacyMapStepBooster,
|
|
||||||
PartnerBonus,
|
|
||||||
WorldMainMapCalculators,
|
|
||||||
WorldPlayResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestWorldMainMapCalculators:
|
|
||||||
def test_step_fandom(self):
|
|
||||||
# Final result from https://arcaea.fandom.com/wiki/World_Mode_Mechanics#Calculation
|
|
||||||
# CC BY-SA 3.0
|
|
||||||
|
|
||||||
booster = LegacyMapStepBooster(6, 250)
|
|
||||||
partner_bonus = PartnerBonus(step_bonus="+3.6")
|
|
||||||
play_result = WorldPlayResult(play_rating=Decimal("11.299"), partner_step=92)
|
|
||||||
result = WorldMainMapCalculators.step(
|
|
||||||
play_result, partner_bonus=partner_bonus, step_booster=booster
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.quantize(Decimal("0.000")) == Decimal("175.149")
|
|
||||||
|
|
||||||
def test_step(self):
|
|
||||||
# Results from actual play results, Arcaea v5.5.8c
|
|
||||||
|
|
||||||
def _quantize(decimal: Decimal) -> Decimal:
|
|
||||||
return decimal.quantize(Decimal("0.0"), rounding=ROUND_FLOOR)
|
|
||||||
|
|
||||||
# goldenslaughter FTR [9.7], 9906968
|
|
||||||
# 10.7 > 34.2 < 160
|
|
||||||
assert _quantize(
|
|
||||||
WorldMainMapCalculators.step(
|
|
||||||
WorldPlayResult(
|
|
||||||
play_rating=PlayResultCalculators.play_rating(9906968, 97),
|
|
||||||
partner_step=160,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) == Decimal("34.2")
|
|
||||||
|
|
||||||
# Luna Rossa FTR [9.7], 9984569
|
|
||||||
# 10.8 > 34.7 < 160
|
|
||||||
assert _quantize(
|
|
||||||
WorldMainMapCalculators.step(
|
|
||||||
WorldPlayResult(
|
|
||||||
play_rating=PlayResultCalculators.play_rating(9984569, 97),
|
|
||||||
partner_step=160,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) == Decimal("34.7")
|
|
||||||
|
|
||||||
# ultradiaxon-N3 FTR [10.5], 9349575
|
|
||||||
# 10.2 > 32.7 < 160
|
|
||||||
assert _quantize(
|
|
||||||
WorldMainMapCalculators.step(
|
|
||||||
WorldPlayResult(
|
|
||||||
play_rating=PlayResultCalculators.play_rating(9349575, 105),
|
|
||||||
partner_step=160,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) == Decimal("32.7")
|
|
||||||
|
|
||||||
# san skia FTR [8.3], 10001036
|
|
||||||
# 10.3 > 64.2 < 310
|
|
||||||
assert _quantize(
|
|
||||||
WorldMainMapCalculators.step(
|
|
||||||
WorldPlayResult(
|
|
||||||
play_rating=PlayResultCalculators.play_rating(10001036, 83),
|
|
||||||
partner_step=310,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) == Decimal("64.2")
|
|
@ -1,27 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# region sqlalchemy fixtures
|
|
||||||
# from https://medium.com/@vittorio.camisa/agile-database-integration-tests-with-python-sqlalchemy-and-factory-boy-6824e8fe33a1
|
|
||||||
engine = create_engine("sqlite:///:memory:")
|
|
||||||
Session = sessionmaker()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def db_conn():
|
|
||||||
connection = engine.connect()
|
|
||||||
yield connection
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
def db_session(db_conn):
|
|
||||||
transaction = db_conn.begin()
|
|
||||||
session = Session(bind=db_conn)
|
|
||||||
yield session
|
|
||||||
session.close()
|
|
||||||
transaction.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
# endregion
|
|
@ -1,95 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
|
||||||
|
|
||||||
from arcaea_offline.constants.enums import (
|
|
||||||
ArcaeaPlayResultClearType,
|
|
||||||
ArcaeaPlayResultModifier,
|
|
||||||
ArcaeaRatingClass,
|
|
||||||
)
|
|
||||||
from arcaea_offline.database.models._custom_types import (
|
|
||||||
DbClearType,
|
|
||||||
DbModifier,
|
|
||||||
DbRatingClass,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RatingClassTestModel(Base):
|
|
||||||
__tablename__ = "test_rating_class"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
value: Mapped[Optional[ArcaeaRatingClass]] = mapped_column(
|
|
||||||
DbRatingClass, nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClearTypeTestModel(Base):
|
|
||||||
__tablename__ = "test_clear_type"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
value: Mapped[Optional[ArcaeaPlayResultClearType]] = mapped_column(
|
|
||||||
DbClearType, nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ModifierTestModel(Base):
|
|
||||||
__tablename__ = "test_modifier"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
value: Mapped[Optional[ArcaeaPlayResultModifier]] = mapped_column(
|
|
||||||
DbModifier, nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCustomTypes:
|
|
||||||
def _common_test_method(self, session: Session, obj: Base, value_in_db):
|
|
||||||
"""
|
|
||||||
This method stores the `obj` into the given `session`,
|
|
||||||
then fetches the raw value of `obj.value` from database,
|
|
||||||
and asserts that the value is equal to `value_in_db`.
|
|
||||||
"""
|
|
||||||
session.add(obj)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
exec_result = session.execute(
|
|
||||||
text(
|
|
||||||
f"SELECT value FROM {obj.__tablename__} WHERE id = {obj.id}" # type: ignore
|
|
||||||
)
|
|
||||||
).fetchone()[0]
|
|
||||||
|
|
||||||
if value_in_db is None:
|
|
||||||
assert exec_result is value_in_db
|
|
||||||
else:
|
|
||||||
assert exec_result == value_in_db
|
|
||||||
|
|
||||||
def test_rating_class(self, db_session):
|
|
||||||
Base.metadata.create_all(db_session.bind)
|
|
||||||
|
|
||||||
basic_obj = RatingClassTestModel(id=1, value=ArcaeaRatingClass.FUTURE)
|
|
||||||
self._common_test_method(db_session, basic_obj, 2)
|
|
||||||
|
|
||||||
null_obj = RatingClassTestModel(id=2, value=None)
|
|
||||||
self._common_test_method(db_session, null_obj, None)
|
|
||||||
|
|
||||||
def test_clear_type(self, db_session):
|
|
||||||
Base.metadata.create_all(db_session.bind)
|
|
||||||
|
|
||||||
basic_obj = ClearTypeTestModel(id=1, value=ArcaeaPlayResultClearType.TRACK_LOST)
|
|
||||||
self._common_test_method(db_session, basic_obj, 0)
|
|
||||||
|
|
||||||
null_obj = ClearTypeTestModel(id=2, value=None)
|
|
||||||
self._common_test_method(db_session, null_obj, None)
|
|
||||||
|
|
||||||
def test_modifier(self, db_session):
|
|
||||||
Base.metadata.create_all(db_session.bind)
|
|
||||||
|
|
||||||
basic_obj = ModifierTestModel(id=1, value=ArcaeaPlayResultModifier.HARD)
|
|
||||||
self._common_test_method(db_session, basic_obj, 2)
|
|
||||||
|
|
||||||
null_obj = ModifierTestModel(id=2, value=None)
|
|
||||||
self._common_test_method(db_session, null_obj, None)
|
|
@ -1,126 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from arcaea_offline.constants.enums import (
|
|
||||||
ArcaeaPlayResultClearType,
|
|
||||||
ArcaeaPlayResultModifier,
|
|
||||||
ArcaeaRatingClass,
|
|
||||||
)
|
|
||||||
from arcaea_offline.utils.formatters.play_result import PlayResultFormatter
|
|
||||||
from arcaea_offline.utils.formatters.rating_class import RatingClassFormatter
|
|
||||||
|
|
||||||
|
|
||||||
class TestRatingClassFormatter:
|
|
||||||
def test_name(self):
|
|
||||||
assert RatingClassFormatter.name(ArcaeaRatingClass.PAST) == "Past"
|
|
||||||
assert RatingClassFormatter.name(ArcaeaRatingClass.PRESENT) == "Present"
|
|
||||||
assert RatingClassFormatter.name(ArcaeaRatingClass.FUTURE) == "Future"
|
|
||||||
assert RatingClassFormatter.name(ArcaeaRatingClass.BEYOND) == "Beyond"
|
|
||||||
assert RatingClassFormatter.name(ArcaeaRatingClass.ETERNAL) == "Eternal"
|
|
||||||
|
|
||||||
assert RatingClassFormatter.name(2) == "Future"
|
|
||||||
|
|
||||||
assert RatingClassFormatter.name(100) == "Unknown"
|
|
||||||
assert RatingClassFormatter.name(-1) == "Unknown"
|
|
||||||
|
|
||||||
pytest.raises(TypeError, RatingClassFormatter.name, "2")
|
|
||||||
pytest.raises(TypeError, RatingClassFormatter.name, [])
|
|
||||||
pytest.raises(TypeError, RatingClassFormatter.name, None)
|
|
||||||
|
|
||||||
def test_abbreviation(self):
|
|
||||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.PAST) == "PST"
|
|
||||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.PRESENT) == "PRS"
|
|
||||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.FUTURE) == "FTR"
|
|
||||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.BEYOND) == "BYD"
|
|
||||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.ETERNAL) == "ETR"
|
|
||||||
|
|
||||||
assert RatingClassFormatter.abbreviation(2) == "FTR"
|
|
||||||
|
|
||||||
assert RatingClassFormatter.abbreviation(100) == "UNK"
|
|
||||||
assert RatingClassFormatter.abbreviation(-1) == "UNK"
|
|
||||||
|
|
||||||
pytest.raises(TypeError, RatingClassFormatter.abbreviation, "2")
|
|
||||||
pytest.raises(TypeError, RatingClassFormatter.abbreviation, [])
|
|
||||||
pytest.raises(TypeError, RatingClassFormatter.abbreviation, None)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlayResultFormatter:
|
|
||||||
def test_score_grade(self):
|
|
||||||
assert PlayResultFormatter.score_grade(10001284) == "EX+"
|
|
||||||
assert PlayResultFormatter.score_grade(9989210) == "EX+"
|
|
||||||
assert PlayResultFormatter.score_grade(9900000) == "EX+"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.score_grade(9899999) == "EX"
|
|
||||||
assert PlayResultFormatter.score_grade(9843717) == "EX"
|
|
||||||
assert PlayResultFormatter.score_grade(9800000) == "EX"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.score_grade(9799999) == "AA"
|
|
||||||
assert PlayResultFormatter.score_grade(9794015) == "AA"
|
|
||||||
assert PlayResultFormatter.score_grade(9750000) == "AA"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.score_grade(9499999) == "A"
|
|
||||||
assert PlayResultFormatter.score_grade(9356855) == "A"
|
|
||||||
assert PlayResultFormatter.score_grade(9200000) == "A"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.score_grade(9199999) == "B"
|
|
||||||
assert PlayResultFormatter.score_grade(9065785) == "B"
|
|
||||||
assert PlayResultFormatter.score_grade(8900000) == "B"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.score_grade(8899999) == "C"
|
|
||||||
assert PlayResultFormatter.score_grade(8756211) == "C"
|
|
||||||
assert PlayResultFormatter.score_grade(8600000) == "C"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.score_grade(8599999) == "D"
|
|
||||||
assert PlayResultFormatter.score_grade(5500000) == "D"
|
|
||||||
assert PlayResultFormatter.score_grade(0) == "D"
|
|
||||||
|
|
||||||
pytest.raises(ValueError, PlayResultFormatter.score_grade, -1)
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.score_grade, "10001284")
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.score_grade, [])
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.score_grade, None)
|
|
||||||
|
|
||||||
def test_clear_type(self):
|
|
||||||
assert (
|
|
||||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.TRACK_LOST)
|
|
||||||
== "TRACK LOST"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.NORMAL_CLEAR)
|
|
||||||
== "NORMAL CLEAR"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.FULL_RECALL)
|
|
||||||
== "FULL RECALL"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.PURE_MEMORY)
|
|
||||||
== "PURE MEMORY"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.EASY_CLEAR)
|
|
||||||
== "EASY CLEAR"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.HARD_CLEAR)
|
|
||||||
== "HARD CLEAR"
|
|
||||||
)
|
|
||||||
assert PlayResultFormatter.clear_type(None) == "None"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.clear_type(1) == "NORMAL CLEAR"
|
|
||||||
assert PlayResultFormatter.clear_type(6) == "UNKNOWN"
|
|
||||||
|
|
||||||
pytest.raises(ValueError, PlayResultFormatter.clear_type, -1)
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.clear_type, "1")
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.clear_type, [])
|
|
||||||
|
|
||||||
def test_modifier(self):
|
|
||||||
assert PlayResultFormatter.modifier(ArcaeaPlayResultModifier.NORMAL) == "NORMAL"
|
|
||||||
assert PlayResultFormatter.modifier(ArcaeaPlayResultModifier.EASY) == "EASY"
|
|
||||||
assert PlayResultFormatter.modifier(ArcaeaPlayResultModifier.HARD) == "HARD"
|
|
||||||
assert PlayResultFormatter.modifier(None) == "None"
|
|
||||||
|
|
||||||
assert PlayResultFormatter.modifier(1) == "EASY"
|
|
||||||
assert PlayResultFormatter.modifier(6) == "UNKNOWN"
|
|
||||||
|
|
||||||
pytest.raises(ValueError, PlayResultFormatter.modifier, -1)
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.modifier, "1")
|
|
||||||
pytest.raises(TypeError, PlayResultFormatter.modifier, [])
|
|
Reference in New Issue
Block a user