21 Commits

Author SHA1 Message Date
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
64285c350c ci: dist file discovery 2024-04-01 00:10:30 +08:00
62c3431cff chore: v0.2.2 2024-03-31 23:56:18 +08:00
280543660a ci: build package using github actions 2024-03-31 23:54:58 +08:00
8dc433b12a feat: ETERNAL rating class support 2024-03-20 15:52:55 +08:00
2bd64bbd5e feat(external): supporting chart info database 2024-03-16 01:54:26 +08:00
54749c8df2 feat(db): DEF v2 scores export supporting 2024-02-27 17:23:43 +08:00
36364d6e3d ci: pytest actions 2023-11-09 17:31:55 +08:00
b14c3e82b4 test: add tests 2023-11-09 16:33:51 +08:00
14f4cef426 chore: fix pylint warnings 2023-11-02 01:10:31 +08:00
92fcc53015 fix: typing issues 2023-11-02 00:21:33 +08:00
60 changed files with 1368 additions and 510 deletions

View File

@ -0,0 +1,48 @@
name: "Build and draft a release"
on:
workflow_dispatch:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
discussions: write
jobs:
build-and-draft-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python environment
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Build package
run: |
pip install build
python -m build
- name: Remove `v` in tag name
uses: mad9000/actions-find-and-replace-string@5
id: tagNameReplaced
with:
source: ${{ github.ref_name }}
find: "v"
replace: ""
- name: Draft a release
uses: softprops/action-gh-release@v2
with:
discussion_category_name: New releases
draft: true
generate_release_notes: true
files: |
dist/arcaea_offline-${{ steps.tagNameReplaced.outputs.value }}*.whl
dist/arcaea-offline-${{ steps.tagNameReplaced.outputs.value }}.tar.gz

23
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,23 @@
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'

2
.sourcery.yaml Normal file
View File

@ -0,0 +1,2 @@
rule_settings:
python_version: '3.8'

View File

@ -4,16 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "arcaea-offline" name = "arcaea-offline"
version = "0.2.1" version = "0.2.2"
authors = [{ name = "283375", email = "log_283375@163.com" }] 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 = [
"beautifulsoup4==4.12.2",
"SQLAlchemy==2.0.20", "SQLAlchemy==2.0.20",
"SQLAlchemy-Utils==0.41.1", "SQLAlchemy-Utils==0.41.1",
"Whoosh==2.7.4",
] ]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
@ -29,4 +27,15 @@ profile = "black"
src_paths = ["src/arcaea_offline"] src_paths = ["src/arcaea_offline"]
[tool.pyright] [tool.pyright]
ignore = ["**/__debug*.*"] 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
]

View File

@ -1,3 +1,6 @@
black==23.3.0 black==23.3.0
isort==5.12.0 isort==5.12.0
pre-commit==3.3.1 pre-commit==3.3.1
pylint==3.0.2
pytest==7.4.3
tox==4.11.3

View File

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

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,64 +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)
elif score >= 9800000:
return Decimal(1) + (Decimal(score - 9800000) / 200000)
else:
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:
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]):
play_rating = Decimal(play_rating)
ranges = []
for upperScore, lowerScore in [
(10000000, 9900000),
(9899999, 9800000),
(9799999, 9500000),
(9499999, 9200000),
(9199999, 8900000),
(8899999, 8600000),
]:
upperScoreModifier = calculate_score_modifier(upperScore)
lowerScoreModifier = calculate_score_modifier(lowerScore)
ranges.append(
(play_rating - upperScoreModifier, play_rating - lowerScoreModifier)
)
return ConstantsFromPlayRatingResult(*ranges)

View File

