24 Commits

Author SHA1 Message Date
dbb5d680af fix ci 2024-08-06 00:31:09 +08:00
3e6cc8d7e7 fix imports 2024-08-06 00:24:08 +08:00
10b332e911 refactor: st3 parser (importer) 2024-08-06 00:23:31 +08:00
2a2a063a3c refactor: external importers
* packlist & songlist importer
2024-08-05 15:07:55 +08:00
6e2bcbd0ea wip, tests and others 2024-06-20 02:30:37 +08:00
62c80c9955 fix: ruff F401 warnings 2024-05-22 02:32:34 +08:00
8e79ffedce ci: sync changes in master branch 2024-05-22 01:37:39 +08:00
677ab6c31e test: refactor legacy tests 2024-05-21 21:12:11 +08:00
ab88b6903c test: conftest database clean-up 2024-05-21 21:04:30 +08:00
ce715bfccc refactor: sqlalchemy custom types
- Unify `IntEnum` type decorators to single `DbIntEnum`
- Add timezone aware `TZDateTime` from sqlalchemy docs
2024-05-20 21:21:49 +08:00
0d5e21a90e refactor: arcaea enums
- Move all arcaea related enums to a single file
- Add ArcaeaSongSide enum
2024-05-20 00:55:33 +08:00
a6d71135fb feat: ArcaeaLanguage enum 2024-05-20 00:22:12 +08:00
5f2b66233b chore: tests 2024-05-20 00:19:16 +08:00
e295e58388 feat: sqlalchemy TypeDecorators for arcaea enums 2024-04-13 22:56:15 +08:00
61d9916cae feat: db pytest fixtures 2024-04-13 22:53:55 +08:00
264b340dfa fix: module __init__ 2024-04-06 18:29:38 +08:00
f359322b6c refactor!: calculate -> calculators 2024-04-04 18:10:53 +08:00
c705fea473 feat: formatter utils 2024-04-03 13:37:23 +08:00
c585e5ec04 feat: score lower limit constants
Add play result score lower limits
2024-04-03 13:36:55 +08:00
09fbebf7a4 refactor: enum naming 2024-04-03 12:51:23 +08:00
bb39a5912b feat: enums 2024-04-03 00:28:08 +08:00
b78040a795 refactor!: sqlalchemy database models 2024-04-02 22:15:21 +08:00
2204338a5e refactor: database base module 2024-04-02 22:02:54 +08:00
55e76ef650 refactor!: remove searcher 2024-04-02 21:54:07 +08:00
73 changed files with 2662 additions and 683 deletions

41
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: test & lint
on:
push:
branches:
- '*'
pull_request:
types: [opened, reopened]
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
run: 'pip install .[dev]'
- name: Run tests
run: 'python -m pytest -v'
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dev dependencies
run: 'pip install .[dev]'
- name: Run linter
run: 'ruff check'

View File

@ -1,23 +0,0 @@
name: Run tests
on:
push:
branches:
- 'master'
pull_request:
types: [opened, reopened]
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- run: 'pip install -r requirements.dev.txt .'
- run: 'pytest -v'

View File

@ -4,11 +4,10 @@ repos:
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.1.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- id: ruff
args: ["--fix"]
- id: ruff-format

View File

