mirror of
https://github.com/283375/arcaea-offline.git
synced 2025-07-01 12:16:26 +00:00
Compare commits
36 Commits
master
...
6fb24d4907
Author | SHA1 | Date | |
---|---|---|---|
6fb24d4907
|
|||
eab2a3e520
|
|||
caced6eaec
|
|||
990efee900
|
|||
10c869846c
|
|||
d97ed91631
|
|||
5e996d35d2
|
|||
bfa1472b5c
|
|||
bb163ad78d
|
|||
864f524e68
|
|||
03696650ea
|
|||
4e799034d7
|
|||
b8136bf25f
|
|||
86d7a86700
|
|||
a32453b989
|
|||
d52d234adc
|
|||
88201e2ca4
|
|||
43be27bd4a
|
|||
1c114816c0
|
|||
f6e5f45579
|
|||
a27afca8a7
|
|||
ce715bfccc
|
|||
0d5e21a90e
|
|||
a6d71135fb
|
|||
5f2b66233b
|
|||
e295e58388
|
|||
61d9916cae
|
|||
264b340dfa
|
|||
f359322b6c
|
|||
c705fea473
|
|||
c585e5ec04
|
|||
09fbebf7a4
|
|||
bb39a5912b
|
|||
b78040a795
|
|||
2204338a5e
|
|||
55e76ef650
|
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@ -14,6 +14,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -24,7 +25,7 @@ jobs:
|
|||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: 'pip install .[dev]'
|
run: 'pip install .[dev]'
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: 'pytest -v'
|
run: 'python -m pytest -v'
|
||||||
|
|
||||||
ruff:
|
ruff:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -6,7 +6,7 @@ repos:
|
|||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.4.4
|
rev: v0.6.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: ["--fix"]
|
args: ["--fix"]
|
||||||
|
@ -10,14 +10,9 @@ English | [简体中文](./README.zh_Hans.md)
|
|||||||
|
|
||||||
## WIP
|
## WIP
|
||||||
|
|
||||||
> [!CAUTION]
|
> **Warning**
|
||||||
> 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,14 +8,9 @@
|
|||||||
|
|
||||||
## WIP
|
## WIP
|
||||||
|
|
||||||
> [!CAUTION]
|
> **Warning**
|
||||||
> 该项目正处于早期开发阶段,不能保证稳定性,且 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` 的核心依赖库,用于维护分数数据库、计算潜力值,并提供一些实用工具。
|
||||||
|
@ -9,19 +9,14 @@ authors = [{ name = "283375", email = "log_283375@163.com" }]
|
|||||||
description = "Manage your local Arcaea score database."
|
description = "Manage your local Arcaea score database."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
dependencies = [
|
dependencies = ["SQLAlchemy==2.0.20", "SQLAlchemy-Utils==0.41.1"]
|
||||||
"beautifulsoup4==4.12.2",
|
|
||||||
"SQLAlchemy==2.0.20",
|
|
||||||
"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]
|
[project.optional-dependencies]
|
||||||
dev = ["ruff~=0.4", "pre-commit~=3.3", "pytest~=7.4", "tox~=4.11"]
|
dev = ["ruff~=0.6.8", "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"
|
||||||
@ -51,3 +46,8 @@ select = [
|
|||||||
ignore = [
|
ignore = [
|
||||||
"E501", # line-too-long
|
"E501", # line-too-long
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = [
|
||||||
|
"PLR2004", # magic-value-comparison
|
||||||
|
]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
ruff~=0.4
|
ruff~=0.6.8
|
||||||
pre-commit~=3.3
|
pre-commit~=3.3
|
||||||
pytest~=7.4
|
pytest~=7.4
|
||||||
tox~=4.11
|
tox~=4.11
|
||||||
|
@ -1,4 +1,2 @@
|
|||||||
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
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
@ -1,24 +0,0 @@
|
|||||||
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")
|
|
@ -1,66 +0,0 @@
|
|||||||
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)
|
|
@ -1,174 +0,0 @@
|
|||||||
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)
|
|
3
src/arcaea_offline/calculators/__init__.py
Normal file
3
src/arcaea_offline/calculators/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .play_result import PlayResultCalculators
|
||||||
|
|
||||||
|
__all__ = ["PlayResultCalculators"]
|
105
src/arcaea_offline/calculators/play_result.py
Normal file
105
src/arcaea_offline/calculators/play_result.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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 >= ScoreLowerLimits.PM:
|
||||||
|
return Decimal(2)
|
||||||
|
if score >= ScoreLowerLimits.EX:
|
||||||
|
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),
|
||||||
|
}
|
25
src/arcaea_offline/calculators/world/__init__.py
Normal file
25
src/arcaea_offline/calculators/world/__init__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from ._common import MemoriesStepBooster, PartnerBonus, WorldPlayResult
|
||||||
|
from .legacy import LegacyMapStepBooster
|
||||||
|
from .main import WorldMainMapCalculators
|
||||||
|
from .partners import (
|
||||||
|
AmaneBelowExPartnerBonus,
|
||||||
|
AwakenedEtoPartnerBonus,
|
||||||
|
AwakenedIlithPartnerBonus,
|
||||||
|
AwakenedLunaPartnerBonus,
|
||||||
|
MayaPartnerBonus,
|
||||||
|
MithraTerceraPartnerBonus,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AmaneBelowExPartnerBonus",
|
||||||
|
"AwakenedEtoPartnerBonus",
|
||||||
|
"AwakenedIlithPartnerBonus",
|
||||||
|
"AwakenedLunaPartnerBonus",
|
||||||
|
"LegacyMapStepBooster",
|
||||||
|
"MayaPartnerBonus",
|
||||||
|
"MemoriesStepBooster",
|
||||||
|
"MithraTerceraPartnerBonus",
|
||||||
|
"PartnerBonus",
|
||||||
|
"WorldMainMapCalculators",
|
||||||
|
"WorldPlayResult",
|
||||||
|
]
|
50
src/arcaea_offline/calculators/world/_common.py
Normal file
50
src/arcaea_offline/calculators/world/_common.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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")
|
45
src/arcaea_offline/calculators/world/legacy.py
Normal file
45
src/arcaea_offline/calculators/world/legacy.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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
|
57
src/arcaea_offline/calculators/world/main.py
Normal file
57
src/arcaea_offline/calculators/world/main.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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)
|
||||||
|
)
|
16
src/arcaea_offline/calculators/world/partners.py
Normal file
16
src/arcaea_offline/calculators/world/partners.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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")
|
0
src/arcaea_offline/constants/__init__.py
Normal file
0
src/arcaea_offline/constants/__init__.py
Normal file
15
src/arcaea_offline/constants/enums/__init__.py
Normal file
15
src/arcaea_offline/constants/enums/__init__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from .arcaea import (
|
||||||
|
ArcaeaLanguage,
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
ArcaeaSongSide,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArcaeaLanguage",
|
||||||
|
"ArcaeaPlayResultClearType",
|
||||||
|
"ArcaeaPlayResultModifier",
|
||||||
|
"ArcaeaRatingClass",
|
||||||
|
"ArcaeaSongSide",
|
||||||
|
]
|
37
src/arcaea_offline/constants/enums/arcaea.py
Normal file
37
src/arcaea_offline/constants/enums/arcaea.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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"
|
13
src/arcaea_offline/constants/play_result.py
Normal file
13
src/arcaea_offline/constants/play_result.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScoreLowerLimits:
|
||||||
|
PM = 10000000
|
||||||
|
EX_PLUS = 9900000
|
||||||
|
EX = 9800000
|
||||||
|
AA = 9500000
|
||||||
|
A = 9200000
|
||||||
|
B = 8900000
|
||||||
|
C = 8600000
|
||||||
|
D = 0
|
3
src/arcaea_offline/database/__init__.py
Normal file
3
src/arcaea_offline/database/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .db import Database
|
||||||
|
|
||||||
|
__all__ = ["Database"]
|
@ -1,14 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from typing import Iterable, List, Optional, Type, Union
|
from typing import Iterable, 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 .external.arcsong.arcsong_json import ArcSongJsonBuilder
|
from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
|
||||||
from .external.exports import ArcaeaOfflineDEFV2_Score, ScoreExport, exporters
|
from arcaea_offline.singleton import Singleton
|
||||||
from .models.config import ConfigBase, Property
|
|
||||||
from .models.scores import (
|
from .models.v4.config import ConfigBase, Property
|
||||||
|
from .models.v4.scores import (
|
||||||
CalculatedPotential,
|
CalculatedPotential,
|
||||||
Score,
|
Score,
|
||||||
ScoreBest,
|
ScoreBest,
|
||||||
@ -16,7 +17,7 @@ from .models.scores import (
|
|||||||
ScoresBase,
|
ScoresBase,
|
||||||
ScoresViewBase,
|
ScoresViewBase,
|
||||||
)
|
)
|
||||||
from .models.songs import (
|
from .models.v4.songs import (
|
||||||
Chart,
|
Chart,
|
||||||
ChartInfo,
|
ChartInfo,
|
||||||
Difficulty,
|
Difficulty,
|
||||||
@ -28,7 +29,6 @@ from .models.songs import (
|
|||||||
SongsBase,
|
SongsBase,
|
||||||
SongsViewBase,
|
SongsViewBase,
|
||||||
)
|
)
|
||||||
from .singleton import Singleton
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -405,20 +405,6 @@ class Database(metaclass=Singleton):
|
|||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
# region export
|
# region export
|
||||||
|
|
||||||
def export_scores(self) -> List[ScoreExport]:
|
|
||||||
scores = self.get_scores()
|
|
||||||
return [exporters.score(score) for score in scores]
|
|
||||||
|
|
||||||
def export_scores_def_v2(self) -> ArcaeaOfflineDEFV2_Score:
|
|
||||||
scores = self.get_scores()
|
|
||||||
return {
|
|
||||||
"$schema": "https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json",
|
|
||||||
"type": "score",
|
|
||||||
"version": 2,
|
|
||||||
"scores": [exporters.score_def_v2(score) for score in scores],
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_arcsong(self):
|
def generate_arcsong(self):
|
||||||
with self.sessionmaker() as session:
|
with self.sessionmaker() as session:
|
||||||
arcsong = ArcSongJsonBuilder(session).generate_arcsong_json()
|
arcsong = ArcSongJsonBuilder(session).generate_arcsong_json()
|
0
src/arcaea_offline/database/models/__init__.py
Normal file
0
src/arcaea_offline/database/models/__init__.py
Normal file
46
src/arcaea_offline/database/models/_custom_types.py
Normal file
46
src/arcaea_offline/database/models/_custom_types.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer
|
||||||
|
from sqlalchemy.types import TypeDecorator
|
||||||
|
|
||||||
|
|
||||||
|
class DbIntEnum(TypeDecorator):
|
||||||
|
"""sqlalchemy `TypeDecorator` for `IntEnum`s"""
|
||||||
|
|
||||||
|
impl = Integer
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def __init__(self, enum_class: Type[IntEnum]):
|
||||||
|
super().__init__()
|
||||||
|
self.enum_class = enum_class
|
||||||
|
|
||||||
|
def process_bind_param(self, value: Optional[IntEnum], dialect) -> Optional[int]:
|
||||||
|
return None if value is None else value.value
|
||||||
|
|
||||||
|
def process_result_value(self, value: Optional[int], dialect) -> Optional[IntEnum]:
|
||||||
|
return None if value is None else self.enum_class(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TZDateTime(TypeDecorator):
|
||||||
|
"""
|
||||||
|
Store Timezone Aware Timestamps as Timezone Naive UTC
|
||||||
|
|
||||||
|
https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc
|
||||||
|
"""
|
||||||
|
|
||||||
|
impl = DateTime
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value: Optional[datetime], dialect):
|
||||||
|
if value is not None:
|
||||||
|
if not value.tzinfo or value.tzinfo.utcoffset(value) is None:
|
||||||
|
raise TypeError("tzinfo is required")
|
||||||
|
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def process_result_value(self, value: Optional[datetime], dialect):
|
||||||
|
if value is not None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
@ -19,3 +19,24 @@ from .songs import (
|
|||||||
SongsBase,
|
SongsBase,
|
||||||
SongsViewBase,
|
SongsViewBase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CalculatedPotential",
|
||||||
|
"Chart",
|
||||||
|
"ChartInfo",
|
||||||
|
"ConfigBase",
|
||||||
|
"Difficulty",
|
||||||
|
"DifficultyLocalized",
|
||||||
|
"Pack",
|
||||||
|
"PackLocalized",
|
||||||
|
"Property",
|
||||||
|
"Score",
|
||||||
|
"ScoreBest",
|
||||||
|
"ScoreCalculated",
|
||||||
|
"ScoresBase",
|
||||||
|
"ScoresViewBase",
|
||||||
|
"Song",
|
||||||
|
"SongLocalized",
|
||||||
|
"SongsBase",
|
||||||
|
"SongsViewBase",
|
||||||
|
]
|
@ -10,12 +10,12 @@ from .common import ReprHelper
|
|||||||
from .songs import ChartInfo, Difficulty
|
from .songs import ChartInfo, Difficulty
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ScoresBase",
|
|
||||||
"Score",
|
|
||||||
"ScoresViewBase",
|
|
||||||
"ScoreCalculated",
|
|
||||||
"ScoreBest",
|
|
||||||
"CalculatedPotential",
|
"CalculatedPotential",
|
||||||
|
"Score",
|
||||||
|
"ScoreBest",
|
||||||
|
"ScoreCalculated",
|
||||||
|
"ScoresBase",
|
||||||
|
"ScoresViewBase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -9,16 +9,16 @@ from sqlalchemy_utils import create_view
|
|||||||
from .common import ReprHelper
|
from .common import ReprHelper
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SongsBase",
|
"Chart",
|
||||||
|
"ChartInfo",
|
||||||
|
"Difficulty",
|
||||||
|
"DifficultyLocalized",
|
||||||
"Pack",
|
"Pack",
|
||||||
"PackLocalized",
|
"PackLocalized",
|
||||||
"Song",
|
"Song",
|
||||||
"SongLocalized",
|
"SongLocalized",
|
||||||
"Difficulty",
|
"SongsBase",
|
||||||
"DifficultyLocalized",
|
|
||||||
"ChartInfo",
|
|
||||||
"SongsViewBase",
|
"SongsViewBase",
|
||||||
"Chart",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
38
src/arcaea_offline/database/models/v5/__init__.py
Normal file
38
src/arcaea_offline/database/models/v5/__init__.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from .arcaea import (
|
||||||
|
Chart,
|
||||||
|
ChartInfo,
|
||||||
|
Difficulty,
|
||||||
|
DifficultyLocalized,
|
||||||
|
Pack,
|
||||||
|
PackLocalized,
|
||||||
|
Song,
|
||||||
|
SongLocalized,
|
||||||
|
SongSearchWord,
|
||||||
|
)
|
||||||
|
from .base import ModelsV5Base, ModelsV5ViewBase
|
||||||
|
from .config import Property
|
||||||
|
from .play_results import (
|
||||||
|
CalculatedPotential,
|
||||||
|
PlayResult,
|
||||||
|
PlayResultBest,
|
||||||
|
PlayResultCalculated,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CalculatedPotential",
|
||||||
|
"Chart",
|
||||||
|
"ChartInfo",
|
||||||
|
"Difficulty",
|
||||||
|
"DifficultyLocalized",
|
||||||
|
"ModelsV5Base",
|
||||||
|
"ModelsV5ViewBase",
|
||||||
|
"Pack",
|
||||||
|
"PackLocalized",
|
||||||
|
"PlayResult",
|
||||||
|
"PlayResultBest",
|
||||||
|
"PlayResultCalculated",
|
||||||
|
"Property",
|
||||||
|
"Song",
|
||||||
|
"SongLocalized",
|
||||||
|
"SongSearchWord",
|
||||||
|
]
|
275
src/arcaea_offline/database/models/v5/arcaea.py
Normal file
275
src/arcaea_offline/database/models/v5/arcaea.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, and_, func, select
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy_utils import create_view
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import ArcaeaRatingClass, ArcaeaSongSide
|
||||||
|
|
||||||
|
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Chart",
|
||||||
|
"ChartInfo",
|
||||||
|
"Difficulty",
|
||||||
|
"DifficultyLocalized",
|
||||||
|
"Pack",
|
||||||
|
"PackLocalized",
|
||||||
|
"Song",
|
||||||
|
"SongLocalized",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Pack(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "packs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str]
|
||||||
|
description: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
songs: Mapped[List["Song"]] = relationship(back_populates="pack", viewonly=True)
|
||||||
|
localized_objects: Mapped[List["PackLocalized"]] = relationship(
|
||||||
|
back_populates="parent", viewonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PackLocalized(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "packs_localized"
|
||||||
|
|
||||||
|
pkid: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
|
||||||
|
)
|
||||||
|
lang: Mapped[str]
|
||||||
|
name: Mapped[Optional[str]]
|
||||||
|
description: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
parent: Mapped[Pack] = relationship(viewonly=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Song(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "songs"
|
||||||
|
|
||||||
|
idx: Mapped[int]
|
||||||
|
id: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str]
|
||||||
|
artist: Mapped[str]
|
||||||
|
pack_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
|
||||||
|
)
|
||||||
|
bpm: Mapped[Optional[str]]
|
||||||
|
bpm_base: Mapped[Optional[float]]
|
||||||
|
audio_preview: Mapped[Optional[int]]
|
||||||
|
audio_preview_end: Mapped[Optional[int]]
|
||||||
|
side: Mapped[Optional[ArcaeaSongSide]]
|
||||||
|
version: Mapped[Optional[str]]
|
||||||
|
date: Mapped[Optional[int]]
|
||||||
|
bg: Mapped[Optional[str]]
|
||||||
|
bg_inverse: Mapped[Optional[str]]
|
||||||
|
bg_day: Mapped[Optional[str]]
|
||||||
|
bg_night: Mapped[Optional[str]]
|
||||||
|
source: Mapped[Optional[str]]
|
||||||
|
source_copyright: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
pack: Mapped[Pack] = relationship(viewonly=True)
|
||||||
|
difficulties: Mapped[List["Difficulty"]] = relationship(
|
||||||
|
back_populates="song", viewonly=True
|
||||||
|
)
|
||||||
|
localized_objects: Mapped[List["SongLocalized"]] = relationship(
|
||||||
|
back_populates="parent", viewonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def charts_info(self):
|
||||||
|
return [d.chart_info for d in self.difficulties if d.chart_info is not None]
|
||||||
|
|
||||||
|
|
||||||
|
class SongLocalized(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "songs_localized"
|
||||||
|
|
||||||
|
pkid: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
|
||||||
|
)
|
||||||
|
lang: Mapped[str]
|
||||||
|
title: Mapped[Optional[str]]
|
||||||
|
source: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
parent: Mapped[Song] = relationship(
|
||||||
|
back_populates="localized_objects", viewonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SongSearchWord(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "songs_search_words"
|
||||||
|
|
||||||
|
pkid: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
|
||||||
|
)
|
||||||
|
lang: Mapped[str]
|
||||||
|
type: Mapped[int] = mapped_column(comment="1: title, 2: artist")
|
||||||
|
value: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Difficulty(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "difficulties"
|
||||||
|
|
||||||
|
song_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(primary_key=True)
|
||||||
|
rating: Mapped[int]
|
||||||
|
rating_plus: Mapped[bool]
|
||||||
|
chart_designer: Mapped[Optional[str]]
|
||||||
|
jacket_desginer: Mapped[Optional[str]]
|
||||||
|
audio_override: Mapped[bool] = mapped_column(default=False)
|
||||||
|
jacket_override: Mapped[bool] = mapped_column(default=False)
|
||||||
|
jacket_night: Mapped[Optional[str]]
|
||||||
|
title: Mapped[Optional[str]]
|
||||||
|
artist: Mapped[Optional[str]]
|
||||||
|
bg: Mapped[Optional[str]]
|
||||||
|
bg_inverse: Mapped[Optional[str]]
|
||||||
|
bpm: Mapped[Optional[str]]
|
||||||
|
bpm_base: Mapped[Optional[float]]
|
||||||
|
version: Mapped[Optional[str]]
|
||||||
|
date: Mapped[Optional[int]]
|
||||||
|
|
||||||
|
song: Mapped[Song] = relationship(back_populates="difficulties", viewonly=True)
|
||||||
|
chart_info: Mapped[Optional["ChartInfo"]] = relationship(
|
||||||
|
primaryjoin=(
|
||||||
|
"and_(Difficulty.song_id==ChartInfo.song_id, "
|
||||||
|
"Difficulty.rating_class==ChartInfo.rating_class)"
|
||||||
|
),
|
||||||
|
viewonly=True,
|
||||||
|
)
|
||||||
|
localized_objects: Mapped[List["DifficultyLocalized"]] = relationship(
|
||||||
|
primaryjoin=(
|
||||||
|
"and_(Difficulty.song_id==DifficultyLocalized.song_id, "
|
||||||
|
"Difficulty.rating_class==DifficultyLocalized.rating_class)"
|
||||||
|
),
|
||||||
|
viewonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DifficultyLocalized(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "difficulties_localized"
|
||||||
|
|
||||||
|
pkid: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
song_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION")
|
||||||
|
)
|
||||||
|
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
|
||||||
|
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION")
|
||||||
|
)
|
||||||
|
lang: Mapped[str]
|
||||||
|
title: Mapped[Optional[str]]
|
||||||
|
artist: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
parent: Mapped[Difficulty] = relationship(
|
||||||
|
primaryjoin=and_(
|
||||||
|
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
|
||||||
|
),
|
||||||
|
viewonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChartInfo(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "charts_info"
|
||||||
|
|
||||||
|
song_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
|
||||||
|
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
constant: Mapped[int] = mapped_column(
|
||||||
|
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104."
|
||||||
|
)
|
||||||
|
notes: Mapped[Optional[int]]
|
||||||
|
|
||||||
|
difficulty: Mapped[Difficulty] = relationship(
|
||||||
|
primaryjoin=and_(
|
||||||
|
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
|
||||||
|
),
|
||||||
|
viewonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Chart(ModelsV5ViewBase, ReprHelper):
|
||||||
|
__tablename__ = "charts"
|
||||||
|
|
||||||
|
song_idx: Mapped[int]
|
||||||
|
song_id: Mapped[str]
|
||||||
|
rating_class: Mapped[ArcaeaRatingClass]
|
||||||
|
rating: Mapped[int]
|
||||||
|
rating_plus: Mapped[bool]
|
||||||
|
title: Mapped[str]
|
||||||
|
artist: Mapped[str]
|
||||||
|
pack_id: Mapped[str]
|
||||||
|
bpm: Mapped[Optional[str]]
|
||||||
|
bpm_base: Mapped[Optional[float]]
|
||||||
|
audio_preview: Mapped[Optional[int]]
|
||||||
|
audio_preview_end: Mapped[Optional[int]]
|
||||||
|
side: Mapped[Optional[int]]
|
||||||
|
version: Mapped[Optional[str]]
|
||||||
|
date: Mapped[Optional[int]]
|
||||||
|
bg: Mapped[Optional[str]]
|
||||||
|
bg_inverse: Mapped[Optional[str]]
|
||||||
|
bg_day: Mapped[Optional[str]]
|
||||||
|
bg_night: Mapped[Optional[str]]
|
||||||
|
source: Mapped[Optional[str]]
|
||||||
|
source_copyright: Mapped[Optional[str]]
|
||||||
|
chart_designer: Mapped[Optional[str]]
|
||||||
|
jacket_desginer: Mapped[Optional[str]]
|
||||||
|
audio_override: Mapped[bool]
|
||||||
|
jacket_override: Mapped[bool]
|
||||||
|
jacket_night: Mapped[Optional[str]]
|
||||||
|
constant: Mapped[int]
|
||||||
|
notes: Mapped[Optional[int]]
|
||||||
|
|
||||||
|
__table__ = create_view(
|
||||||
|
name=__tablename__,
|
||||||
|
selectable=select(
|
||||||
|
Song.idx.label("song_idx"),
|
||||||
|
Difficulty.song_id,
|
||||||
|
Difficulty.rating_class,
|
||||||
|
Difficulty.rating,
|
||||||
|
Difficulty.rating_plus,
|
||||||
|
func.coalesce(Difficulty.title, Song.title).label("title"),
|
||||||
|
func.coalesce(Difficulty.artist, Song.artist).label("artist"),
|
||||||
|
Song.pack_id,
|
||||||
|
func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"),
|
||||||
|
func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"),
|
||||||
|
Song.audio_preview,
|
||||||
|
Song.audio_preview_end,
|
||||||
|
Song.side,
|
||||||
|
func.coalesce(Difficulty.version, Song.version).label("version"),
|
||||||
|
func.coalesce(Difficulty.date, Song.date).label("date"),
|
||||||
|
func.coalesce(Difficulty.bg, Song.bg).label("bg"),
|
||||||
|
func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"),
|
||||||
|
Song.bg_day,
|
||||||
|
Song.bg_night,
|
||||||
|
Song.source,
|
||||||
|
Song.source_copyright,
|
||||||
|
Difficulty.chart_designer,
|
||||||
|
Difficulty.jacket_desginer,
|
||||||
|
Difficulty.audio_override,
|
||||||
|
Difficulty.jacket_override,
|
||||||
|
Difficulty.jacket_night,
|
||||||
|
ChartInfo.constant,
|
||||||
|
ChartInfo.notes,
|
||||||
|
)
|
||||||
|
.select_from(Difficulty)
|
||||||
|
.join(
|
||||||
|
ChartInfo,
|
||||||
|
(Difficulty.song_id == ChartInfo.song_id)
|
||||||
|
& (Difficulty.rating_class == ChartInfo.rating_class),
|
||||||
|
)
|
||||||
|
.join(Song, Difficulty.song_id == Song.id),
|
||||||
|
metadata=ModelsV5ViewBase.metadata,
|
||||||
|
cascade_on_drop=False,
|
||||||
|
)
|
62
src/arcaea_offline/database/models/v5/base.py
Normal file
62
src/arcaea_offline/database/models/v5/base.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import (
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
ArcaeaSongSide,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .._custom_types import DbIntEnum, TZDateTime
|
||||||
|
|
||||||
|
TYPE_ANNOTATION_MAP = {
|
||||||
|
datetime: TZDateTime,
|
||||||
|
ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass),
|
||||||
|
ArcaeaSongSide: DbIntEnum(ArcaeaSongSide),
|
||||||
|
ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType),
|
||||||
|
ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsV5Base(DeclarativeBase):
|
||||||
|
type_annotation_map = TYPE_ANNOTATION_MAP
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsV5ViewBase(DeclarativeBase):
|
||||||
|
type_annotation_map = TYPE_ANNOTATION_MAP
|
||||||
|
|
||||||
|
|
||||||
|
class ReprHelper:
|
||||||
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
def _repr(self, **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
Helper for __repr__
|
||||||
|
|
||||||
|
https://stackoverflow.com/a/55749579/16484891
|
||||||
|
|
||||||
|
CC BY-SA 4.0
|
||||||
|
"""
|
||||||
|
field_strings = []
|
||||||
|
at_least_one_attached_attribute = False
|
||||||
|
for key, field in kwargs.items():
|
||||||
|
try:
|
||||||
|
field_strings.append(f"{key}={field!r}")
|
||||||
|
except DetachedInstanceError:
|
||||||
|
field_strings.append(f"{key}=DetachedInstanceError")
|
||||||
|
else:
|
||||||
|
at_least_one_attached_attribute = True
|
||||||
|
|
||||||
|
if at_least_one_attached_attribute:
|
||||||
|
return f"<{self.__class__.__name__}({', '.join(field_strings)})>"
|
||||||
|
return f"<{self.__class__.__name__} {id(self)}>"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if isinstance(self, DeclarativeBase):
|
||||||
|
return self._repr(
|
||||||
|
**{c.key: getattr(self, c.key) for c in self.__table__.columns}
|
||||||
|
)
|
||||||
|
return super().__repr__()
|
12
src/arcaea_offline/database/models/v5/config.py
Normal file
12
src/arcaea_offline/database/models/v5/config.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import ModelsV5Base, ReprHelper
|
||||||
|
|
||||||
|
__all__ = ["Property"]
|
||||||
|
|
||||||
|
|
||||||
|
class Property(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "properties"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column()
|
197
src/arcaea_offline/database/models/v5/play_results.py
Normal file
197
src/arcaea_offline/database/models/v5/play_results.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy_utils import create_view
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import (
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .arcaea import ChartInfo, Difficulty
|
||||||
|
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CalculatedPotential",
|
||||||
|
"PlayResult",
|
||||||
|
"PlayResultBest",
|
||||||
|
"PlayResultCalculated",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayResult(ModelsV5Base, ReprHelper):
|
||||||
|
__tablename__ = "play_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
|
||||||
|
song_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
|
||||||
|
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
score: Mapped[int]
|
||||||
|
pure: Mapped[Optional[int]]
|
||||||
|
far: Mapped[Optional[int]]
|
||||||
|
lost: Mapped[Optional[int]]
|
||||||
|
date: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
max_recall: Mapped[Optional[int]]
|
||||||
|
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
|
||||||
|
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
|
||||||
|
comment: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
difficulty: Mapped[Difficulty] = relationship(
|
||||||
|
primaryjoin=and_(
|
||||||
|
song_id == Difficulty.song_id,
|
||||||
|
rating_class == Difficulty.rating_class,
|
||||||
|
),
|
||||||
|
viewonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# How to create an SQL View with SQLAlchemy?
|
||||||
|
# https://stackoverflow.com/a/53253105/16484891
|
||||||
|
# CC BY-SA 4.0
|
||||||
|
|
||||||
|
|
||||||
|
class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
|
||||||
|
__tablename__ = "play_results_calculated"
|
||||||
|
|
||||||
|
id: Mapped[int]
|
||||||
|
song_id: Mapped[str]
|
||||||
|
rating_class: Mapped[ArcaeaRatingClass]
|
||||||
|
score: Mapped[int]
|
||||||
|
pure: Mapped[Optional[int]]
|
||||||
|
shiny_pure: Mapped[Optional[int]]
|
||||||
|
far: Mapped[Optional[int]]
|
||||||
|
lost: Mapped[Optional[int]]
|
||||||
|
date: Mapped[Optional[datetime]]
|
||||||
|
max_recall: Mapped[Optional[int]]
|
||||||
|
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
|
||||||
|
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
|
||||||
|
potential: Mapped[float]
|
||||||
|
comment: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
__table__ = create_view(
|
||||||
|
name=__tablename__,
|
||||||
|
selectable=select(
|
||||||
|
PlayResult.id,
|
||||||
|
Difficulty.song_id,
|
||||||
|
Difficulty.rating_class,
|
||||||
|
PlayResult.score,
|
||||||
|
PlayResult.pure,
|
||||||
|
(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
ChartInfo.notes.is_not(None)
|
||||||
|
& PlayResult.pure.is_not(None)
|
||||||
|
& PlayResult.far.is_not(None)
|
||||||
|
& (ChartInfo.notes != 0)
|
||||||
|
),
|
||||||
|
PlayResult.score
|
||||||
|
- func.floor(
|
||||||
|
(PlayResult.pure * 10000000.0 / ChartInfo.notes)
|
||||||
|
+ (PlayResult.far * 0.5 * 10000000.0 / ChartInfo.notes)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
else_=text("NULL"),
|
||||||
|
)
|
||||||
|
).label("shiny_pure"),
|
||||||
|
PlayResult.far,
|
||||||
|
PlayResult.lost,
|
||||||
|
PlayResult.date,
|
||||||
|
PlayResult.max_recall,
|
||||||
|
PlayResult.modifier,
|
||||||
|
PlayResult.clear_type,
|
||||||
|
case(
|
||||||
|
(PlayResult.score >= 10000000, ChartInfo.constant / 10.0 + 2), # noqa: PLR2004
|
||||||
|
(
|
||||||
|
PlayResult.score >= 9800000, # noqa: PLR2004
|
||||||
|
ChartInfo.constant / 10.0
|
||||||
|
+ 1
|
||||||
|
+ (PlayResult.score - 9800000) / 200000.0,
|
||||||
|
),
|
||||||
|
else_=func.max(
|
||||||
|
(ChartInfo.constant / 10.0)
|
||||||
|
+ (PlayResult.score - 9500000) / 300000.0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).label("potential"),
|
||||||
|
PlayResult.comment,
|
||||||
|
)
|
||||||
|
.select_from(Difficulty)
|
||||||
|
.join(
|
||||||
|
ChartInfo,
|
||||||
|
(Difficulty.song_id == ChartInfo.song_id)
|
||||||
|
& (Difficulty.rating_class == ChartInfo.rating_class),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
PlayResult,
|
||||||
|
(Difficulty.song_id == PlayResult.song_id)
|
||||||
|
& (Difficulty.rating_class == PlayResult.rating_class),
|
||||||
|
),
|
||||||
|
metadata=ModelsV5ViewBase.metadata,
|
||||||
|
cascade_on_drop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayResultBest(ModelsV5ViewBase, ReprHelper):
|
||||||
|
__tablename__ = "play_results_best"
|
||||||
|
|
||||||
|
id: Mapped[int]
|
||||||
|
song_id: Mapped[str]
|
||||||
|
rating_class: Mapped[int]
|
||||||
|
score: Mapped[int]
|
||||||
|
pure: Mapped[Optional[int]]
|
||||||
|
shiny_pure: Mapped[Optional[int]]
|
||||||
|
far: Mapped[Optional[int]]
|
||||||
|
lost: Mapped[Optional[int]]
|
||||||
|
date: Mapped[Optional[datetime]]
|
||||||
|
max_recall: Mapped[Optional[int]]
|
||||||
|
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
|
||||||
|
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
|
||||||
|
potential: Mapped[float]
|
||||||
|
comment: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
__table__ = create_view(
|
||||||
|
name=__tablename__,
|
||||||
|
selectable=select(
|
||||||
|
*[
|
||||||
|
col
|
||||||
|
for col in inspect(PlayResultCalculated).columns
|
||||||
|
if col.name != "potential"
|
||||||
|
],
|
||||||
|
func.max(PlayResultCalculated.potential).label("potential"),
|
||||||
|
)
|
||||||
|
.select_from(PlayResultCalculated)
|
||||||
|
.group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class)
|
||||||
|
.order_by(PlayResultCalculated.potential.desc()),
|
||||||
|
metadata=ModelsV5ViewBase.metadata,
|
||||||
|
cascade_on_drop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculatedPotential(ModelsV5ViewBase, ReprHelper):
|
||||||
|
__tablename__ = "calculated_potential"
|
||||||
|
|
||||||
|
b30: Mapped[float]
|
||||||
|
|
||||||
|
_select_bests_subquery = (
|
||||||
|
select(PlayResultBest.potential.label("b30_sum"))
|
||||||
|
.order_by(PlayResultBest.potential.desc())
|
||||||
|
.limit(30)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
__table__ = create_view(
|
||||||
|
name=__tablename__,
|
||||||
|
selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")),
|
||||||
|
metadata=ModelsV5ViewBase.metadata,
|
||||||
|
cascade_on_drop=False,
|
||||||
|
)
|
@ -1 +1,3 @@
|
|||||||
from .api_data import AndrealImageGeneratorApiDataConverter
|
from .api_data import AndrealImageGeneratorApiDataConverter
|
||||||
|
|
||||||
|
__all__ = ["AndrealImageGeneratorApiDataConverter"]
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
from .online import ArcaeaOnlineParser
|
|
||||||
from .packlist import PacklistParser
|
|
||||||
from .songlist import SonglistDifficultiesParser, SonglistParser
|
|
||||||
from .st3 import St3ScoreParser
|
|
99
src/arcaea_offline/external/arcaea/common.py
vendored
99
src/arcaea_offline/external/arcaea/common.py
vendored
@ -1,99 +0,0 @@
|
|||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
from os import PathLike
|
|
||||||
from typing import Any, List, Optional, Union
|
|
||||||
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session
|
|
||||||
|
|
||||||
|
|
||||||
def fix_timestamp(timestamp: int) -> Union[int, None]:
|
|
||||||
"""
|
|
||||||
Some of the `date` column in st3 are strangely truncated. For example,
|
|
||||||
a `1670283375` may be truncated to `167028`, even `1`. Yes, a single `1`.
|
|
||||||
|
|
||||||
To properly handle this situation, we check the timestamp's digits.
|
|
||||||
If `digits < 5`, we treat this timestamp as a `None`. Otherwise, we try to
|
|
||||||
fix the timestamp.
|
|
||||||
|
|
||||||
:param timestamp: a POSIX timestamp
|
|
||||||
:return: `None` if the timestamp's digits < 5, otherwise a fixed POSIX timestamp
|
|
||||||
"""
|
|
||||||
# find digit length from https://stackoverflow.com/a/2189827/16484891
|
|
||||||
# CC BY-SA 2.5
|
|
||||||
# this might give incorrect result when timestamp > 999999999999997,
|
|
||||||
# see https://stackoverflow.com/a/28883802/16484891 (CC BY-SA 4.0).
|
|
||||||
# but that's way too later than 9999-12-31 23:59:59, 253402271999,
|
|
||||||
# I don't think Arcaea would still be an active updated game by then.
|
|
||||||
# so don't mind those small issues, just use this.
|
|
||||||
digits = int(math.log10(abs(timestamp))) + 1 if timestamp != 0 else 1
|
|
||||||
if digits < 5:
|
|
||||||
return None
|
|
||||||
timestamp_str = str(timestamp)
|
|
||||||
current_timestamp_digits = int(math.log10(int(time.time()))) + 1
|
|
||||||
timestamp_str = timestamp_str.ljust(current_timestamp_digits, "0")
|
|
||||||
return int(timestamp_str, 10)
|
|
||||||
|
|
||||||
|
|
||||||
def to_db_value(val: Any) -> Any:
|
|
||||||
if not val:
|
|
||||||
return None
|
|
||||||
return json.dumps(val, ensure_ascii=False) if isinstance(val, list) else val
|
|
||||||
|
|
||||||
|
|
||||||
def is_localized(item: dict, key: str, append_localized: bool = True):
|
|
||||||
item_key = f"{key}_localized" if append_localized else key
|
|
||||||
subitem: Optional[dict] = item.get(item_key)
|
|
||||||
return subitem and (
|
|
||||||
subitem.get("ja")
|
|
||||||
or subitem.get("ko")
|
|
||||||
or subitem.get("zh-Hant")
|
|
||||||
or subitem.get("zh-Hans")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def set_model_localized_attrs(
|
|
||||||
model: DeclarativeBase, item: dict, model_key: str, item_key: Optional[str] = None
|
|
||||||
):
|
|
||||||
if item_key is None:
|
|
||||||
item_key = f"{model_key}_localized"
|
|
||||||
subitem: dict = item.get(item_key, {})
|
|
||||||
if not subitem:
|
|
||||||
return
|
|
||||||
setattr(model, f"{model_key}_ja", to_db_value(subitem.get("ja")))
|
|
||||||
setattr(model, f"{model_key}_ko", to_db_value(subitem.get("ko")))
|
|
||||||
setattr(model, f"{model_key}_zh_hans", to_db_value(subitem.get("zh-Hans")))
|
|
||||||
setattr(model, f"{model_key}_zh_hant", to_db_value(subitem.get("zh-Hant")))
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaParser:
|
|
||||||
def __init__(self, filepath: Union[str, bytes, PathLike]):
|
|
||||||
self.filepath = filepath
|
|
||||||
|
|
||||||
def read_file_text(self):
|
|
||||||
file_handle = None
|
|
||||||
|
|
||||||
with contextlib.suppress(TypeError):
|
|
||||||
# original open
|
|
||||||
file_handle = open(self.filepath, "r", encoding="utf-8")
|
|
||||||
|
|
||||||
if file_handle is None:
|
|
||||||
try:
|
|
||||||
# or maybe a `pathlib.Path` subset
|
|
||||||
# or an `importlib.resources.abc.Traversable` like object
|
|
||||||
# e.g. `zipfile.Path`
|
|
||||||
file_handle = self.filepath.open(mode="r", encoding="utf-8") # type: ignore
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError("Invalid `filepath`.") from e
|
|
||||||
|
|
||||||
with file_handle:
|
|
||||||
return file_handle.read()
|
|
||||||
|
|
||||||
def parse(self) -> List[DeclarativeBase]:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def write_database(self, session: Session):
|
|
||||||
results = self.parse()
|
|
||||||
for result in results:
|
|
||||||
session.merge(result)
|
|
72
src/arcaea_offline/external/arcaea/online.py
vendored
72
src/arcaea_offline/external/arcaea/online.py
vendored
@ -1,72 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Literal, Optional, TypedDict
|
|
||||||
|
|
||||||
from ...models import Score
|
|
||||||
from .common import ArcaeaParser, fix_timestamp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TWebApiRatingMeScoreItem(TypedDict):
|
|
||||||
song_id: str
|
|
||||||
difficulty: int
|
|
||||||
modifier: int
|
|
||||||
rating: float
|
|
||||||
score: int
|
|
||||||
perfect_count: int
|
|
||||||
near_count: int
|
|
||||||
miss_count: int
|
|
||||||
clear_type: int
|
|
||||||
title: Dict[Literal["ja", "en"], str]
|
|
||||||
artist: str
|
|
||||||
time_played: int
|
|
||||||
bg: str
|
|
||||||
|
|
||||||
|
|
||||||
class TWebApiRatingMeValue(TypedDict):
|
|
||||||
best_rated_scores: List[TWebApiRatingMeScoreItem]
|
|
||||||
recent_rated_scores: List[TWebApiRatingMeScoreItem]
|
|
||||||
|
|
||||||
|
|
||||||
class TWebApiRatingMeResult(TypedDict):
|
|
||||||
success: bool
|
|
||||||
error_code: Optional[int]
|
|
||||||
value: Optional[TWebApiRatingMeValue]
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaOnlineParser(ArcaeaParser):
|
|
||||||
def parse(self) -> List[Score]:
|
|
||||||
api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text())
|
|
||||||
|
|
||||||
api_result_value = api_result_root.get("value")
|
|
||||||
if not api_result_value:
|
|
||||||
error_code = api_result_root.get("error_code")
|
|
||||||
raise ValueError(f"Cannot parse API result, error code {error_code}")
|
|
||||||
|
|
||||||
best30_score_items = api_result_value.get("best_rated_scores", [])
|
|
||||||
recent_score_items = api_result_value.get("recent_rated_scores", [])
|
|
||||||
score_items = best30_score_items + recent_score_items
|
|
||||||
|
|
||||||
date_text = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
results: List[Score] = []
|
|
||||||
for score_item in score_items:
|
|
||||||
score = Score()
|
|
||||||
score.song_id = score_item["song_id"]
|
|
||||||
score.rating_class = score_item["difficulty"]
|
|
||||||
score.score = score_item["score"]
|
|
||||||
score.pure = score_item["perfect_count"]
|
|
||||||
score.far = score_item["near_count"]
|
|
||||||
score.lost = score_item["miss_count"]
|
|
||||||
score.date = fix_timestamp(int(score_item["time_played"] / 1000))
|
|
||||||
score.modifier = score_item["modifier"]
|
|
||||||
score.clear_type = score_item["clear_type"]
|
|
||||||
|
|
||||||
if score.lost == 0:
|
|
||||||
score.max_recall = score.pure + score.far
|
|
||||||
|
|
||||||
score.comment = f"Parsed from web API at {date_text}"
|
|
||||||
results.append(score)
|
|
||||||
return results
|
|
29
src/arcaea_offline/external/arcaea/packlist.py
vendored
29
src/arcaea_offline/external/arcaea/packlist.py
vendored
@ -1,29 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import List, Union
|
|
||||||
|
|
||||||
from ...models.songs import Pack, PackLocalized
|
|
||||||
from .common import ArcaeaParser, is_localized, set_model_localized_attrs
|
|
||||||
|
|
||||||
|
|
||||||
class PacklistParser(ArcaeaParser):
|
|
||||||
def parse(self) -> List[Union[Pack, PackLocalized]]:
|
|
||||||
packlist_json_root = json.loads(self.read_file_text())
|
|
||||||
|
|
||||||
packlist_json = packlist_json_root["packs"]
|
|
||||||
results: List[Union[Pack, PackLocalized]] = [
|
|
||||||
Pack(id="single", name="Memory Archive")
|
|
||||||
]
|
|
||||||
for item in packlist_json:
|
|
||||||
pack = Pack()
|
|
||||||
pack.id = item["id"]
|
|
||||||
pack.name = item["name_localized"]["en"]
|
|
||||||
pack.description = item["description_localized"]["en"] or None
|
|
||||||
results.append(pack)
|
|
||||||
|
|
||||||
if is_localized(item, "name") or is_localized(item, "description"):
|
|
||||||
pack_localized = PackLocalized(id=pack.id)
|
|
||||||
set_model_localized_attrs(pack_localized, item, "name")
|
|
||||||
set_model_localized_attrs(pack_localized, item, "description")
|
|
||||||
results.append(pack_localized)
|
|
||||||
|
|
||||||
return results
|
|
101
src/arcaea_offline/external/arcaea/songlist.py
vendored
101
src/arcaea_offline/external/arcaea/songlist.py
vendored
@ -1,101 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import List, Union
|
|
||||||
|
|
||||||
from ...models.songs import Difficulty, DifficultyLocalized, Song, SongLocalized
|
|
||||||
from .common import ArcaeaParser, is_localized, set_model_localized_attrs, to_db_value
|
|
||||||
|
|
||||||
|
|
||||||
class SonglistParser(ArcaeaParser):
|
|
||||||
def parse(
|
|
||||||
self,
|
|
||||||
) -> List[Union[Song, SongLocalized, Difficulty, DifficultyLocalized]]:
|
|
||||||
songlist_json_root = json.loads(self.read_file_text())
|
|
||||||
|
|
||||||
songlist_json = songlist_json_root["songs"]
|
|
||||||
results = []
|
|
||||||
for item in songlist_json:
|
|
||||||
song = Song()
|
|
||||||
song.idx = item["idx"]
|
|
||||||
song.id = item["id"]
|
|
||||||
song.title = item["title_localized"]["en"]
|
|
||||||
song.artist = item["artist"]
|
|
||||||
song.bpm = item["bpm"]
|
|
||||||
song.bpm_base = item["bpm_base"]
|
|
||||||
song.set = item["set"]
|
|
||||||
song.audio_preview = item["audioPreview"]
|
|
||||||
song.audio_preview_end = item["audioPreviewEnd"]
|
|
||||||
song.side = item["side"]
|
|
||||||
song.version = item["version"]
|
|
||||||
song.date = item["date"]
|
|
||||||
song.bg = to_db_value(item.get("bg"))
|
|
||||||
song.bg_inverse = to_db_value(item.get("bg_inverse"))
|
|
||||||
if item.get("bg_daynight"):
|
|
||||||
song.bg_day = to_db_value(item["bg_daynight"].get("day"))
|
|
||||||
song.bg_night = to_db_value(item["bg_daynight"].get("night"))
|
|
||||||
if item.get("source_localized"):
|
|
||||||
song.source = item["source_localized"]["en"]
|
|
||||||
song.source_copyright = to_db_value(item.get("source_copyright"))
|
|
||||||
results.append(song)
|
|
||||||
|
|
||||||
if (
|
|
||||||
is_localized(item, "title")
|
|
||||||
or is_localized(item, "search_title", append_localized=False)
|
|
||||||
or is_localized(item, "search_artist", append_localized=False)
|
|
||||||
or is_localized(item, "source")
|
|
||||||
):
|
|
||||||
song_localized = SongLocalized(id=song.id)
|
|
||||||
set_model_localized_attrs(song_localized, item, "title")
|
|
||||||
set_model_localized_attrs(
|
|
||||||
song_localized, item, "search_title", "search_title"
|
|
||||||
)
|
|
||||||
set_model_localized_attrs(
|
|
||||||
song_localized, item, "search_artist", "search_artist"
|
|
||||||
)
|
|
||||||
set_model_localized_attrs(song_localized, item, "source")
|
|
||||||
results.append(song_localized)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
class SonglistDifficultiesParser(ArcaeaParser):
|
|
||||||
def parse(self) -> List[Union[Difficulty, DifficultyLocalized]]:
|
|
||||||
songlist_json_root = json.loads(self.read_file_text())
|
|
||||||
|
|
||||||
songlist_json = songlist_json_root["songs"]
|
|
||||||
results = []
|
|
||||||
for song_item in songlist_json:
|
|
||||||
if not song_item.get("difficulties"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
for item in song_item["difficulties"]:
|
|
||||||
if item["rating"] == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
chart = Difficulty(song_id=song_item["id"])
|
|
||||||
chart.rating_class = item["ratingClass"]
|
|
||||||
chart.rating = item["rating"]
|
|
||||||
chart.rating_plus = item.get("ratingPlus") or False
|
|
||||||
chart.chart_designer = item["chartDesigner"]
|
|
||||||
chart.jacket_desginer = item.get("jacketDesigner") or None
|
|
||||||
chart.audio_override = item.get("audioOverride") or False
|
|
||||||
chart.jacket_override = item.get("jacketOverride") or False
|
|
||||||
chart.jacket_night = item.get("jacketNight") or None
|
|
||||||
chart.title = item.get("title_localized", {}).get("en") or None
|
|
||||||
chart.artist = item.get("artist") or None
|
|
||||||
chart.bg = item.get("bg") or None
|
|
||||||
chart.bg_inverse = item.get("bg_inverse")
|
|
||||||
chart.bpm = item.get("bpm") or None
|
|
||||||
chart.bpm_base = item.get("bpm_base") or None
|
|
||||||
chart.version = item.get("version") or None
|
|
||||||
chart.date = item.get("date") or None
|
|
||||||
results.append(chart)
|
|
||||||
|
|
||||||
if is_localized(item, "title") or is_localized(item, "artist"):
|
|
||||||
chart_localized = DifficultyLocalized(
|
|
||||||
song_id=chart.song_id, rating_class=chart.rating_class
|
|
||||||
)
|
|
||||||
set_model_localized_attrs(chart_localized, item, "title")
|
|
||||||
set_model_localized_attrs(chart_localized, item, "artist")
|
|
||||||
results.append(chart_localized)
|
|
||||||
|
|
||||||
return results
|
|
73
src/arcaea_offline/external/arcaea/st3.py
vendored
73
src/arcaea_offline/external/arcaea/st3.py
vendored
@ -1,73 +0,0 @@
|
|||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ...models.scores import Score
|
|
||||||
from .common import ArcaeaParser, fix_timestamp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class St3ScoreParser(ArcaeaParser):
|
|
||||||
def parse(self) -> List[Score]:
|
|
||||||
items = []
|
|
||||||
with sqlite3.connect(self.filepath) as st3_conn:
|
|
||||||
cursor = st3_conn.cursor()
|
|
||||||
db_scores = cursor.execute(
|
|
||||||
"SELECT songId, songDifficulty, score, perfectCount, nearCount, missCount, "
|
|
||||||
"date, modifier FROM scores"
|
|
||||||
).fetchall()
|
|
||||||
for (
|
|
||||||
song_id,
|
|
||||||
rating_class,
|
|
||||||
score,
|
|
||||||
pure,
|
|
||||||
far,
|
|
||||||
lost,
|
|
||||||
date,
|
|
||||||
modifier,
|
|
||||||
) in db_scores:
|
|
||||||
clear_type = cursor.execute(
|
|
||||||
"SELECT clearType FROM cleartypes WHERE songId = ? AND songDifficulty = ?",
|
|
||||||
(song_id, rating_class),
|
|
||||||
).fetchone()[0]
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
Score(
|
|
||||||
song_id=song_id,
|
|
||||||
rating_class=rating_class,
|
|
||||||
score=score,
|
|
||||||
pure=pure,
|
|
||||||
far=far,
|
|
||||||
lost=lost,
|
|
||||||
date=fix_timestamp(date),
|
|
||||||
modifier=modifier,
|
|
||||||
clear_type=clear_type,
|
|
||||||
comment="Parsed from st3",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
def write_database(self, session: Session, *, skip_duplicate=True):
|
|
||||||
parsed_scores = self.parse()
|
|
||||||
for parsed_score in parsed_scores:
|
|
||||||
query_score = session.scalar(
|
|
||||||
select(Score).where(
|
|
||||||
(Score.song_id == parsed_score.song_id)
|
|
||||||
& (Score.rating_class == parsed_score.rating_class)
|
|
||||||
& (Score.score == parsed_score.score)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if query_score and skip_duplicate:
|
|
||||||
logger.info(
|
|
||||||
"%r skipped because potential duplicate item %r found.",
|
|
||||||
parsed_score,
|
|
||||||
query_score,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
session.add(parsed_score)
|
|
@ -1 +1,3 @@
|
|||||||
from .arcsong_db import ArcsongDbParser
|
from .arcsong_db import ArcsongDbParser
|
||||||
|
|
||||||
|
__all__ = ["ArcsongDbParser"]
|
||||||
|
@ -3,7 +3,7 @@ from typing import List
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ...models.songs import ChartInfo
|
from arcaea_offline.database.models.v4 import ChartInfo
|
||||||
|
|
||||||
|
|
||||||
class ArcsongDbParser:
|
class ArcsongDbParser:
|
||||||
|
@ -5,7 +5,7 @@ from typing import List, Optional, TypedDict
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ...models import (
|
from arcaea_offline.database.models.v4 import (
|
||||||
ChartInfo,
|
ChartInfo,
|
||||||
Difficulty,
|
Difficulty,
|
||||||
DifficultyLocalized,
|
DifficultyLocalized,
|
||||||
|
@ -1 +0,0 @@
|
|||||||
from .parser import ChartInfoDbParser
|
|
@ -1,35 +0,0 @@
|
|||||||
import contextlib
|
|
||||||
import sqlite3
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ...models.songs import ChartInfo
|
|
||||||
|
|
||||||
|
|
||||||
class ChartInfoDbParser:
|
|
||||||
def __init__(self, filepath):
|
|
||||||
self.filepath = filepath
|
|
||||||
|
|
||||||
def parse(self) -> List[ChartInfo]:
|
|
||||||
results = []
|
|
||||||
with sqlite3.connect(self.filepath) as conn:
|
|
||||||
with contextlib.closing(conn.cursor()) as cursor:
|
|
||||||
db_results = cursor.execute(
|
|
||||||
"SELECT song_id, rating_class, constant, notes FROM charts_info"
|
|
||||||
).fetchall()
|
|
||||||
for result in db_results:
|
|
||||||
chart = ChartInfo(
|
|
||||||
song_id=result[0],
|
|
||||||
rating_class=result[1],
|
|
||||||
constant=result[2],
|
|
||||||
notes=result[3] or None,
|
|
||||||
)
|
|
||||||
results.append(chart)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def write_database(self, session: Session):
|
|
||||||
results = self.parse()
|
|
||||||
for result in results:
|
|
||||||
session.merge(result)
|
|
0
src/arcaea_offline/external/exporters/defv2/__init__.py
vendored
Normal file
0
src/arcaea_offline/external/exporters/defv2/__init__.py
vendored
Normal file
@ -1,22 +1,7 @@
|
|||||||
from typing import List, Literal, Optional, TypedDict
|
from typing import List, Literal, Optional, TypedDict
|
||||||
|
|
||||||
|
|
||||||
class ScoreExport(TypedDict):
|
class ArcaeaOfflineDEFv2PlayResultItem(TypedDict, total=False):
|
||||||
id: int
|
|
||||||
song_id: str
|
|
||||||
rating_class: int
|
|
||||||
score: int
|
|
||||||
pure: Optional[int]
|
|
||||||
far: Optional[int]
|
|
||||||
lost: Optional[int]
|
|
||||||
date: Optional[int]
|
|
||||||
max_recall: Optional[int]
|
|
||||||
modifier: Optional[int]
|
|
||||||
clear_type: Optional[int]
|
|
||||||
comment: Optional[str]
|
|
||||||
|
|
||||||
|
|
||||||
class ArcaeaOfflineDEFV2_ScoreItem(TypedDict, total=False):
|
|
||||||
id: Optional[int]
|
id: Optional[int]
|
||||||
songId: str
|
songId: str
|
||||||
ratingClass: int
|
ratingClass: int
|
||||||
@ -32,14 +17,14 @@ class ArcaeaOfflineDEFV2_ScoreItem(TypedDict, total=False):
|
|||||||
comment: Optional[str]
|
comment: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
ArcaeaOfflineDEFV2_Score = TypedDict(
|
ArcaeaOfflineDEFv2PlayResultRoot = TypedDict(
|
||||||
"ArcaeaOfflineDEFV2_Score",
|
"ArcaeaOfflineDEFv2PlayResultRoot",
|
||||||
{
|
{
|
||||||
"$schema": Literal[
|
"$schema": Literal[
|
||||||
"https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json"
|
"https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json"
|
||||||
],
|
],
|
||||||
"type": Literal["score"],
|
"type": Literal["score"],
|
||||||
"version": Literal[2],
|
"version": Literal[2],
|
||||||
"scores": List[ArcaeaOfflineDEFV2_ScoreItem],
|
"scores": List[ArcaeaOfflineDEFv2PlayResultItem],
|
||||||
},
|
},
|
||||||
)
|
)
|
40
src/arcaea_offline/external/exporters/defv2/play_result.py
vendored
Normal file
40
src/arcaea_offline/external/exporters/defv2/play_result.py
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from arcaea_offline.database.models.v5 import PlayResult
|
||||||
|
|
||||||
|
from .definitions import (
|
||||||
|
ArcaeaOfflineDEFv2PlayResultItem,
|
||||||
|
ArcaeaOfflineDEFv2PlayResultRoot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaeaOfflineDEFv2PlayResultExporter:
|
||||||
|
def export(self, items: List[PlayResult]) -> ArcaeaOfflineDEFv2PlayResultRoot:
|
||||||
|
export_items = []
|
||||||
|
for item in items:
|
||||||
|
export_item: ArcaeaOfflineDEFv2PlayResultItem = {}
|
||||||
|
|
||||||
|
export_item["id"] = item.id
|
||||||
|
export_item["songId"] = item.song_id
|
||||||
|
export_item["ratingClass"] = item.rating_class.value
|
||||||
|
export_item["score"] = item.score
|
||||||
|
export_item["pure"] = item.pure
|
||||||
|
export_item["far"] = item.far
|
||||||
|
export_item["lost"] = item.lost
|
||||||
|
export_item["date"] = item.date
|
||||||
|
export_item["maxRecall"] = item.max_recall
|
||||||
|
export_item["modifier"] = (
|
||||||
|
item.modifier.value if item.modifier is not None else None
|
||||||
|
)
|
||||||
|
export_item["clearType"] = (
|
||||||
|
item.clear_type.value if item.clear_type is not None else None
|
||||||
|
)
|
||||||
|
export_item["source"] = "https://arcaeaoffline.sevive.xyz/python"
|
||||||
|
export_item["comment"] = item.comment
|
||||||
|
|
||||||
|
return {
|
||||||
|
"$schema": "https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json",
|
||||||
|
"type": "score",
|
||||||
|
"version": 2,
|
||||||
|
"scores": export_items,
|
||||||
|
}
|
75
src/arcaea_offline/external/exporters/smartrte.py
vendored
Normal file
75
src/arcaea_offline/external/exporters/smartrte.py
vendored
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
|
||||||
|
from arcaea_offline.database.models.v5 import (
|
||||||
|
ChartInfo,
|
||||||
|
Difficulty,
|
||||||
|
PlayResultBest,
|
||||||
|
Song,
|
||||||
|
)
|
||||||
|
from arcaea_offline.utils.formatters.rating_class import RatingClassFormatter
|
||||||
|
|
||||||
|
|
||||||
|
class SmartRteBest30CsvExporter:
|
||||||
|
CSV_ROWS = [
|
||||||
|
"SongName",
|
||||||
|
"SongId",
|
||||||
|
"Difficulty",
|
||||||
|
"Score",
|
||||||
|
"Perfect",
|
||||||
|
"Perfect+",
|
||||||
|
"Far",
|
||||||
|
"Lost",
|
||||||
|
"Constant",
|
||||||
|
"PlayRating",
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rows(cls, session: Session) -> List:
|
||||||
|
results: List[
|
||||||
|
Tuple[str, str, ArcaeaRatingClass, int, int, int, int, int, float]
|
||||||
|
] = (
|
||||||
|
session.query(
|
||||||
|
func.coalesce(Difficulty.title, Song.title),
|
||||||
|
PlayResultBest.song_id,
|
||||||
|
PlayResultBest.rating_class,
|
||||||
|
PlayResultBest.score,
|
||||||
|
PlayResultBest.pure,
|
||||||
|
PlayResultBest.shiny_pure,
|
||||||
|
PlayResultBest.far,
|
||||||
|
PlayResultBest.lost,
|
||||||
|
ChartInfo.constant,
|
||||||
|
PlayResultBest.potential,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
ChartInfo,
|
||||||
|
(ChartInfo.song_id == PlayResultBest.song_id)
|
||||||
|
& (ChartInfo.rating_class == PlayResultBest.rating_class),
|
||||||
|
)
|
||||||
|
.join(Song, (Song.id == PlayResultBest.song_id))
|
||||||
|
.join(
|
||||||
|
Difficulty,
|
||||||
|
(Difficulty.song_id == PlayResultBest.song_id)
|
||||||
|
& (Difficulty.rating_class == PlayResultBest.rating_class),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_rows = []
|
||||||
|
csv_rows.append(cls.CSV_ROWS.copy())
|
||||||
|
for _result in results:
|
||||||
|
result = list(_result)
|
||||||
|
|
||||||
|
# replace the comma in song title because the target project
|
||||||
|
# cannot handle quoted string
|
||||||
|
result[0] = result[0].replace(",", "") # type: ignore
|
||||||
|
result[2] = RatingClassFormatter.name(result[2]) # type: ignore
|
||||||
|
result[-2] = result[-2] / 10 # type: ignore
|
||||||
|
result[-1] = round(result[-1], 5) # type: ignore
|
||||||
|
|
||||||
|
csv_rows.append(result)
|
||||||
|
|
||||||
|
return csv_rows
|
@ -1,2 +0,0 @@
|
|||||||
from . import exporters
|
|
||||||
from .types import ArcaeaOfflineDEFV2_Score, ScoreExport
|
|
37
src/arcaea_offline/external/exports/exporters.py
vendored
37
src/arcaea_offline/external/exports/exporters.py
vendored
@ -1,37 +0,0 @@
|
|||||||
from ...models import Score
|
|
||||||
from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport
|
|
||||||
|
|
||||||
|
|
||||||
def score(score: Score) -> ScoreExport:
|
|
||||||
return {
|
|
||||||
"id": score.id,
|
|
||||||
"song_id": score.song_id,
|
|
||||||
"rating_class": score.rating_class,
|
|
||||||
"score": score.score,
|
|
||||||
"pure": score.pure,
|
|
||||||
"far": score.far,
|
|
||||||
"lost": score.lost,
|
|
||||||
"date": score.date,
|
|
||||||
"max_recall": score.max_recall,
|
|
||||||
"modifier": score.modifier,
|
|
||||||
"clear_type": score.clear_type,
|
|
||||||
"comment": score.comment,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def score_def_v2(score: Score) -> ArcaeaOfflineDEFV2_ScoreItem:
|
|
||||||
return {
|
|
||||||
"id": score.id,
|
|
||||||
"songId": score.song_id,
|
|
||||||
"ratingClass": score.rating_class,
|
|
||||||
"score": score.score,
|
|
||||||
"pure": score.pure,
|
|
||||||
"far": score.far,
|
|
||||||
"lost": score.lost,
|
|
||||||
"date": score.date,
|
|
||||||
"maxRecall": score.max_recall,
|
|
||||||
"modifier": score.modifier,
|
|
||||||
"clearType": score.clear_type,
|
|
||||||
"source": None,
|
|
||||||
"comment": score.comment,
|
|
||||||
}
|
|
10
src/arcaea_offline/external/importers/arcaea/__init__.py
vendored
Normal file
10
src/arcaea_offline/external/importers/arcaea/__init__.py
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from .lists import ArcaeaPacklistParser, ArcaeaSonglistParser
|
||||||
|
from .online import ArcaeaOnlineApiParser
|
||||||
|
from .st3 import ArcaeaSt3Parser
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArcaeaPacklistParser",
|
||||||
|
"ArcaeaSonglistParser",
|
||||||
|
"ArcaeaOnlineApiParser",
|
||||||
|
"ArcaeaSt3Parser",
|
||||||
|
]
|
31
src/arcaea_offline/external/importers/arcaea/common.py
vendored
Normal file
31
src/arcaea_offline/external/importers/arcaea/common.py
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
def fix_timestamp(timestamp: int) -> Union[int, None]:
|
||||||
|
"""
|
||||||
|
Some of the `date` column in st3 are unexpectedly truncated. For example,
|
||||||
|
a `1670283375` may be truncated to `167028`, even a single `1`.
|
||||||
|
|
||||||
|
To properly handle this:
|
||||||
|
|
||||||
|
If `timestamp > 1489017600` (the release date of Arcaea), consider it's ok.
|
||||||
|
|
||||||
|
Otherwise, if the timestamp is 'fixable'
|
||||||
|
(`1489 <= timestamp <= 9999` or `timestamp > 14889`),
|
||||||
|
pad zeros to the end of timestamp.
|
||||||
|
For example, a `1566` will be padded to `1566000000`.
|
||||||
|
|
||||||
|
Otherwise, treat the timestamp as `None`.
|
||||||
|
|
||||||
|
:param timestamp: `date` value
|
||||||
|
"""
|
||||||
|
if timestamp > 1489017600: # noqa: PLR2004
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
timestamp_fixable = 1489 <= timestamp <= 9999 or timestamp > 14889 # noqa: PLR2004
|
||||||
|
if not timestamp_fixable:
|
||||||
|
return None
|
||||||
|
|
||||||
|
timestamp_str = str(timestamp)
|
||||||
|
timestamp_str = timestamp_str.ljust(10, "0")
|
||||||
|
return int(timestamp_str, 10)
|
178
src/arcaea_offline/external/importers/arcaea/lists.py
vendored
Normal file
178
src/arcaea_offline/external/importers/arcaea/lists.py
vendored
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
packlist and songlist parsers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import (
|
||||||
|
ArcaeaLanguage,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
ArcaeaSongSide,
|
||||||
|
)
|
||||||
|
from arcaea_offline.database.models.v5 import (
|
||||||
|
Difficulty,
|
||||||
|
DifficultyLocalized,
|
||||||
|
Pack,
|
||||||
|
PackLocalized,
|
||||||
|
Song,
|
||||||
|
SongLocalized,
|
||||||
|
SongSearchWord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaeaListParser:
|
||||||
|
def __init__(self, list_text: str):
|
||||||
|
self.list_text = list_text
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaeaPacklistParser(ArcaeaListParser):
|
||||||
|
def parse(self) -> List[Union[Pack, PackLocalized]]:
|
||||||
|
root = json.loads(self.list_text)
|
||||||
|
|
||||||
|
packs = root["packs"]
|
||||||
|
results: List[Union[Pack, PackLocalized]] = [
|
||||||
|
Pack(id="single", name="Memory Archive")
|
||||||
|
]
|
||||||
|
for item in packs:
|
||||||
|
pack = Pack()
|
||||||
|
pack.id = item["id"]
|
||||||
|
pack.name = item["name_localized"]["en"]
|
||||||
|
pack.description = item["description_localized"]["en"] or None
|
||||||
|
results.append(pack)
|
||||||
|
|
||||||
|
for key in ArcaeaLanguage:
|
||||||
|
name_localized = item["name_localized"].get(key.value, None)
|
||||||
|
description_localized = item["description_localized"].get(
|
||||||
|
key.value, None
|
||||||
|
)
|
||||||
|
|
||||||
|
if name_localized or description_localized:
|
||||||
|
pack_localized = PackLocalized(id=pack.id)
|
||||||
|
pack_localized.lang = key.value
|
||||||
|
pack_localized.name = name_localized
|
||||||
|
pack_localized.description = description_localized
|
||||||
|
results.append(pack_localized)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaeaSonglistParser(ArcaeaListParser):
|
||||||
|
def parse_songs(self) -> List[Union[Song, SongLocalized, SongSearchWord]]:
|
||||||
|
root = json.loads(self.list_text)
|
||||||
|
|
||||||
|
songs = root["songs"]
|
||||||
|
results = []
|
||||||
|
for item in songs:
|
||||||
|
song = Song()
|
||||||
|
song.idx = item["idx"]
|
||||||
|
song.id = item["id"]
|
||||||
|
song.title = item["title_localized"]["en"]
|
||||||
|
song.artist = item["artist"]
|
||||||
|
song.bpm = item["bpm"]
|
||||||
|
song.bpm_base = item["bpm_base"]
|
||||||
|
song.pack_id = item["set"]
|
||||||
|
song.audio_preview = item["audioPreview"]
|
||||||
|
song.audio_preview_end = item["audioPreviewEnd"]
|
||||||
|
song.side = ArcaeaSongSide(item["side"])
|
||||||
|
song.version = item["version"]
|
||||||
|
song.date = item["date"]
|
||||||
|
song.bg = item.get("bg")
|
||||||
|
song.bg_inverse = item.get("bg_inverse")
|
||||||
|
if item.get("bg_daynight"):
|
||||||
|
song.bg_day = item["bg_daynight"].get("day")
|
||||||
|
song.bg_night = item["bg_daynight"].get("night")
|
||||||
|
if item.get("source_localized"):
|
||||||
|
song.source = item["source_localized"]["en"]
|
||||||
|
song.source_copyright = item.get("source_copyright")
|
||||||
|
results.append(song)
|
||||||
|
|
||||||
|
for lang in ArcaeaLanguage:
|
||||||
|
# SongLocalized objects
|
||||||
|
title_localized = item["title_localized"].get(lang.value, None)
|
||||||
|
source_localized = item.get("source_localized", {}).get(
|
||||||
|
lang.value, None
|
||||||
|
)
|
||||||
|
|
||||||
|
if title_localized or source_localized:
|
||||||
|
song_localized = SongLocalized(id=song.id)
|
||||||
|
song_localized.lang = lang.value
|
||||||
|
song_localized.title = title_localized
|
||||||
|
song_localized.source = source_localized
|
||||||
|
results.append(song_localized)
|
||||||
|
|
||||||
|
# SongSearchTitle
|
||||||
|
search_titles = item.get("search_title", {}).get(lang.value, None)
|
||||||
|
if search_titles:
|
||||||
|
for search_title in search_titles:
|
||||||
|
song_search_word = SongSearchWord(
|
||||||
|
id=song.id, lang=lang.value, type=1, value=search_title
|
||||||
|
)
|
||||||
|
results.append(song_search_word)
|
||||||
|
|
||||||
|
search_artists = item.get("search_artist", {}).get(lang.value, None)
|
||||||
|
if search_artists:
|
||||||
|
for search_artist in search_artists:
|
||||||
|
song_search_word = SongSearchWord(
|
||||||
|
id=song.id, lang=lang.value, type=2, value=search_artist
|
||||||
|
)
|
||||||
|
results.append(song_search_word)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def parse_difficulties(self) -> List[Union[Difficulty, DifficultyLocalized]]:
|
||||||
|
root = json.loads(self.list_text)
|
||||||
|
|
||||||
|
songs = root["songs"]
|
||||||
|
results = []
|
||||||
|
for song in songs:
|
||||||
|
difficulties = song.get("difficulties")
|
||||||
|
if not difficulties:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in difficulties:
|
||||||
|
if item["rating"] == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
difficulty = Difficulty()
|
||||||
|
difficulty.song_id = song["id"]
|
||||||
|
difficulty.rating_class = ArcaeaRatingClass(item["ratingClass"])
|
||||||
|
difficulty.rating = item["rating"]
|
||||||
|
difficulty.rating_plus = item.get("ratingPlus") or False
|
||||||
|
difficulty.chart_designer = item["chartDesigner"]
|
||||||
|
difficulty.jacket_desginer = item.get("jacketDesigner") or None
|
||||||
|
difficulty.audio_override = item.get("audioOverride") or False
|
||||||
|
difficulty.jacket_override = item.get("jacketOverride") or False
|
||||||
|
difficulty.jacket_night = item.get("jacketNight") or None
|
||||||
|
difficulty.title = item.get("title_localized", {}).get("en") or None
|
||||||
|
difficulty.artist = item.get("artist") or None
|
||||||
|
difficulty.bg = item.get("bg") or None
|
||||||
|
difficulty.bg_inverse = item.get("bg_inverse")
|
||||||
|
difficulty.bpm = item.get("bpm") or None
|
||||||
|
difficulty.bpm_base = item.get("bpm_base") or None
|
||||||
|
difficulty.version = item.get("version") or None
|
||||||
|
difficulty.date = item.get("date") or None
|
||||||
|
results.append(difficulty)
|
||||||
|
|
||||||
|
for lang in ArcaeaLanguage:
|
||||||
|
title_localized = item.get("title_localized", {}).get(
|
||||||
|
lang.value, None
|
||||||
|
)
|
||||||
|
artist_localized = item.get("artist_localized", {}).get(
|
||||||
|
lang.value, None
|
||||||
|
)
|
||||||
|
|
||||||
|
if title_localized or artist_localized:
|
||||||
|
difficulty_localized = DifficultyLocalized(
|
||||||
|
song_id=difficulty.song_id,
|
||||||
|
rating_class=difficulty.rating_class,
|
||||||
|
)
|
||||||
|
difficulty_localized.lang = lang.value
|
||||||
|
difficulty_localized.title = title_localized
|
||||||
|
difficulty_localized.artist = artist_localized
|
||||||
|
results.append(difficulty_localized)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def parse_all(self):
|
||||||
|
return self.parse_songs() + self.parse_difficulties()
|
97
src/arcaea_offline/external/importers/arcaea/online.py
vendored
Normal file
97
src/arcaea_offline/external/importers/arcaea/online.py
vendored
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Literal, Optional, TypedDict
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import (
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
)
|
||||||
|
from arcaea_offline.database.models.v5 import PlayResult
|
||||||
|
|
||||||
|
from .common import fix_timestamp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _RatingMePlayResultItem(TypedDict):
|
||||||
|
song_id: str
|
||||||
|
difficulty: int
|
||||||
|
modifier: int
|
||||||
|
rating: float
|
||||||
|
score: int
|
||||||
|
perfect_count: int
|
||||||
|
near_count: int
|
||||||
|
miss_count: int
|
||||||
|
clear_type: int
|
||||||
|
title: Dict[Literal["ja", "en"], str]
|
||||||
|
artist: str
|
||||||
|
time_played: int
|
||||||
|
bg: str
|
||||||
|
|
||||||
|
|
||||||
|
class _RatingMeValue(TypedDict):
|
||||||
|
best_rated_scores: List[_RatingMePlayResultItem]
|
||||||
|
recent_rated_scores: List[_RatingMePlayResultItem]
|
||||||
|
|
||||||
|
|
||||||
|
class _RatingMeResponse(TypedDict):
|
||||||
|
success: bool
|
||||||
|
error_code: Optional[int]
|
||||||
|
value: Optional[_RatingMeValue]
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaeaOnlineApiParser:
|
||||||
|
def __init__(self, api_result_text: str):
|
||||||
|
self.api_result_text = api_result_text
|
||||||
|
self.api_result: _RatingMeResponse = json.loads(api_result_text)
|
||||||
|
|
||||||
|
def parse(self) -> List[PlayResult]:
|
||||||
|
api_result_value = self.api_result.get("value")
|
||||||
|
if not api_result_value:
|
||||||
|
error_code = self.api_result.get("error_code")
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot parse Arcaea Online API result, error code {error_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
best30_items = api_result_value.get("best_rated_scores", [])
|
||||||
|
recent_items = api_result_value.get("recent_rated_scores", [])
|
||||||
|
items = best30_items + recent_items
|
||||||
|
|
||||||
|
date_text = (
|
||||||
|
datetime.now(tz=timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||||
|
)
|
||||||
|
|
||||||
|
results: List[PlayResult] = []
|
||||||
|
results_time_played = []
|
||||||
|
for item in items:
|
||||||
|
date_millis = fix_timestamp(item["time_played"])
|
||||||
|
|
||||||
|
if date_millis in results_time_played:
|
||||||
|
# filter out duplicate play results
|
||||||
|
continue
|
||||||
|
|
||||||
|
if date_millis:
|
||||||
|
date = datetime.fromtimestamp(date_millis / 1000).astimezone()
|
||||||
|
results_time_played.append(date_millis)
|
||||||
|
else:
|
||||||
|
date = None
|
||||||
|
|
||||||
|
play_result = PlayResult()
|
||||||
|
play_result.song_id = item["song_id"]
|
||||||
|
play_result.rating_class = ArcaeaRatingClass(item["difficulty"])
|
||||||
|
play_result.score = item["score"]
|
||||||
|
play_result.pure = item["perfect_count"]
|
||||||
|
play_result.far = item["near_count"]
|
||||||
|
play_result.lost = item["miss_count"]
|
||||||
|
play_result.date = date
|
||||||
|
play_result.modifier = ArcaeaPlayResultModifier(item["modifier"])
|
||||||
|
play_result.clear_type = ArcaeaPlayResultClearType(item["clear_type"])
|
||||||
|
|
||||||
|
if play_result.lost == 0:
|
||||||
|
play_result.max_recall = play_result.pure + play_result.far
|
||||||
|
|
||||||
|
play_result.comment = f"Parsed from web API at {date_text}"
|
||||||
|
results.append(play_result)
|
||||||
|
return results
|
118
src/arcaea_offline/external/importers/arcaea/st3.py
vendored
Normal file
118
src/arcaea_offline/external/importers/arcaea/st3.py
vendored
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Game database play results importer
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, overload
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import (
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
)
|
||||||
|
from arcaea_offline.database.models.v5 import PlayResult
|
||||||
|
|
||||||
|
from .common import fix_timestamp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaeaSt3Parser:
|
||||||
|
@classmethod
|
||||||
|
@overload
|
||||||
|
def parse(cls, db: sqlite3.Connection) -> List[PlayResult]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@overload
|
||||||
|
def parse(cls, db: sqlite3.Cursor) -> List[PlayResult]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, db) -> List[PlayResult]:
|
||||||
|
if isinstance(db, sqlite3.Connection):
|
||||||
|
return cls.parse(db.cursor())
|
||||||
|
|
||||||
|
if not isinstance(db, sqlite3.Cursor):
|
||||||
|
raise TypeError(
|
||||||
|
"Unknown overload of `db`. Expected `sqlite3.Connection` or `sqlite3.Cursor`."
|
||||||
|
)
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
query_results = db.execute("""
|
||||||
|
SELECT s.id AS _id, s.songId, s.songDifficulty AS ratingClass, s.score,
|
||||||
|
s.perfectCount AS pure, s.nearCount AS far, s.missCount AS lost,
|
||||||
|
s.`date`, s.modifier, ct.clearType
|
||||||
|
FROM scores s JOIN cleartypes ct
|
||||||
|
ON s.songId = ct.songId AND s.songDifficulty = ct.songDifficulty""")
|
||||||
|
# maybe `s.id = ct.id`?
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
import_comment = (
|
||||||
|
f"Imported from st3 at {now.astimezone().isoformat(timespec='seconds')}"
|
||||||
|
)
|
||||||
|
for result in query_results:
|
||||||
|
(
|
||||||
|
_id,
|
||||||
|
song_id,
|
||||||
|
rating_class,
|
||||||
|
score,
|
||||||
|
pure,
|
||||||
|
far,
|
||||||
|
lost,
|
||||||
|
date,
|
||||||
|
modifier,
|
||||||
|
clear_type,
|
||||||
|
) = result
|
||||||
|
|
||||||
|
try:
|
||||||
|
rating_class_enum = ArcaeaRatingClass(rating_class)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown rating class [%r] at entry id %d, skipping!",
|
||||||
|
rating_class,
|
||||||
|
_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
clear_type_enum = ArcaeaPlayResultClearType(clear_type)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown clear type [%r] at entry id %d, falling back to `None`!",
|
||||||
|
clear_type,
|
||||||
|
_id,
|
||||||
|
)
|
||||||
|
clear_type_enum = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
modifier_enum = ArcaeaPlayResultModifier(modifier)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Unknown modifier [%r] at entry id %d, falling back to `None`!",
|
||||||
|
modifier,
|
||||||
|
_id,
|
||||||
|
)
|
||||||
|
modifier_enum = None
|
||||||
|
|
||||||
|
if date := fix_timestamp(date):
|
||||||
|
date = datetime.fromtimestamp(date).astimezone()
|
||||||
|
else:
|
||||||
|
date = None
|
||||||
|
|
||||||
|
entities.append(
|
||||||
|
PlayResult(
|
||||||
|
song_id=song_id,
|
||||||
|
rating_class=rating_class_enum,
|
||||||
|
score=score,
|
||||||
|
pure=pure,
|
||||||
|
far=far,
|
||||||
|
lost=lost,
|
||||||
|
date=date,
|
||||||
|
modifier=modifier_enum,
|
||||||
|
clear_type=clear_type_enum,
|
||||||
|
comment=import_comment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return entities
|
42
src/arcaea_offline/external/importers/chart_info_database.py
vendored
Normal file
42
src/arcaea_offline/external/importers/chart_info_database.py
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from typing import List, overload
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
|
||||||
|
from arcaea_offline.database.models.v5 import ChartInfo
|
||||||
|
|
||||||
|
|
||||||
|
class ChartInfoDatabaseParser:
|
||||||
|
@classmethod
|
||||||
|
@overload
|
||||||
|
def parse(cls, conn: sqlite3.Connection) -> List[ChartInfo]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@overload
|
||||||
|
def parse(cls, conn: sqlite3.Cursor) -> List[ChartInfo]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, conn) -> List[ChartInfo]:
|
||||||
|
if isinstance(conn, sqlite3.Connection):
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
return cls.parse(cur)
|
||||||
|
|
||||||
|
if not isinstance(conn, sqlite3.Cursor):
|
||||||
|
raise ValueError("conn must be sqlite3.Connection or sqlite3.Cursor!")
|
||||||
|
|
||||||
|
db_items = conn.execute(
|
||||||
|
"SELECT song_id, rating_class, constant, notes FROM charts_info"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
results: List[ChartInfo] = []
|
||||||
|
for item in db_items:
|
||||||
|
(song_id, rating_class, constant, notes) = item
|
||||||
|
|
||||||
|
chart_info = ChartInfo()
|
||||||
|
chart_info.song_id = song_id
|
||||||
|
chart_info.rating_class = ArcaeaRatingClass(rating_class)
|
||||||
|
chart_info.constant = constant
|
||||||
|
chart_info.notes = notes
|
||||||
|
|
||||||
|
results.append(chart_info)
|
||||||
|
return results
|
@ -1 +0,0 @@
|
|||||||
from .b30_csv import SmartRteB30CsvConverter
|
|
64
src/arcaea_offline/external/smartrte/b30_csv.py
vendored
64
src/arcaea_offline/external/smartrte/b30_csv.py
vendored
@ -1,64 +0,0 @@
|
|||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ...models import Chart, ScoreBest
|
|
||||||
from ...utils.rating import rating_class_to_text
|
|
||||||
|
|
||||||
|
|
||||||
class SmartRteB30CsvConverter:
|
|
||||||
CSV_ROWS = [
|
|
||||||
"songname",
|
|
||||||
"songId",
|
|
||||||
"Difficulty",
|
|
||||||
"score",
|
|
||||||
"Perfect",
|
|
||||||
"criticalPerfect",
|
|
||||||
"Far",
|
|
||||||
"Lost",
|
|
||||||
"Constant",
|
|
||||||
"singlePTT",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: Session,
|
|
||||||
):
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
def rows(self) -> list:
|
|
||||||
csv_rows = [self.CSV_ROWS.copy()]
|
|
||||||
|
|
||||||
with self.session as session:
|
|
||||||
results = (
|
|
||||||
session.query(
|
|
||||||
Chart.title,
|
|
||||||
ScoreBest.song_id,
|
|
||||||
ScoreBest.rating_class,
|
|
||||||
ScoreBest.score,
|
|
||||||
ScoreBest.pure,
|
|
||||||
ScoreBest.shiny_pure,
|
|
||||||
ScoreBest.far,
|
|
||||||
ScoreBest.lost,
|
|
||||||
Chart.constant,
|
|
||||||
ScoreBest.potential,
|
|
||||||
)
|
|
||||||
.join(
|
|
||||||
Chart,
|
|
||||||
(Chart.song_id == ScoreBest.song_id)
|
|
||||||
& (Chart.rating_class == ScoreBest.rating_class),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
# replace the comma in song title because the target project
|
|
||||||
# cannot handle quoted string
|
|
||||||
result = list(result)
|
|
||||||
result[0] = result[0].replace(",", "")
|
|
||||||
result[2] = rating_class_to_text(result[2])
|
|
||||||
# divide constant to float
|
|
||||||
result[-2] = result[-2] / 10
|
|
||||||
# round potential
|
|
||||||
result[-1] = round(result[-1], 5)
|
|
||||||
csv_rows.append(result)
|
|
||||||
|
|
||||||
return csv_rows
|
|
@ -1,111 +0,0 @@
|
|||||||
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]
|
|
7
src/arcaea_offline/utils/formatters/__init__.py
Normal file
7
src/arcaea_offline/utils/formatters/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from .play_result import PlayResultFormatter
|
||||||
|
from .rating_class import RatingClassFormatter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlayResultFormatter",
|
||||||
|
"RatingClassFormatter",
|
||||||
|
]
|
143
src/arcaea_offline/utils/formatters/play_result.py
Normal file
143
src/arcaea_offline/utils/formatters/play_result.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
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")
|
79
src/arcaea_offline/utils/formatters/rating_class.py
Normal file
79
src/arcaea_offline/utils/formatters/rating_class.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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")
|
@ -1,25 +0,0 @@
|
|||||||
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)
|
|
@ -1,46 +0,0 @@
|
|||||||
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]
|
|
@ -1,6 +0,0 @@
|
|||||||
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 []
|
|
@ -1,22 +0,0 @@
|
|||||||
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")
|
|
42
tests/calculators/test_play_result.py
Normal file
42
tests/calculators/test_play_result.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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)
|
74
tests/calculators/test_world.py
Normal file
74
tests/calculators/test_world.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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")
|
53
tests/conftest.py
Normal file
53
tests/conftest.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# region sqlalchemy fixtures
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Session = sessionmaker()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def db_conn():
|
||||||
|
conn = engine.connect()
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_session(db_conn):
|
||||||
|
session = Session(bind=db_conn)
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
# drop everything
|
||||||
|
query_tables = db_conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
).fetchall()
|
||||||
|
for row in query_tables:
|
||||||
|
table_name = row[0]
|
||||||
|
db_conn.execute(text(f"DROP TABLE {table_name}"))
|
||||||
|
|
||||||
|
query_views = db_conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='view'")
|
||||||
|
).fetchall()
|
||||||
|
for row in query_views:
|
||||||
|
view_name = row[0]
|
||||||
|
db_conn.execute(text(f"DROP VIEW {view_name}"))
|
||||||
|
|
||||||
|
query_indexes = db_conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
).fetchall()
|
||||||
|
for row in query_indexes:
|
||||||
|
index_name = row[0]
|
||||||
|
db_conn.execute(text(f"DROP INDEX {index_name}"))
|
||||||
|
|
||||||
|
query_triggers = db_conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||||
|
).fetchall()
|
||||||
|
for row in query_triggers:
|
||||||
|
trigger_name = row[0]
|
||||||
|
db_conn.execute(text(f"DROP TRIGGER {trigger_name}"))
|
||||||
|
|
||||||
|
|
||||||
|
# endregion
|
@ -1,5 +0,0 @@
|
|||||||
from sqlalchemy import Engine, create_engine, inspect
|
|
||||||
|
|
||||||
|
|
||||||
def create_engine_in_memory():
|
|
||||||
return create_engine("sqlite:///:memory:")
|
|
67
tests/db/models/test_custom_types.py
Normal file
67
tests/db/models/test_custom_types.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from arcaea_offline.database.models._custom_types import DbIntEnum, TZDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntEnum(IntEnum):
|
||||||
|
__test__ = False
|
||||||
|
|
||||||
|
ONE = 1
|
||||||
|
TWO = 2
|
||||||
|
THREE = 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(DeclarativeBase):
|
||||||
|
__test__ = False
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IntEnumTestModel(TestBase):
|
||||||
|
__tablename__ = "test_int_enum"
|
||||||
|
value: Mapped[Optional[TestIntEnum]] = mapped_column(DbIntEnum(TestIntEnum))
|
||||||
|
|
||||||
|
|
||||||
|
class TZDatetimeTestModel(TestBase):
|
||||||
|
__tablename__ = "test_tz_datetime"
|
||||||
|
value: Mapped[Optional[datetime]] = mapped_column(TZDateTime)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomTypes:
|
||||||
|
def test_int_enum(self, db_session):
|
||||||
|
def _query_value(_id: int):
|
||||||
|
return db_session.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT value FROM {IntEnumTestModel.__tablename__} WHERE id = {_id}"
|
||||||
|
)
|
||||||
|
).one()[0]
|
||||||
|
|
||||||
|
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
|
||||||
|
|
||||||
|
basic_obj = IntEnumTestModel(id=1, value=TestIntEnum.TWO)
|
||||||
|
null_obj = IntEnumTestModel(id=2, value=None)
|
||||||
|
db_session.add(basic_obj)
|
||||||
|
db_session.add(null_obj)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert _query_value(1) == TestIntEnum.TWO.value
|
||||||
|
assert _query_value(2) is None
|
||||||
|
|
||||||
|
def test_tz_datetime(self, db_session):
|
||||||
|
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
|
||||||
|
|
||||||
|
dt1 = datetime.now(tz=timezone(timedelta(hours=8)))
|
||||||
|
|
||||||
|
basic_obj = TZDatetimeTestModel(id=1, value=dt1)
|
||||||
|
null_obj = TZDatetimeTestModel(id=2, value=None)
|
||||||
|
db_session.add(basic_obj)
|
||||||
|
db_session.add(null_obj)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert basic_obj.value == dt1
|
||||||
|
assert null_obj.value is None
|
@ -1,118 +0,0 @@
|
|||||||
from sqlalchemy import Engine
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from arcaea_offline.models.songs import (
|
|
||||||
Chart,
|
|
||||||
ChartInfo,
|
|
||||||
Difficulty,
|
|
||||||
Pack,
|
|
||||||
Song,
|
|
||||||
SongsBase,
|
|
||||||
SongsViewBase,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..db import create_engine_in_memory
|
|
||||||
|
|
||||||
|
|
||||||
def _song(**kw):
|
|
||||||
defaults = {"artist": "test"}
|
|
||||||
defaults.update(kw)
|
|
||||||
return Song(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def _difficulty(**kw):
|
|
||||||
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
|
|
||||||
defaults.update(kw)
|
|
||||||
return Difficulty(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
class Test_Chart:
|
|
||||||
def init_db(self, engine: Engine):
|
|
||||||
SongsBase.metadata.create_all(engine)
|
|
||||||
SongsViewBase.metadata.create_all(engine)
|
|
||||||
|
|
||||||
def db(self):
|
|
||||||
db = create_engine_in_memory()
|
|
||||||
self.init_db(db)
|
|
||||||
return db
|
|
||||||
|
|
||||||
def test_chart_info(self):
|
|
||||||
pre_entites = [
|
|
||||||
Pack(id="test", name="Test Pack"),
|
|
||||||
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
|
|
||||||
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
|
|
||||||
_song(idx=2, id="song2", set="test", title="No Chart Info"),
|
|
||||||
_difficulty(song_id="song0", rating_class=2, rating=9),
|
|
||||||
_difficulty(song_id="song1", rating_class=2, rating=9),
|
|
||||||
_difficulty(song_id="song2", rating_class=2, rating=9),
|
|
||||||
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
|
|
||||||
ChartInfo(song_id="song1", rating_class=2, constant=90),
|
|
||||||
]
|
|
||||||
|
|
||||||
db = self.db()
|
|
||||||
with Session(db) as session:
|
|
||||||
session.add_all(pre_entites)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
chart_song0_ratingclass2 = (
|
|
||||||
session.query(Chart)
|
|
||||||
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert chart_song0_ratingclass2.constant == 90
|
|
||||||
assert chart_song0_ratingclass2.notes == 1234
|
|
||||||
|
|
||||||
chart_song1_ratingclass2 = (
|
|
||||||
session.query(Chart)
|
|
||||||
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert chart_song1_ratingclass2.constant == 90
|
|
||||||
assert chart_song1_ratingclass2.notes is None
|
|
||||||
|
|
||||||
chart_song2_ratingclass2 = (
|
|
||||||
session.query(Chart)
|
|
||||||
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert chart_song2_ratingclass2 is None
|
|
||||||
|
|
||||||
def test_difficulty_title_override(self):
|
|
||||||
pre_entites = [
|
|
||||||
Pack(id="test", name="Test Pack"),
|
|
||||||
_song(idx=0, id="test", set="test", title="Test"),
|
|
||||||
_difficulty(song_id="test", rating_class=0, rating=2),
|
|
||||||
_difficulty(song_id="test", rating_class=1, rating=5),
|
|
||||||
_difficulty(song_id="test", rating_class=2, rating=8),
|
|
||||||
_difficulty(
|
|
||||||
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
|
|
||||||
),
|
|
||||||
ChartInfo(song_id="test", rating_class=0, constant=10),
|
|
||||||
ChartInfo(song_id="test", rating_class=1, constant=10),
|
|
||||||
ChartInfo(song_id="test", rating_class=2, constant=10),
|
|
||||||
ChartInfo(song_id="test", rating_class=3, constant=10),
|
|
||||||
]
|
|
||||||
|
|
||||||
db = self.db()
|
|
||||||
with Session(db) as session:
|
|
||||||
session.add_all(pre_entites)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
charts_original_title = (
|
|
||||||
session.query(Chart)
|
|
||||||
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert all(chart.title == "Test" for chart in charts_original_title)
|
|
||||||
|
|
||||||
chart_overrided_title = (
|
|
||||||
session.query(Chart)
|
|
||||||
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert chart_overrided_title.title == "TEST ~REVIVE~"
|
|
0
tests/db/models/v4/__init__.py
Normal file
0
tests/db/models/v4/__init__.py
Normal file
108
tests/db/models/v4/test_songs.py
Normal file
108
tests/db/models/v4/test_songs.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
from arcaea_offline.database.models.v4.songs import (
|
||||||
|
Chart,
|
||||||
|
ChartInfo,
|
||||||
|
Difficulty,
|
||||||
|
Pack,
|
||||||
|
Song,
|
||||||
|
SongsBase,
|
||||||
|
SongsViewBase,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _song(**kw):
|
||||||
|
defaults = {"artist": "test"}
|
||||||
|
defaults.update(kw)
|
||||||
|
return Song(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _difficulty(**kw):
|
||||||
|
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
|
||||||
|
defaults.update(kw)
|
||||||
|
return Difficulty(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class Test_Chart:
|
||||||
|
def init_db(self, session):
|
||||||
|
SongsBase.metadata.create_all(session.bind, checkfirst=False)
|
||||||
|
SongsViewBase.metadata.create_all(session.bind, checkfirst=False)
|
||||||
|
|
||||||
|
def test_chart_info(self, db_session):
|
||||||
|
self.init_db(db_session)
|
||||||
|
|
||||||
|
pre_entites = [
|
||||||
|
Pack(id="test", name="Test Pack"),
|
||||||
|
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
|
||||||
|
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
|
||||||
|
_song(idx=2, id="song2", set="test", title="No Chart Info"),
|
||||||
|
_difficulty(song_id="song0", rating_class=2, rating=9),
|
||||||
|
_difficulty(song_id="song1", rating_class=2, rating=9),
|
||||||
|
_difficulty(song_id="song2", rating_class=2, rating=9),
|
||||||
|
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
|
||||||
|
ChartInfo(song_id="song1", rating_class=2, constant=90),
|
||||||
|
]
|
||||||
|
|
||||||
|
db_session.add_all(pre_entites)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
chart_song0_ratingclass2 = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chart_song0_ratingclass2.constant == 90
|
||||||
|
assert chart_song0_ratingclass2.notes == 1234
|
||||||
|
|
||||||
|
chart_song1_ratingclass2 = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chart_song1_ratingclass2.constant == 90
|
||||||
|
assert chart_song1_ratingclass2.notes is None
|
||||||
|
|
||||||
|
chart_song2_ratingclass2 = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chart_song2_ratingclass2 is None
|
||||||
|
|
||||||
|
def test_difficulty_title_override(self, db_session):
|
||||||
|
self.init_db(db_session)
|
||||||
|
|
||||||
|
pre_entites = [
|
||||||
|
Pack(id="test", name="Test Pack"),
|
||||||
|
_song(idx=0, id="test", set="test", title="Test"),
|
||||||
|
_difficulty(song_id="test", rating_class=0, rating=2),
|
||||||
|
_difficulty(song_id="test", rating_class=1, rating=5),
|
||||||
|
_difficulty(song_id="test", rating_class=2, rating=8),
|
||||||
|
_difficulty(
|
||||||
|
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
|
||||||
|
),
|
||||||
|
ChartInfo(song_id="test", rating_class=0, constant=10),
|
||||||
|
ChartInfo(song_id="test", rating_class=1, constant=10),
|
||||||
|
ChartInfo(song_id="test", rating_class=2, constant=10),
|
||||||
|
ChartInfo(song_id="test", rating_class=3, constant=10),
|
||||||
|
]
|
||||||
|
|
||||||
|
db_session.add_all(pre_entites)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
charts_original_title = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(chart.title == "Test" for chart in charts_original_title)
|
||||||
|
|
||||||
|
chart_overrided_title = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chart_overrided_title.title == "TEST ~REVIVE~"
|
0
tests/db/models/v5/__init__.py
Normal file
0
tests/db/models/v5/__init__.py
Normal file
0
tests/db/models/v5/relationships/__init__.py
Normal file
0
tests/db/models/v5/relationships/__init__.py
Normal file
143
tests/db/models/v5/relationships/test_common.py
Normal file
143
tests/db/models/v5/relationships/test_common.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Database model v5 common relationships
|
||||||
|
|
||||||
|
┌──────┐ ┌──────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ Pack ◄───► Song ◄───► Difficulty ◄───┤ PlayResult │
|
||||||
|
└──────┘ └──┬───┘ └─────▲──────┘ └────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────▼─────┐
|
||||||
|
└───────► ChartInfo │
|
||||||
|
└───────────┘
|
||||||
|
"""
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import ArcaeaRatingClass
|
||||||
|
from arcaea_offline.database.models.v5 import (
|
||||||
|
ChartInfo,
|
||||||
|
Difficulty,
|
||||||
|
ModelsV5Base,
|
||||||
|
Pack,
|
||||||
|
PlayResult,
|
||||||
|
Song,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSongRelationships:
|
||||||
|
@staticmethod
|
||||||
|
def init_db(session):
|
||||||
|
ModelsV5Base.metadata.create_all(session.bind)
|
||||||
|
|
||||||
|
def test_relationships(self, db_session):
|
||||||
|
self.init_db(db_session)
|
||||||
|
|
||||||
|
song_id = "test_song"
|
||||||
|
title_en = "Test Lorem Ipsum"
|
||||||
|
artist_en = "Test Artist"
|
||||||
|
|
||||||
|
pack = Pack(
|
||||||
|
id="test_pack",
|
||||||
|
name="Test Pack",
|
||||||
|
description="This is a test pack.",
|
||||||
|
)
|
||||||
|
|
||||||
|
song = Song(
|
||||||
|
idx=1,
|
||||||
|
id=song_id,
|
||||||
|
title=title_en,
|
||||||
|
artist=artist_en,
|
||||||
|
pack_id=pack.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
difficulty_pst = Difficulty(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.PAST,
|
||||||
|
rating=2,
|
||||||
|
rating_plus=False,
|
||||||
|
)
|
||||||
|
chart_info_pst = ChartInfo(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.PAST,
|
||||||
|
constant=20,
|
||||||
|
notes=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
difficulty_prs = Difficulty(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.PRESENT,
|
||||||
|
rating=7,
|
||||||
|
rating_plus=True,
|
||||||
|
)
|
||||||
|
chart_info_prs = ChartInfo(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.PRESENT,
|
||||||
|
constant=78,
|
||||||
|
notes=780,
|
||||||
|
)
|
||||||
|
|
||||||
|
difficulty_ftr = Difficulty(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.FUTURE,
|
||||||
|
rating=10,
|
||||||
|
rating_plus=True,
|
||||||
|
)
|
||||||
|
chart_info_ftr = ChartInfo(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.FUTURE,
|
||||||
|
constant=109,
|
||||||
|
notes=1090,
|
||||||
|
)
|
||||||
|
|
||||||
|
difficulty_etr = Difficulty(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.ETERNAL,
|
||||||
|
rating=9,
|
||||||
|
rating_plus=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
play_result_ftr = PlayResult(
|
||||||
|
song_id=song.id,
|
||||||
|
rating_class=ArcaeaRatingClass.FUTURE,
|
||||||
|
score=123456,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
pack,
|
||||||
|
song,
|
||||||
|
difficulty_pst,
|
||||||
|
chart_info_pst,
|
||||||
|
difficulty_prs,
|
||||||
|
chart_info_prs,
|
||||||
|
difficulty_ftr,
|
||||||
|
chart_info_ftr,
|
||||||
|
difficulty_etr,
|
||||||
|
play_result_ftr,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert pack.songs == [song]
|
||||||
|
|
||||||
|
assert song.pack == pack
|
||||||
|
assert song.difficulties == [
|
||||||
|
difficulty_pst,
|
||||||
|
difficulty_prs,
|
||||||
|
difficulty_ftr,
|
||||||
|
difficulty_etr,
|
||||||
|
]
|
||||||
|
assert song.charts_info == [chart_info_pst, chart_info_prs, chart_info_ftr]
|
||||||
|
|
||||||
|
assert difficulty_pst.song == song
|
||||||
|
assert difficulty_prs.song == song
|
||||||
|
assert difficulty_ftr.song == song
|
||||||
|
assert difficulty_etr.song == song
|
||||||
|
|
||||||
|
assert difficulty_pst.chart_info == chart_info_pst
|
||||||
|
assert difficulty_prs.chart_info == chart_info_prs
|
||||||
|
assert difficulty_ftr.chart_info == chart_info_ftr
|
||||||
|
assert difficulty_etr.chart_info is None
|
||||||
|
|
||||||
|
assert chart_info_pst.difficulty == difficulty_pst
|
||||||
|
assert chart_info_prs.difficulty == difficulty_prs
|
||||||
|
assert chart_info_ftr.difficulty == difficulty_ftr
|
||||||
|
|
||||||
|
assert play_result_ftr.difficulty == difficulty_ftr
|
69
tests/db/models/v5/relationships/test_pack.py
Normal file
69
tests/db/models/v5/relationships/test_pack.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Database model v5 relationships
|
||||||
|
|
||||||
|
Pack <> PackLocalized
|
||||||
|
"""
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums import ArcaeaLanguage
|
||||||
|
from arcaea_offline.database.models.v5 import ModelsV5Base, Pack, PackLocalized
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackRelationships:
|
||||||
|
@staticmethod
|
||||||
|
def init_db(session):
|
||||||
|
ModelsV5Base.metadata.create_all(session.bind)
|
||||||
|
|
||||||
|
def test_localized_objects(self, db_session):
|
||||||
|
self.init_db(db_session)
|
||||||
|
|
||||||
|
pack_id = "test_pack"
|
||||||
|
name_en = "Test Pack"
|
||||||
|
description_en = "Travel through common database models\nfrom the unpopular framework 'Arcaea Offline'\ntogether with an ordinary partner '∅'."
|
||||||
|
|
||||||
|
pack = Pack(
|
||||||
|
id=pack_id,
|
||||||
|
name=name_en,
|
||||||
|
description=description_en,
|
||||||
|
)
|
||||||
|
|
||||||
|
pkid_ja = 1
|
||||||
|
description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。"
|
||||||
|
pack_localized_ja = PackLocalized(
|
||||||
|
pkid=pkid_ja,
|
||||||
|
id=pack_id,
|
||||||
|
lang=ArcaeaLanguage.JA.value,
|
||||||
|
name=None,
|
||||||
|
description=description_ja,
|
||||||
|
)
|
||||||
|
|
||||||
|
pkid_zh_hans = 2
|
||||||
|
description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里,\n一同探索随处可见的数据库模型。"
|
||||||
|
pack_localized_zh_hans = PackLocalized(
|
||||||
|
pkid=pkid_zh_hans,
|
||||||
|
id=pack_id,
|
||||||
|
lang=ArcaeaLanguage.ZH_HANS.value,
|
||||||
|
name=None,
|
||||||
|
description=description_zh_hans,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all([pack, pack_localized_ja])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert len(pack.localized_objects) == len([pack_localized_ja])
|
||||||
|
|
||||||
|
assert pack_localized_ja.parent.description == pack.description
|
||||||
|
|
||||||
|
# relationships should be viewonly
|
||||||
|
new_pack = Pack(
|
||||||
|
id=f"{pack_id}_new",
|
||||||
|
name="NEW",
|
||||||
|
description="new new pack",
|
||||||
|
)
|
||||||
|
db_session.add(new_pack)
|
||||||
|
|
||||||
|
pack_localized_ja.parent = new_pack
|
||||||
|
pack.localized_objects.append(pack_localized_zh_hans)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert pack_localized_ja.parent == pack
|
||||||
|
assert len(pack.localized_objects) == 1
|
110
tests/db/models/v5/test_chart.py
Normal file
110
tests/db/models/v5/test_chart.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Database models v5
|
||||||
|
|
||||||
|
Chart functionalities
|
||||||
|
- basic data handling
|
||||||
|
- Difficulty song info overriding
|
||||||
|
"""
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
|
||||||
|
from arcaea_offline.database.models.v5 import (
|
||||||
|
Chart,
|
||||||
|
ChartInfo,
|
||||||
|
Difficulty,
|
||||||
|
ModelsV5Base,
|
||||||
|
ModelsV5ViewBase,
|
||||||
|
Pack,
|
||||||
|
Song,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChart:
|
||||||
|
def init_db(self, session):
|
||||||
|
ModelsV5Base.metadata.create_all(session.bind)
|
||||||
|
ModelsV5ViewBase.metadata.create_all(session.bind)
|
||||||
|
|
||||||
|
def test_basic(self, db_session):
|
||||||
|
self.init_db(db_session)
|
||||||
|
|
||||||
|
pack_id = "test_pack"
|
||||||
|
song_id = "test_song"
|
||||||
|
rating_class = ArcaeaRatingClass.FUTURE
|
||||||
|
|
||||||
|
pack = Pack(id=pack_id, name="Test Pack")
|
||||||
|
song = Song(
|
||||||
|
idx=2,
|
||||||
|
id=song_id,
|
||||||
|
title="~TEST~",
|
||||||
|
artist="~test~",
|
||||||
|
pack_id=pack_id,
|
||||||
|
)
|
||||||
|
difficulty = Difficulty(
|
||||||
|
song_id=song_id,
|
||||||
|
rating_class=rating_class,
|
||||||
|
rating=9,
|
||||||
|
rating_plus=True,
|
||||||
|
)
|
||||||
|
chart_info = ChartInfo(
|
||||||
|
song_id=song_id,
|
||||||
|
rating_class=rating_class,
|
||||||
|
constant=98,
|
||||||
|
notes=980,
|
||||||
|
)
|
||||||
|
db_session.add_all([pack, song, difficulty, chart_info])
|
||||||
|
|
||||||
|
chart: Chart = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == song_id) & (Chart.rating_class == rating_class))
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
# `song_id` and `rating_class` are guarded by the WHERE clause above
|
||||||
|
assert chart.song_idx == song.idx
|
||||||
|
assert chart.title == song.title
|
||||||
|
assert chart.artist == song.artist
|
||||||
|
assert chart.pack_id == song.pack_id
|
||||||
|
assert chart.rating == difficulty.rating
|
||||||
|
assert chart.rating_plus == difficulty.rating_plus
|
||||||
|
assert chart.constant == chart_info.constant
|
||||||
|
assert chart.notes == chart_info.notes
|
||||||
|
|
||||||
|
def test_difficulty_override(self, db_session):
|
||||||
|
self.init_db(db_session)
|
||||||
|
|
||||||
|
pack_id = "test_pack"
|
||||||
|
song_id = "test_song"
|
||||||
|
rating_class = ArcaeaRatingClass.FUTURE
|
||||||
|
|
||||||
|
pack = Pack(id=pack_id, name="Test Pack")
|
||||||
|
song = Song(
|
||||||
|
idx=2,
|
||||||
|
id=song_id,
|
||||||
|
title="~TEST~",
|
||||||
|
artist="~test~",
|
||||||
|
pack_id=pack_id,
|
||||||
|
)
|
||||||
|
difficulty = Difficulty(
|
||||||
|
song_id=song_id,
|
||||||
|
rating_class=rating_class,
|
||||||
|
rating=9,
|
||||||
|
rating_plus=True,
|
||||||
|
title="~TEST DIFF~",
|
||||||
|
artist="~diff~",
|
||||||
|
)
|
||||||
|
chart_info = ChartInfo(
|
||||||
|
song_id=song_id,
|
||||||
|
rating_class=rating_class,
|
||||||
|
constant=98,
|
||||||
|
notes=980,
|
||||||
|
)
|
||||||
|
db_session.add_all([pack, song, difficulty, chart_info])
|
||||||
|
|
||||||
|
chart: Chart = (
|
||||||
|
db_session.query(Chart)
|
||||||
|
.where((Chart.song_id == song_id) & (Chart.rating_class == rating_class))
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chart.song_idx == song.idx
|
||||||
|
assert chart.title == difficulty.title
|
||||||
|
assert chart.artist == difficulty.artist
|
88
tests/external/importers/arcaea/test_online.py
vendored
Normal file
88
tests/external/importers/arcaea/test_online.py
vendored
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from arcaea_offline.constants.enums.arcaea import (
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
)
|
||||||
|
from arcaea_offline.database.models.v5.play_results import PlayResult
|
||||||
|
from arcaea_offline.external.importers.arcaea.online import ArcaeaOnlineApiParser
|
||||||
|
|
||||||
|
API_RESULT = {
|
||||||
|
"success": True,
|
||||||
|
"value": {
|
||||||
|
"best_rated_scores": [
|
||||||
|
{
|
||||||
|
"song_id": "test1",
|
||||||
|
"difficulty": 2,
|
||||||
|
"modifier": 0,
|
||||||
|
"rating": 12.5,
|
||||||
|
"score": 9908123,
|
||||||
|
"perfect_count": 1234,
|
||||||
|
"near_count": 12,
|
||||||
|
"miss_count": 4,
|
||||||
|
"clear_type": 1,
|
||||||
|
"title": {"ja": "テスト1", "en": "Test 1"},
|
||||||
|
"artist": "pytest",
|
||||||
|
"time_played": 1704067200000, # 2024-01-01 00:00:00 UTC
|
||||||
|
"bg": "abcdefg123456hijklmn7890123opqrs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"song_id": "test2",
|
||||||
|
"difficulty": 2,
|
||||||
|
"modifier": 0,
|
||||||
|
"rating": 12.0,
|
||||||
|
"score": 9998123,
|
||||||
|
"perfect_count": 1234,
|
||||||
|
"near_count": 1,
|
||||||
|
"miss_count": 0,
|
||||||
|
"clear_type": 1,
|
||||||
|
"title": {"ja": "テスト2", "en": "Test 2"},
|
||||||
|
"artist": "pytest",
|
||||||
|
"time_played": 1704067200000,
|
||||||
|
"bg": "abcdefg123456hijklmn7890123opqrs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"recent_rated_scores": [
|
||||||
|
{
|
||||||
|
"song_id": "test2",
|
||||||
|
"difficulty": 2,
|
||||||
|
"modifier": 0,
|
||||||
|
"rating": 12.0,
|
||||||
|
"score": 9998123,
|
||||||
|
"perfect_count": 1234,
|
||||||
|
"near_count": 1,
|
||||||
|
"miss_count": 0,
|
||||||
|
"clear_type": 1,
|
||||||
|
"title": {"ja": "テスト2", "en": "Test 2"},
|
||||||
|
"artist": "pytest",
|
||||||
|
"time_played": 1704153600000, # 2024-01-02 00:00:00 UTC
|
||||||
|
"bg": "abcdefg123456hijklmn7890123opqrs",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestArcaeaOnlineApiParser:
|
||||||
|
API_RESULT_CONTENT = json.dumps(API_RESULT, ensure_ascii=False)
|
||||||
|
|
||||||
|
def test_parse(self):
|
||||||
|
play_results = ArcaeaOnlineApiParser(self.API_RESULT_CONTENT).parse()
|
||||||
|
assert all(isinstance(item, PlayResult) for item in play_results)
|
||||||
|
|
||||||
|
assert len(play_results) == 2
|
||||||
|
|
||||||
|
test1 = next(filter(lambda x: x.song_id == "test1", play_results))
|
||||||
|
assert test1.rating_class is ArcaeaRatingClass.FUTURE
|
||||||
|
assert test1.score == 9908123
|
||||||
|
assert test1.pure == 1234
|
||||||
|
assert test1.far == 12
|
||||||
|
assert test1.lost == 4
|
||||||
|
assert test1.date == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
assert test1.clear_type is ArcaeaPlayResultClearType.NORMAL_CLEAR
|
||||||
|
assert test1.modifier is ArcaeaPlayResultModifier.NORMAL
|
||||||
|
|
||||||
|
test2 = next(filter(lambda x: x.song_id == "test2", play_results))
|
||||||
|
assert test2.date == datetime(2024, 1, 2, 0, 0, 0, tzinfo=timezone.utc)
|
55
tests/external/importers/arcaea/test_st3.py
vendored
Normal file
55
tests/external/importers/arcaea/test_st3.py
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import tests.resources
|
||||||
|
from arcaea_offline.constants.enums.arcaea import (
|
||||||
|
ArcaeaPlayResultClearType,
|
||||||
|
ArcaeaPlayResultModifier,
|
||||||
|
ArcaeaRatingClass,
|
||||||
|
)
|
||||||
|
from arcaea_offline.external.importers.arcaea.st3 import ArcaeaSt3Parser
|
||||||
|
|
||||||
|
db = sqlite3.connect(":memory:")
|
||||||
|
db.executescript(tests.resources.get_resource("st3.sql").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestArcaeaSt3Parser:
|
||||||
|
@property
|
||||||
|
def play_results(self):
|
||||||
|
return ArcaeaSt3Parser.parse(db)
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
play_results = self.play_results
|
||||||
|
|
||||||
|
assert len(play_results) == 4
|
||||||
|
|
||||||
|
test1 = next(filter(lambda x: x.song_id == "test1", play_results))
|
||||||
|
assert test1.rating_class is ArcaeaRatingClass.FUTURE
|
||||||
|
assert test1.score == 9441167
|
||||||
|
assert test1.pure == 895
|
||||||
|
assert test1.far == 32
|
||||||
|
assert test1.lost == 22
|
||||||
|
assert test1.date == datetime.fromtimestamp(1722100000).astimezone()
|
||||||
|
assert test1.clear_type is ArcaeaPlayResultClearType.TRACK_LOST
|
||||||
|
assert test1.modifier is ArcaeaPlayResultModifier.HARD
|
||||||
|
|
||||||
|
def test_corrupt_handling(self):
|
||||||
|
play_results = self.play_results
|
||||||
|
|
||||||
|
corrupt1 = filter(lambda x: x.song_id == "corrupt1", play_results)
|
||||||
|
# `rating_class` out of range, so this should be ignored during parsing,
|
||||||
|
# thus does not present in the result.
|
||||||
|
assert len(list(corrupt1)) == 0
|
||||||
|
|
||||||
|
corrupt2 = next(filter(lambda x: x.song_id == "corrupt2", play_results))
|
||||||
|
assert corrupt2.clear_type is None
|
||||||
|
assert corrupt2.modifier is None
|
||||||
|
|
||||||
|
date1 = next(filter(lambda x: x.song_id == "date1", play_results))
|
||||||
|
assert date1.date is None
|
||||||
|
|
||||||
|
def test_invalid_input(self):
|
||||||
|
pytest.raises(TypeError, ArcaeaSt3Parser.parse, "abcdefghijklmn")
|
||||||
|
pytest.raises(TypeError, ArcaeaSt3Parser.parse, 123456)
|
34
tests/external/importers/test_chart_info_database.py
vendored
Normal file
34
tests/external/importers/test_chart_info_database.py
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import tests.resources
|
||||||
|
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
|
||||||
|
from arcaea_offline.database.models.v5 import ChartInfo
|
||||||
|
from arcaea_offline.external.importers.chart_info_database import (
|
||||||
|
ChartInfoDatabaseParser,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = sqlite3.connect(":memory:")
|
||||||
|
db.executescript(tests.resources.get_resource("cidb.sql").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestChartInfoDatabaseParser:
|
||||||
|
def test_parse(self):
|
||||||
|
items = ChartInfoDatabaseParser.parse(db)
|
||||||
|
assert all(isinstance(item, ChartInfo) for item in items)
|
||||||
|
|
||||||
|
assert len(items) == 3
|
||||||
|
|
||||||
|
test1 = next(filter(lambda x: x.song_id == "test1", items))
|
||||||
|
assert test1.rating_class is ArcaeaRatingClass.PRESENT
|
||||||
|
assert test1.constant == 90
|
||||||
|
assert test1.notes == 900
|
||||||
|
|
||||||
|
test2 = next(filter(lambda x: x.song_id == "test2", items))
|
||||||
|
assert test2.rating_class is ArcaeaRatingClass.FUTURE
|
||||||
|
assert test2.constant == 95
|
||||||
|
assert test2.notes == 950
|
||||||
|
|
||||||
|
test3 = next(filter(lambda x: x.song_id == "test3", items))
|
||||||
|
assert test3.rating_class is ArcaeaRatingClass.BEYOND
|
||||||
|
assert test3.constant == 100
|
||||||
|
assert test3.notes is None
|
16
tests/resources/__init__.py
Normal file
16
tests/resources/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import importlib.resources
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def get_resource(path: str):
|
||||||
|
"""
|
||||||
|
A wrapper for `importlib.resources.files()` since it's not available in Python 3.8.
|
||||||
|
"""
|
||||||
|
if sys.version_info >= (3, 9, 0):
|
||||||
|
with importlib.resources.as_file(
|
||||||
|
importlib.resources.files(__name__).joinpath(path)
|
||||||
|
) as resource_path:
|
||||||
|
return resource_path
|
||||||
|
|
||||||
|
with importlib.resources.path(__name__, path) as resource_path:
|
||||||
|
return resource_path
|
11
tests/resources/cidb.sql
Normal file
11
tests/resources/cidb.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE charts_info (
|
||||||
|
song_id TEXT NOT NULL,
|
||||||
|
rating_class INTEGER NOT NULL,
|
||||||
|
constant INTEGER NOT NULL,
|
||||||
|
notes INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO charts_info (song_id, rating_class, constant, notes) VALUES
|
||||||
|
("test1", 1, 90, 900),
|
||||||
|
("test2", 2, 95, 950),
|
||||||
|
("test3", 3, 100, NULL);
|
37
tests/resources/st3.sql
Normal file
37
tests/resources/st3.sql
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE scores (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
version INTEGER,
|
||||||
|
score INTEGER,
|
||||||
|
shinyPerfectCount INTEGER,
|
||||||
|
perfectCount INTEGER,
|
||||||
|
nearCount INTEGER,
|
||||||
|
missCount INTEGER,
|
||||||
|
date INTEGER,
|
||||||
|
songId TEXT,
|
||||||
|
songDifficulty INTEGER,
|
||||||
|
modifier INTEGER,
|
||||||
|
health INTEGER,
|
||||||
|
ct INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE cleartypes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
songId TEXT,
|
||||||
|
songDifficulty INTEGER,
|
||||||
|
clearType INTEGER,
|
||||||
|
ct INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO scores ("id", "version", "score", "shinyPerfectCount", "perfectCount", "nearCount", "missCount", "date", "songId", "songDifficulty", "modifier", "health", "ct") VALUES
|
||||||
|
('1', '1', '9441167', '753', '895', '32', '22', '1722100000', 'test1', '2', '2', '0', '0'),
|
||||||
|
('2', '1', '9752087', '914', '1024', '29', '12', '1722200000', 'test2', '2', '0', '100', '0'),
|
||||||
|
('3', '1', '9750000', '900', '1000', '20', '10', '1722200000', 'corrupt1', '5', '0', '0', '0'),
|
||||||
|
('4', '1', '9750000', '900', '1000', '20', '10', '1722200000', 'corrupt2', '2', '9', '0', '0'),
|
||||||
|
('5', '1', '9750000', '900', '1000', '20', '10', '1', 'date1', '2', '0', '0', '0');
|
||||||
|
|
||||||
|
INSERT INTO cleartypes ("id", "songId", "songDifficulty", "clearType", "ct") VALUES
|
||||||
|
('1', 'test1', '2', '0', '0'),
|
||||||
|
('2', 'test2', '2', '1', '0'),
|
||||||
|
('3', 'corrupt1', '5', '0', '0'),
|
||||||
|
('4', 'corrupt2', '2', '7', '0'),
|
||||||
|
('5', 'date1', '2', '1', '0');
|
126
tests/utils/test_formatters.py
Normal file
126
tests/utils/test_formatters.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
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