@ -1,175 +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)
if self.fragments is None:
fragments_multiplier = Decimal(1)
elif 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,
):
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")
play_result = (Decimal("2.45") * ptt.sqrt() + Decimal("2.5")) * (step / 50)
play_result += partner_bonus_step
play_result *= partner_bonus_multiplier
if step_booster:
play_result *= step_booster.final_value()
return play_result
def calculate_step(
play_result: PlayResult,
*,
partner_bonus: Optional[PartnerBonus] = None,
step_booster: Optional[StepBooster] = None,
):
play_result_original = calculate_step_original(
play_result, partner_bonus=partner_bonus, step_booster=step_booster
)
return round(play_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,2 @@
from . import world
from .play_result import 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,11 @@
from ._common import MemoriesStepBooster, PartnerBonus, WorldPlayResult
from .legacy import LegacyMapStepBooster
from .main import WorldMainMapCalculators
from .partners import (
AmaneBelowExPartnerBonus,
AwakenedEtoPartnerBonus,
AwakenedIlithPartnerBonus,
AwakenedLunaPartnerBonus,
MayaPartnerBonus,
MithraTerceraPartnerBonus,
)

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,3 @@
from .clear_type import ArcaeaPlayResultClearType
from .modifier import ArcaeaPlayResultModifier
from .rating_class import ArcaeaRatingClass

View File

@ -0,0 +1,10 @@
from enum import IntEnum
class ArcaeaPlayResultClearType(IntEnum):
TRACK_LOST = 0
NORMAL_CLEAR = 1
FULL_RECALL = 2
PURE_MEMORY = 3
HARD_CLEAR = 4
EASY_CLEAR = 5

View File

@ -0,0 +1,7 @@
from enum import IntEnum
class ArcaeaPlayResultModifier(IntEnum):
NORMAL = 0
EASY = 1
HARD = 2

View File

@ -0,0 +1,9 @@
from enum import IntEnum
class ArcaeaRatingClass(IntEnum):
PAST = 0
PRESENT = 1
FUTURE = 2
BEYOND = 3
ETERNAL = 4

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 @@
from .db import Database

View File

@ -5,13 +5,35 @@ from typing import Iterable, List, Optional, Type, Union
from sqlalchemy import Engine, func, inspect, select from sqlalchemy import Engine, func, inspect, select
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
from .calculate import calculate_score_modifier from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
from .external.arcsong.arcsong_json import ArcSongJsonBuilder from arcaea_offline.external.exports import (
from .external.exports import ScoreExport, exporters ArcaeaOfflineDEFV2_Score,
from .models.config import * ScoreExport,
from .models.scores import * exporters,
from .models.songs import * )
from .singleton import Singleton from arcaea_offline.singleton import Singleton
from .models.v4.config import ConfigBase, Property
from .models.v4.scores import (
CalculatedPotential,
Score,
ScoreBest,
ScoreCalculated,
ScoresBase,
ScoresViewBase,
)
from .models.v4.songs import (
Chart,
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongsBase,
SongsViewBase,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,18 +49,21 @@ class Database(metaclass=Singleton):
if isinstance(self.engine, Engine): if isinstance(self.engine, Engine):
return return
raise ValueError("No sqlalchemy.Engine instance specified before.") raise ValueError("No sqlalchemy.Engine instance specified before.")
elif isinstance(engine, Engine):
if isinstance(self.engine, Engine): if not isinstance(engine, Engine):
logger.warning(
f"A sqlalchemy.Engine instance {self.engine} has been specified "
f"and will be replaced to {engine}"
)
self.engine = engine
else:
raise ValueError( raise ValueError(
f"A sqlalchemy.Engine instance expected, not {repr(engine)}" f"A sqlalchemy.Engine instance expected, not {repr(engine)}"
) )
if isinstance(self.engine, Engine):
logger.warning(
"A sqlalchemy.Engine instance %r has been specified "
"and will be replaced to %r",
self.engine,
engine,
)
self.engine = engine
@property @property
def engine(self) -> Engine: def engine(self) -> Engine:
return self.__engine # type: ignore return self.__engine # type: ignore
@ -60,7 +85,8 @@ class Database(metaclass=Singleton):
# create tables & views # create tables & views
if checkfirst: if checkfirst:
# > https://github.com/kvesteri/sqlalchemy-utils/issues/396 # > https://github.com/kvesteri/sqlalchemy-utils/issues/396
# > view.create_view() causes DuplicateTableError on Base.metadata.create_all(checkfirst=True) # > view.create_view() causes DuplicateTableError on
# > Base.metadata.create_all(checkfirst=True)
# so if `checkfirst` is True, drop these views before creating # so if `checkfirst` is True, drop these views before creating
SongsViewBase.metadata.drop_all(self.engine) SongsViewBase.metadata.drop_all(self.engine)
ScoresViewBase.metadata.drop_all(self.engine) ScoresViewBase.metadata.drop_all(self.engine)
@ -389,6 +415,15 @@ class Database(metaclass=Singleton):
scores = self.get_scores() scores = self.get_scores()
return [exporters.score(score) for score in 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()

View File

@ -0,0 +1,58 @@
from typing import Optional
from sqlalchemy import Integer
from sqlalchemy.types import TypeDecorator
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
class DbRatingClass(TypeDecorator):
"""sqlalchemy rating_class type decorator"""
impl = Integer
def process_bind_param(
self, value: Optional[ArcaeaRatingClass], dialect
) -> Optional[int]:
return None if value is None else value.value
def process_result_value(
self, value: Optional[int], dialect
) -> Optional[ArcaeaRatingClass]:
return None if value is None else ArcaeaRatingClass(value)
class DbClearType(TypeDecorator):
"""sqlalchemy clear_type type decorator"""
impl = Integer
def process_bind_param(
self, value: Optional[ArcaeaPlayResultClearType], dialect
) -> Optional[int]:
return None if value is None else value.value
def process_result_value(
self, value: Optional[int], dialect
) -> Optional[ArcaeaPlayResultClearType]:
return None if value is None else ArcaeaPlayResultClearType(value)
class DbModifier(TypeDecorator):
"""sqlalchemy modifier type decorator"""
impl = Integer
def process_bind_param(
self, value: Optional[ArcaeaPlayResultModifier], dialect
) -> Optional[int]:
return None if value is None else value.value
def process_result_value(
self, value: Optional[int], dialect
) -> Optional[ArcaeaPlayResultModifier]:
return None if value is None else ArcaeaPlayResultModifier(value)

View File

@ -1,8 +1,12 @@
# pylint: disable=too-few-public-methods
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
class ReprHelper: class ReprHelper:
# pylint: disable=no-member
def _repr(self, **kwargs) -> str: def _repr(self, **kwargs) -> str:
""" """
Helper for __repr__ Helper for __repr__

View File

@ -1,3 +1,5 @@
# pylint: disable=too-few-public-methods
from sqlalchemy import TEXT from sqlalchemy import TEXT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

View File

@ -1,3 +1,5 @@
# pylint: disable=too-few-public-methods, duplicate-code
from typing import Optional from typing import Optional
from sqlalchemy import TEXT, case, func, inspect, select, text from sqlalchemy import TEXT, case, func, inspect, select, text
@ -37,7 +39,8 @@ class Score(ScoresBase):
comment="0: NORMAL, 1: EASY, 2: HARD" comment="0: NORMAL, 1: EASY, 2: HARD"
) )
clear_type: Mapped[Optional[int]] = mapped_column( clear_type: Mapped[Optional[int]] = mapped_column(
comment="0: TRACK LOST, 1: NORMAL CLEAR, 2: FULL RECALL, 3: PURE MEMORY, 4: EASY CLEAR, 5: HARD CLEAR" comment="0: TRACK LOST, 1: NORMAL CLEAR, 2: FULL RECALL, "
"3: PURE MEMORY, 4: EASY CLEAR, 5: HARD CLEAR"
) )
comment: Mapped[Optional[str]] = mapped_column(TEXT()) comment: Mapped[Optional[str]] = mapped_column(TEXT())

View File

@ -1,3 +1,5 @@
# pylint: disable=too-few-public-methods, duplicate-code
from typing import Optional from typing import Optional
from sqlalchemy import TEXT, ForeignKey, func, select from sqlalchemy import TEXT, ForeignKey, func, select
@ -154,7 +156,7 @@ class ChartInfo(SongsBase):
ForeignKey("difficulties.rating_class"), primary_key=True ForeignKey("difficulties.rating_class"), primary_key=True
) )
constant: Mapped[int] = mapped_column( constant: Mapped[int] = mapped_column(
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104 here." comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104."
) )
notes: Mapped[Optional[int]] notes: Mapped[Optional[int]]