@ -9,35 +9,45 @@ authors = [{ name = "283375", email = "log_283375@163.com" }]
description = "Manage your local Arcaea score database."
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"beautifulsoup4==4.12.2",
"SQLAlchemy==2.0.20",
"SQLAlchemy-Utils==0.41.1",
"Whoosh==2.7.4",
]
dependencies = ["SQLAlchemy==2.0.20", "SQLAlchemy-Utils==0.41.1"]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
]
[project.optional-dependencies]
dev = ["ruff~=0.4", "pre-commit~=3.3", "pytest~=7.4", "tox~=4.11"]
[project.urls]
"Homepage" = "https://github.com/283375/arcaea-offline"
"Bug Tracker" = "https://github.com/283375/arcaea-offline/issues"
[tool.isort]
profile = "black"
src_paths = ["src/arcaea_offline"]
[tool.pyright]
ignore = ["build/"]
[tool.pylint.main]
jobs = 0
[tool.pylint.logging]
disable = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"not-callable", # false positive to sqlalchemy `func.*`, remove this when pylint-dev/pylint(#8138) closed
[tool.ruff.lint]
# Full list: https://docs.astral.sh/ruff/rules
select = [
"E", # pycodestyle (Error)
"W", # pycodestyle (Warning)
"F", # pyflakes
"I", # isort
"PL", # pylint
"N", # pep8-naming
"FBT", # flake8-boolean-trap
"A", # flake8-builtins
"DTZ", # flake8-datetimez
"LOG", # flake8-logging
"Q", # flake8-quotes
"G", # flake8-logging-format
"PIE", # flake8-pie
"PT", # flake8-pytest-style
]
ignore = [
"E501", # line-too-long
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"PLR2004", # magic-value-comparison
]

View File

@ -1,6 +1,4 @@
black==23.3.0
isort==5.12.0
pre-commit==3.3.1
pylint==3.0.2
pytest==7.4.3
tox==4.11.3
ruff~=0.4
pre-commit~=3.3
pytest~=7.4
tox~=4.11

View File

@ -1,4 +1,2 @@
beautifulsoup4==4.12.2
SQLAlchemy==2.0.20
SQLAlchemy-Utils==0.41.1
Whoosh==2.7.4

View File

@ -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,
)

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,3 @@
from .play_result import PlayResultCalculators
__all__ = ["PlayResultCalculators"]

View 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 >= 10000000:
return Decimal(2)
if score >= 9800000:
return Decimal(1) + (Decimal(score - 9800000) / 200000)
return Decimal(score - 9500000) / 300000
@classmethod
def play_rating(cls, score: int, constant: int) -> Decimal:
"""
Returns the play rating of the given score
https://arcaea.fandom.com/wiki/Potential#Play_Rating
:param constant: The (constant * 10) of the played chart, e.g. 120 for Testify[BYD]
:param score: The score of the play result, e.g. 10002221
:return: The play rating of the given values, e.g. Decimal("14.0")
"""
if not isinstance(score, int):
raise TypeError("score must be an integer")
if not isinstance(constant, int):
raise TypeError("constant must be an integer")
if score < 0:
raise ValueError("score cannot be negative")
if constant < 0:
raise ValueError("constant cannot be negative")
score_modifier = cls.score_modifier(score)
return max(Decimal(0), Decimal(constant) / 10 + score_modifier)
class ConstantsFromPlayRatingResult(TypedDict):
EX_PLUS: Tuple[Decimal, Decimal]
EX: Tuple[Decimal, Decimal]
AA: Tuple[Decimal, Decimal]
A: Tuple[Decimal, Decimal]
B: Tuple[Decimal, Decimal]
C: Tuple[Decimal, Decimal]
@classmethod
def constants_from_play_rating(
cls, play_rating: Union[Decimal, str, float, int]
) -> ConstantsFromPlayRatingResult:
play_rating = Decimal(play_rating)
def _result(score_upper: int, score_lower: int) -> Tuple[Decimal, Decimal]:
upper_score_modifier = cls.score_modifier(score_upper)
lower_score_modifier = cls.score_modifier(score_lower)
return (
play_rating - upper_score_modifier,
play_rating - lower_score_modifier,
)
return {
"EX_PLUS": _result(10000000, ScoreLowerLimits.EX_PLUS),
"EX": _result(ScoreLowerLimits.EX_PLUS - 1, ScoreLowerLimits.EX),
"AA": _result(ScoreLowerLimits.EX - 1, ScoreLowerLimits.AA),
"A": _result(ScoreLowerLimits.AA - 1, ScoreLowerLimits.A),
"B": _result(ScoreLowerLimits.A - 1, ScoreLowerLimits.B),
"C": _result(ScoreLowerLimits.B - 1, ScoreLowerLimits.C),
}

View 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",
]

View 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")

View 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

View 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)
)

View 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")

View File

View File