View File

@ -39,10 +39,7 @@ def fix_timestamp(timestamp: int) -> Union[int, None]:
def to_db_value(val: Any) -> Any: def to_db_value(val: Any) -> Any:
if not val: if not val:
return None return None
elif isinstance(val, list): return json.dumps(val, ensure_ascii=False) if isinstance(val, list) else val
return json.dumps(val, ensure_ascii=False)
else:
return val
def is_localized(item: dict, key: str, append_localized: bool = True): def is_localized(item: dict, key: str, append_localized: bool = True):
@ -86,7 +83,7 @@ class ArcaeaParser:
# or maybe a `pathlib.Path` subset # or maybe a `pathlib.Path` subset
# or an `importlib.resources.abc.Traversable` like object # or an `importlib.resources.abc.Traversable` like object
# e.g. `zipfile.Path` # e.g. `zipfile.Path`
file_handle = self.filepath.open(mode="r", encoding="utf-8") file_handle = self.filepath.open(mode="r", encoding="utf-8") # type: ignore
except Exception as e: except Exception as e:
raise ValueError("Invalid `filepath`.") from e raise ValueError("Invalid `filepath`.") from e
@ -94,7 +91,7 @@ class ArcaeaParser:
return file_handle.read() return file_handle.read()
def parse(self) -> List[DeclarativeBase]: def parse(self) -> List[DeclarativeBase]:
... raise NotImplementedError()
def write_database(self, session: Session): def write_database(self, session: Session):
results = self.parse() results = self.parse()