@ -0,0 +1,15 @@
from .arcaea import (
ArcaeaLanguage,
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
ArcaeaSongSide,
)
__all__ = [
"ArcaeaLanguage",
"ArcaeaPlayResultClearType",
"ArcaeaPlayResultModifier",
"ArcaeaRatingClass",
"ArcaeaSongSide",
]

View 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"

View File

@ -0,0 +1,12 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class ScoreLowerLimits:
EX_PLUS = 9900000
EX = 9800000
AA = 9500000
A = 9200000
B = 8900000
C = 8600000
D = 0

View File

@ -0,0 +1,3 @@
from .db import Database
__all__ = ["Database"]

View File

@ -5,10 +5,13 @@ from typing import Iterable, List, Optional, Type, Union
from sqlalchemy import Engine, func, inspect, select
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
from .external.arcsong.arcsong_json import ArcSongJsonBuilder
from .external.exports import ArcaeaOfflineDEFV2_Score, ScoreExport, exporters
from .models.config import ConfigBase, Property
from .models.scores import (
from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
from arcaea_offline.external.exports import exporters
from arcaea_offline.external.exports.types import ArcaeaOfflineDEFV2_Score, ScoreExport
from arcaea_offline.singleton import Singleton
from .models.v4.config import ConfigBase, Property
from .models.v4.scores import (
CalculatedPotential,
Score,
ScoreBest,
@ -16,7 +19,7 @@ from .models.scores import (
ScoresBase,
ScoresViewBase,
)
from .models.songs import (
from .models.v4.songs import (
Chart,
ChartInfo,
Difficulty,
@ -28,7 +31,6 @@ from .models.songs import (
SongsBase,
SongsViewBase,
)
from .singleton import Singleton
logger = logging.getLogger(__name__)

View 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

View File

@ -19,3 +19,24 @@ from .songs import (
SongsBase,
SongsViewBase,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"ConfigBase",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Property",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
"Song",
"SongLocalized",
"SongsBase",
"SongsViewBase",
]

View File

@ -10,12 +10,12 @@ from .common import ReprHelper
from .songs import ChartInfo, Difficulty
__all__ = [
"ScoresBase",
"Score",
"ScoresViewBase",
"ScoreCalculated",
"ScoreBest",
"CalculatedPotential",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
]

View File

@ -9,16 +9,16 @@ from sqlalchemy_utils import create_view
from .common import ReprHelper
__all__ = [
"SongsBase",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
"Difficulty",
"DifficultyLocalized",
"ChartInfo",
"SongsBase",
"SongsViewBase",
"Chart",
]

View 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",
]

View 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,
)

View 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__()

View 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()

View 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,
)

View File

@ -1 +1,3 @@
from .api_data import AndrealImageGeneratorApiDataConverter
__all__ = ["AndrealImageGeneratorApiDataConverter"]

View File

@ -2,3 +2,11 @@ from .online import ArcaeaOnlineParser
from .packlist import PacklistParser
from .songlist import SonglistDifficultiesParser, SonglistParser
from .st3 import St3ScoreParser
__all__ = [
"ArcaeaOnlineParser",
"PacklistParser",
"SonglistDifficultiesParser",
"SonglistParser",
"St3ScoreParser",
]

View File

@ -1 +1,3 @@
from .arcsong_db import ArcsongDbParser
__all__ = ["ArcsongDbParser"]

View File

@ -3,7 +3,7 @@ from typing import List
from sqlalchemy.orm import Session
from ...models.songs import ChartInfo
from arcaea_offline.database.models.v4 import ChartInfo
class ArcsongDbParser:

View File

@ -5,7 +5,7 @@ from typing import List, Optional, TypedDict
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...models import (
from arcaea_offline.database.models.v4 import (
ChartInfo,
Difficulty,
DifficultyLocalized,

View File

@ -1 +1,3 @@
from .parser import ChartInfoDbParser
__all__ = ["ChartInfoDbParser"]

View File

@ -1,2 +0,0 @@
from . import exporters
from .types import ArcaeaOfflineDEFV2_Score, ScoreExport

View File

@ -1,4 +1,5 @@
from ...models import Score
from arcaea_offline.database.models.v4 import Score
from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport

View 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)

View 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 PacklistParser(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 SonglistParser(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()

View 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 St3Parser:
@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

View File

@ -1 +1,3 @@
from .b30_csv import SmartRteB30CsvConverter
__all__ = ["SmartRteB30CsvConverter"]

View File

@ -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]

View File

@ -0,0 +1,7 @@
from .play_result import PlayResultFormatter
from .rating_class import RatingClassFormatter
__all__ = [
"PlayResultFormatter",
"RatingClassFormatter",
]

View 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")

View File

@ -0,0 +1,83 @@
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")

View File

@ -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)

View File

@ -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]

View File

@ -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 []

View File

@ -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")

View 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)