View File

@ -37,9 +37,6 @@ class TWebApiRatingMeResult(TypedDict):
class ArcaeaOnlineParser(ArcaeaParser): class ArcaeaOnlineParser(ArcaeaParser):
def __init__(self, filepath):
super().__init__(filepath)
def parse(self) -> List[Score]: def parse(self) -> List[Score]:
api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text()) api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text())

View File

@ -6,9 +6,6 @@ from .common import ArcaeaParser, is_localized, set_model_localized_attrs
class PacklistParser(ArcaeaParser): class PacklistParser(ArcaeaParser):
def __init__(self, filepath):
super().__init__(filepath)
def parse(self) -> List[Union[Pack, PackLocalized]]: def parse(self) -> List[Union[Pack, PackLocalized]]:
packlist_json_root = json.loads(self.read_file_text()) packlist_json_root = json.loads(self.read_file_text())

View File

@ -6,9 +6,6 @@ from .common import ArcaeaParser, is_localized, set_model_localized_attrs, to_db
class SonglistParser(ArcaeaParser): class SonglistParser(ArcaeaParser):
def __init__(self, filepath):
super().__init__(filepath)
def parse( def parse(
self, self,
) -> List[Union[Song, SongLocalized, Difficulty, DifficultyLocalized]]: ) -> List[Union[Song, SongLocalized, Difficulty, DifficultyLocalized]]:
@ -61,9 +58,6 @@ class SonglistParser(ArcaeaParser):
class SonglistDifficultiesParser(ArcaeaParser): class SonglistDifficultiesParser(ArcaeaParser):
def __init__(self, filepath):
self.filepath = filepath
def parse(self) -> List[Union[Difficulty, DifficultyLocalized]]: def parse(self) -> List[Union[Difficulty, DifficultyLocalized]]:
songlist_json_root = json.loads(self.read_file_text()) songlist_json_root = json.loads(self.read_file_text())

View File

@ -12,15 +12,13 @@ logger = logging.getLogger(__name__)
class St3ScoreParser(ArcaeaParser): class St3ScoreParser(ArcaeaParser):
def __init__(self, filepath):
super().__init__(filepath)
def parse(self) -> List[Score]: def parse(self) -> List[Score]:
items = [] items = []
with sqlite3.connect(self.filepath) as st3_conn: with sqlite3.connect(self.filepath) as st3_conn:
cursor = st3_conn.cursor() cursor = st3_conn.cursor()
db_scores = cursor.execute( db_scores = cursor.execute(
"SELECT songId, songDifficulty, score, perfectCount, nearCount, missCount, date, modifier FROM scores" "SELECT songId, songDifficulty, score, perfectCount, nearCount, missCount, "
"date, modifier FROM scores"
).fetchall() ).fetchall()
for ( for (
song_id, song_id,
@ -48,7 +46,7 @@ class St3ScoreParser(ArcaeaParser):
date=fix_timestamp(date), date=fix_timestamp(date),
modifier=modifier, modifier=modifier,
clear_type=clear_type, clear_type=clear_type,
comment=f"Parsed from st3", comment="Parsed from st3",
) )
) )
@ -67,8 +65,9 @@ class St3ScoreParser(ArcaeaParser):
if query_score and skip_duplicate: if query_score and skip_duplicate:
logger.info( logger.info(
f"{repr(parsed_score)} skipped because " "%r skipped because potential duplicate item %r found.",
f"potential duplicate item {repr(query_score)} found." parsed_score,
query_score,
) )
continue continue
session.add(parsed_score) session.add(parsed_score)

View File