View 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
View 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

View File

@ -1,5 +0,0 @@
from sqlalchemy import Engine, create_engine, inspect
def create_engine_in_memory():
return create_engine("sqlite:///:memory:")

View 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

View File

@ -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~"

View File

View 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~"

View File

View 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

View 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

View 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

View File

@ -0,0 +1,56 @@
import sqlite3
from datetime import datetime
from importlib.resources import files
import pytest
from arcaea_offline.constants.enums.arcaea import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.external.importers.arcaea.st3 import St3Parser
import tests.resources
class TestSt3Parser:
DB_PATH = files(tests.resources).joinpath("st3-test.db")
@property
def play_results(self):
conn = sqlite3.connect(str(self.DB_PATH))
return St3Parser.parse(conn)
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, St3Parser.parse, "abcdefghijklmn")
pytest.raises(TypeError, St3Parser.parse, 123456)

View File

BIN
tests/resources/st3-test.db Normal file

Binary file not shown.

View File

@ -0,0 +1,117 @@
{
"_comment": "A quick preview of the data in st3-test.db",
"scores": [
{
"id": 1,
"version": 1,
"score": 9441167,
"shinyPerfectCount": 753,
"perfectCount": 895,
"nearCount": 32,
"missCount": 22,
"date": 1722100000,
"songId": "test1",
"songDifficulty": 2,
"modifier": 2,
"health": 0,
"ct": 0
},
{
"id": 2,
"version": 1,
"score": 9752087,
"shinyPerfectCount": 914,
"perfectCount": 1024,
"nearCount": 29,
"missCount": 12,
"date": 1722200000,
"songId": "test2",
"songDifficulty": 2,
"modifier": 0,
"health": 100,
"ct": 0
},
{
"id": 3,
"version": 1,
"score": 9750000,
"shinyPerfectCount": 900,
"perfectCount": 1000,
"nearCount": 20,
"missCount": 10,
"date": 1722200000,
"songId": "corrupt1",
"songDifficulty": 5,
"modifier": 0,
"health": 0,
"ct": 0
},
{
"id": 4,
"version": 1,
"score": 9750000,
"shinyPerfectCount": 900,
"perfectCount": 1000,
"nearCount": 20,
"missCount": 10,
"date": 1722200000,
"songId": "corrupt2",
"songDifficulty": 2,
"modifier": 9,
"health": 0,
"ct": 0
},
{
"id": 5,
"version": 1,
"score": 9750000,
"shinyPerfectCount": 900,
"perfectCount": 1000,
"nearCount": 20,
"missCount": 10,
"date": 1,
"songId": "date1",
"songDifficulty": 2,
"modifier": 0,
"health": 0,
"ct": 0
}
],
"cleartypes": [
{
"id": 1,
"songId": "test1",
"songDifficulty": 2,
"clearType": 0,
"ct": 0
},
{
"id": 2,
"songId": "test2",
"songDifficulty": 2,
"clearType": 1,
"ct": 0
},
{
"id": 3,
"songId": "corrupt1",
"songDifficulty": 5,
"clearType": 0,
"ct": 0
},
{
"id": 4,
"songId": "corrupt2",
"songDifficulty": 2,
"clearType": 7,
"ct": 0
},
{
"id": 5,
"songId": "date1",
"songDifficulty": 2,
"clearType": 1,
"ct": 0
}
]
}

View 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, [])