@ -122,7 +122,9 @@ class ArcSongJsonBuilder:
pack = self.session.scalar(select(Pack).where(Pack.id == song.set)) pack = self.session.scalar(select(Pack).where(Pack.id == song.set))
if not pack: if not pack:
logger.warning(f'Cannot find pack "{song.set}", using placeholder instead.') logger.warning(
'Cannot find pack "%s", using placeholder instead.', song.set
)
pack = Pack(id="unknown", name="Unknown", description="__PLACEHOLDER__") pack = Pack(id="unknown", name="Unknown", description="__PLACEHOLDER__")
song_localized = self.session.scalar( song_localized = self.session.scalar(
select(SongLocalized).where(SongLocalized.id == song.id) select(SongLocalized).where(SongLocalized.id == song.id)

View File

@ -0,0 +1 @@
from .parser import ChartInfoDbParser

View File

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

View File

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

View File

@ -1,5 +1,5 @@
from ...models import Score from ...models import Score
from .types import ScoreExport from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport
def score(score: Score) -> ScoreExport: def score(score: Score) -> ScoreExport:
@ -17,3 +17,21 @@ def score(score: Score) -> ScoreExport:
"clear_type": score.clear_type, "clear_type": score.clear_type,
"comment": score.comment, "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,
}

View File

@ -1,4 +1,4 @@
from typing import Optional, TypedDict from typing import List, Literal, Optional, TypedDict
class ScoreExport(TypedDict): class ScoreExport(TypedDict):
@ -14,3 +14,32 @@ class ScoreExport(TypedDict):
modifier: Optional[int] modifier: Optional[int]
clear_type: Optional[int] clear_type: Optional[int]
comment: Optional[str] comment: Optional[str]
class ArcaeaOfflineDEFV2_ScoreItem(TypedDict, total=False):
id: Optional[int]
songId: str
ratingClass: int
score: int
pure: Optional[int]
far: Optional[int]
lost: Optional[int]
date: Optional[int]
maxRecall: Optional[int]
modifier: Optional[int]
clearType: Optional[int]
source: Optional[str]
comment: Optional[str]
ArcaeaOfflineDEFV2_Score = TypedDict(
"ArcaeaOfflineDEFV2_Score",
{
"$schema": Literal[
"https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json"
],
"type": Literal["score"],
"version": Literal[2],
"scores": List[ArcaeaOfflineDEFV2_ScoreItem],
},
)

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, fuzzy_distance: 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,2 @@
from .play_result import PlayResultFormatter
from .rating_class import 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,23 +0,0 @@
from typing import Optional
RATING_CLASS_TEXT_MAP = {
0: "Past",
1: "Present",
2: "Future",
3: "Beyond",
}
RATING_CLASS_SHORT_TEXT_MAP = {
0: "PST",
1: "PRS",
2: "FTR",
3: "BYD",
}
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

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

27
tests/conftest.py Normal file
View File

@ -0,0 +1,27 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
# region sqlalchemy fixtures
# from https://medium.com/@vittorio.camisa/agile-database-integration-tests-with-python-sqlalchemy-and-factory-boy-6824e8fe33a1
engine = create_engine("sqlite:///:memory:")
Session = sessionmaker()
@pytest.fixture(scope="module")
def db_conn():
connection = engine.connect()
yield connection
connection.close()
@pytest.fixture(scope="function")
def db_session(db_conn):
transaction = db_conn.begin()
session = Session(bind=db_conn)
yield session
session.close()
transaction.rollback()
# endregion

0
tests/db/__init__.py Normal file
View File

5
tests/db/db.py Normal file
View File

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

View File

View File

@ -0,0 +1,95 @@
from typing import Optional
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models._custom_types import (
DbClearType,
DbModifier,
DbRatingClass,
)
class Base(DeclarativeBase):
pass
class RatingClassTestModel(Base):
__tablename__ = "test_rating_class"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Optional[ArcaeaRatingClass]] = mapped_column(
DbRatingClass, nullable=True
)
class ClearTypeTestModel(Base):
__tablename__ = "test_clear_type"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Optional[ArcaeaPlayResultClearType]] = mapped_column(
DbClearType, nullable=True
)
class ModifierTestModel(Base):
__tablename__ = "test_modifier"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[Optional[ArcaeaPlayResultModifier]] = mapped_column(
DbModifier, nullable=True
)
class TestCustomTypes:
def _common_test_method(self, db_session, obj: Base, value_in_db):
"""
This method stores the `obj` into the given `db_session`,
then fetches the raw value of `obj.value` from database,
and asserts that the value is equal to `value_in_db`.
"""
db_session.add(obj)
db_session.commit()
exec_result = db_session.execute(
text(
f"SELECT value FROM {obj.__tablename__} WHERE id = {obj.id}" # type: ignore
)
).fetchone()[0]
if value_in_db is None:
assert exec_result is value_in_db
else:
assert exec_result == value_in_db
def test_rating_class(self, db_session):
Base.metadata.create_all(db_session.bind)
basic_obj = RatingClassTestModel(id=1, value=ArcaeaRatingClass.FUTURE)
self._common_test_method(db_session, basic_obj, 2)
null_obj = RatingClassTestModel(id=2, value=None)
self._common_test_method(db_session, null_obj, None)
def test_clear_type(self, db_session):
Base.metadata.create_all(db_session.bind)
basic_obj = ClearTypeTestModel(id=1, value=ArcaeaPlayResultClearType.TRACK_LOST)
self._common_test_method(db_session, basic_obj, 0)
null_obj = ClearTypeTestModel(id=2, value=None)
self._common_test_method(db_session, null_obj, None)
def test_modifier(self, db_session):
Base.metadata.create_all(db_session.bind)
basic_obj = ModifierTestModel(id=1, value=ArcaeaPlayResultModifier.HARD)
self._common_test_method(db_session, basic_obj, 2)
null_obj = ModifierTestModel(id=2, value=None)
self._common_test_method(db_session, null_obj, None)

View File

@ -0,0 +1,118 @@
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

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

16
tox.ini Normal file
View File

@ -0,0 +1,16 @@
[tox]
env_list =
py311
py310
py39
py38
minversion = 4.11.3
[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg
deps =
pytest==7.4.3
commands =
pytest {tty:--color=yes} {posargs}