mirror of
https://github.com/283375/arcaea-offline.git
synced 2025-07-01 20:26:27 +00:00
Compare commits
17 Commits
v0.2.1
...
c705fea473
Author | SHA1 | Date | |
---|---|---|---|
c705fea473
|
|||
c585e5ec04
|
|||
09fbebf7a4
|
|||
bb39a5912b
|
|||
b78040a795
|
|||
2204338a5e
|
|||
55e76ef650
|
|||
64285c350c
|
|||
62c3431cff
|
|||
280543660a
|
|||
8dc433b12a
|
|||
2bd64bbd5e
|
|||
54749c8df2
|
|||
36364d6e3d
|
|||
b14c3e82b4
|
|||
14f4cef426
|
|||
92fcc53015
|
48
.github/workflows/build-and-draft-release.yml
vendored
Normal file
48
.github/workflows/build-and-draft-release.yml
vendored
Normal 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
23
.github/workflows/test.yml
vendored
Normal 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
2
.sourcery.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
rule_settings:
|
||||
python_version: '3.8'
|
@ -4,16 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "arcaea-offline"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
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",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
@ -29,4 +27,15 @@ profile = "black"
|
||||
src_paths = ["src/arcaea_offline"]
|
||||
|
||||
[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
|
||||
]
|
||||
|
@ -1,3 +1,6 @@
|
||||
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
|
||||
|
@ -1,4 +1,2 @@
|
||||
beautifulsoup4==4.12.2
|
||||
SQLAlchemy==2.0.20
|
||||
SQLAlchemy-Utils==0.41.1
|
||||
Whoosh==2.7.4
|
||||
|
@ -16,10 +16,9 @@ def calculate_score_range(notes: int, pure: int, far: int):
|
||||
def calculate_score_modifier(score: int) -> Decimal:
|
||||
if score >= 10000000:
|
||||
return Decimal(2)
|
||||
elif score >= 9800000:
|
||||
if score >= 9800000:
|
||||
return Decimal(1) + (Decimal(score - 9800000) / 200000)
|
||||
else:
|
||||
return Decimal(score - 9500000) / 300000
|
||||
return Decimal(score - 9500000) / 300000
|
||||
|
||||
|
||||
def calculate_play_rating(constant: int, score: int) -> Decimal:
|
||||
@ -35,6 +34,7 @@ def calculate_shiny_pure(notes: int, score: int, pure: int, far: int) -> int:
|
||||
|
||||
@dataclass
|
||||
class ConstantsFromPlayRatingResult:
|
||||
# pylint: disable=invalid-name
|
||||
EXPlus: Tuple[Decimal, Decimal]
|
||||
EX: Tuple[Decimal, Decimal]
|
||||
AA: Tuple[Decimal, Decimal]
|
||||
@ -44,10 +44,12 @@ class ConstantsFromPlayRatingResult:
|
||||
|
||||
|
||||
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 upperScore, lowerScore in [
|
||||
for upper_score, lower_score in [
|
||||
(10000000, 9900000),
|
||||
(9899999, 9800000),
|
||||
(9799999, 9500000),
|
||||
@ -55,10 +57,10 @@ def calculate_constants_from_play_rating(play_rating: Union[Decimal, str, float,
|
||||
(9199999, 8900000),
|
||||
(8899999, 8600000),
|
||||
]:
|
||||
upperScoreModifier = calculate_score_modifier(upperScore)
|
||||
lowerScoreModifier = calculate_score_modifier(lowerScore)
|
||||
upper_score_modifier = calculate_score_modifier(upper_score)
|
||||
lower_score_modifier = calculate_score_modifier(lower_score)
|
||||
ranges.append(
|
||||
(play_rating - upperScoreModifier, play_rating - lowerScoreModifier)
|
||||
(play_rating - upper_score_modifier, play_rating - lower_score_modifier)
|
||||
)
|
||||
|
||||
return ConstantsFromPlayRatingResult(*ranges)
|
||||
|
@ -97,9 +97,8 @@ class LegacyMapStepBooster(StepBooster):
|
||||
|
||||
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)
|
||||
if self.fragments == 100:
|
||||
fragments_multiplier = Decimal("1.1")
|
||||
elif self.fragments == 250:
|
||||
fragments_multiplier = Decimal("1.25")
|
||||
@ -118,7 +117,7 @@ def calculate_step_original(
|
||||
*,
|
||||
partner_bonus: Optional[PartnerBonus] = None,
|
||||
step_booster: Optional[StepBooster] = None,
|
||||
):
|
||||
) -> Decimal:
|
||||
ptt = play_result.play_rating
|
||||
step = play_result.partner_step
|
||||
if partner_bonus:
|
||||
@ -128,13 +127,13 @@ def calculate_step_original(
|
||||
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
|
||||
result = (Decimal("2.45") * ptt.sqrt() + Decimal("2.5")) * (step / 50)
|
||||
result += partner_bonus_step
|
||||
result *= partner_bonus_multiplier
|
||||
if step_booster:
|
||||
play_result *= step_booster.final_value()
|
||||
result *= step_booster.final_value()
|
||||
|
||||
return play_result
|
||||
return result
|
||||
|
||||
|
||||
def calculate_step(
|
||||
@ -142,12 +141,12 @@ def calculate_step(
|
||||
*,
|
||||
partner_bonus: Optional[PartnerBonus] = None,
|
||||
step_booster: Optional[StepBooster] = None,
|
||||
):
|
||||
play_result_original = calculate_step_original(
|
||||
) -> Decimal:
|
||||
result_original = calculate_step_original(
|
||||
play_result, partner_bonus=partner_bonus, step_booster=step_booster
|
||||
)
|
||||
|
||||
return round(play_result_original, 1)
|
||||
return round(result_original, 1)
|
||||
|
||||
|
||||
def calculate_play_rating_from_step(
|
||||
|
0
src/arcaea_offline/constants/__init__.py
Normal file
0
src/arcaea_offline/constants/__init__.py
Normal file
3
src/arcaea_offline/constants/enums/__init__.py
Normal file
3
src/arcaea_offline/constants/enums/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .clear_type import ArcaeaPlayResultClearType
|
||||
from .modifier import ArcaeaPlayResultModifier
|
||||
from .rating_class import ArcaeaRatingClass
|
10
src/arcaea_offline/constants/enums/clear_type.py
Normal file
10
src/arcaea_offline/constants/enums/clear_type.py
Normal 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
|
7
src/arcaea_offline/constants/enums/modifier.py
Normal file
7
src/arcaea_offline/constants/enums/modifier.py
Normal file
@ -0,0 +1,7 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class ArcaeaPlayResultModifier(IntEnum):
|
||||
NORMAL = 0
|
||||
EASY = 1
|
||||
HARD = 2
|
9
src/arcaea_offline/constants/enums/rating_class.py
Normal file
9
src/arcaea_offline/constants/enums/rating_class.py
Normal file
@ -0,0 +1,9 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class ArcaeaRatingClass(IntEnum):
|
||||
PAST = 0
|
||||
PRESENT = 1
|
||||
FUTURE = 2
|
||||
BEYOND = 3
|
||||
ETERNAL = 4
|
12
src/arcaea_offline/constants/play_result.py
Normal file
12
src/arcaea_offline/constants/play_result.py
Normal 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
|
1
src/arcaea_offline/database/__init__.py
Normal file
1
src/arcaea_offline/database/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .db import Database
|
@ -5,13 +5,35 @@ from typing import Iterable, List, Optional, Type, Union
|
||||
from sqlalchemy import Engine, func, inspect, select
|
||||
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
|
||||
|
||||
from .calculate import calculate_score_modifier
|
||||
from .external.arcsong.arcsong_json import ArcSongJsonBuilder
|
||||
from .external.exports import ScoreExport, exporters
|
||||
from .models.config import *
|
||||
from .models.scores import *
|
||||
from .models.songs import *
|
||||
from .singleton import Singleton
|
||||
from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
|
||||
from arcaea_offline.external.exports import (
|
||||
ArcaeaOfflineDEFV2_Score,
|
||||
ScoreExport,
|
||||
exporters,
|
||||
)
|
||||
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__)
|
||||
|
||||
@ -27,18 +49,21 @@ class Database(metaclass=Singleton):
|
||||
if isinstance(self.engine, Engine):
|
||||
return
|
||||
raise ValueError("No sqlalchemy.Engine instance specified before.")
|
||||
elif isinstance(engine, Engine):
|
||||
if isinstance(self.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:
|
||||
|
||||
if not isinstance(engine, Engine):
|
||||
raise ValueError(
|
||||
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
|
||||
def engine(self) -> Engine:
|
||||
return self.__engine # type: ignore
|
||||
@ -60,7 +85,8 @@ class Database(metaclass=Singleton):
|
||||
# create tables & views
|
||||
if checkfirst:
|
||||
# > 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
|
||||
SongsViewBase.metadata.drop_all(self.engine)
|
||||
ScoresViewBase.metadata.drop_all(self.engine)
|
||||
@ -389,6 +415,15 @@ class Database(metaclass=Singleton):
|
||||
scores = self.get_scores()
|
||||
return [exporters.score(score) for score in scores]
|
||||
|
||||
def export_scores_def_v2(self) -> ArcaeaOfflineDEFV2_Score:
|
||||
scores = self.get_scores()
|
||||
return {
|
||||
"$schema": "https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json",
|
||||
"type": "score",
|
||||
"version": 2,
|
||||
"scores": [exporters.score_def_v2(score) for score in scores],
|
||||
}
|
||||
|
||||
def generate_arcsong(self):
|
||||
with self.sessionmaker() as session:
|
||||
arcsong = ArcSongJsonBuilder(session).generate_arcsong_json()
|
0
src/arcaea_offline/database/models/__init__.py
Normal file
0
src/arcaea_offline/database/models/__init__.py
Normal file
@ -1,8 +1,12 @@
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||
|
||||
|
||||
class ReprHelper:
|
||||
# pylint: disable=no-member
|
||||
|
||||
def _repr(self, **kwargs) -> str:
|
||||
"""
|
||||
Helper for __repr__
|
@ -1,3 +1,5 @@
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
from sqlalchemy import TEXT
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
@ -1,3 +1,5 @@
|
||||
# pylint: disable=too-few-public-methods, duplicate-code
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import TEXT, case, func, inspect, select, text
|
||||
@ -37,7 +39,8 @@ class Score(ScoresBase):
|
||||
comment="0: NORMAL, 1: EASY, 2: HARD"
|
||||
)
|
||||
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())
|
||||
|
@ -1,3 +1,5 @@
|
||||
# pylint: disable=too-few-public-methods, duplicate-code
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import TEXT, ForeignKey, func, select
|
||||
@ -154,7 +156,7 @@ class ChartInfo(SongsBase):
|
||||
ForeignKey("difficulties.rating_class"), primary_key=True
|
||||
)
|
||||
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]]
|
||||
|
9
src/arcaea_offline/external/arcaea/common.py
vendored
9
src/arcaea_offline/external/arcaea/common.py
vendored
@ -39,10 +39,7 @@ def fix_timestamp(timestamp: int) -> Union[int, None]:
|
||||
def to_db_value(val: Any) -> Any:
|
||||
if not val:
|
||||
return None
|
||||
elif isinstance(val, list):
|
||||
return json.dumps(val, ensure_ascii=False)
|
||||
else:
|
||||
return val
|
||||
return json.dumps(val, ensure_ascii=False) if isinstance(val, list) else val
|
||||
|
||||
|
||||
def is_localized(item: dict, key: str, append_localized: bool = True):
|
||||
@ -86,7 +83,7 @@ class ArcaeaParser:
|
||||
# or maybe a `pathlib.Path` subset
|
||||
# or an `importlib.resources.abc.Traversable` like object
|
||||
# e.g. `zipfile.Path`
|
||||
file_handle = self.filepath.open(mode="r", encoding="utf-8")
|
||||
file_handle = self.filepath.open(mode="r", encoding="utf-8") # type: ignore
|
||||
except Exception as e:
|
||||
raise ValueError("Invalid `filepath`.") from e
|
||||
|
||||
@ -94,7 +91,7 @@ class ArcaeaParser:
|
||||
return file_handle.read()
|
||||
|
||||
def parse(self) -> List[DeclarativeBase]:
|
||||
...
|
||||
raise NotImplementedError()
|
||||
|
||||
def write_database(self, session: Session):
|
||||
results = self.parse()
|
||||
|
3
src/arcaea_offline/external/arcaea/online.py
vendored
3
src/arcaea_offline/external/arcaea/online.py
vendored
@ -37,9 +37,6 @@ class TWebApiRatingMeResult(TypedDict):
|
||||
|
||||
|
||||
class ArcaeaOnlineParser(ArcaeaParser):
|
||||
def __init__(self, filepath):
|
||||
super().__init__(filepath)
|
||||
|
||||
def parse(self) -> List[Score]:
|
||||
api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text())
|
||||
|
||||
|
@ -6,9 +6,6 @@ from .common import ArcaeaParser, is_localized, set_model_localized_attrs
|
||||
|
||||
|
||||
class PacklistParser(ArcaeaParser):
|
||||
def __init__(self, filepath):
|
||||
super().__init__(filepath)
|
||||
|
||||
def parse(self) -> List[Union[Pack, PackLocalized]]:
|
||||
packlist_json_root = json.loads(self.read_file_text())
|
||||
|
||||
|
@ -6,9 +6,6 @@ from .common import ArcaeaParser, is_localized, set_model_localized_attrs, to_db
|
||||
|
||||
|
||||
class SonglistParser(ArcaeaParser):
|
||||
def __init__(self, filepath):
|
||||
super().__init__(filepath)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
) -> List[Union[Song, SongLocalized, Difficulty, DifficultyLocalized]]:
|
||||
@ -61,9 +58,6 @@ class SonglistParser(ArcaeaParser):
|
||||
|
||||
|
||||
class SonglistDifficultiesParser(ArcaeaParser):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def parse(self) -> List[Union[Difficulty, DifficultyLocalized]]:
|
||||
songlist_json_root = json.loads(self.read_file_text())
|
||||
|
||||
|
13
src/arcaea_offline/external/arcaea/st3.py
vendored
13
src/arcaea_offline/external/arcaea/st3.py
vendored
@ -12,15 +12,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class St3ScoreParser(ArcaeaParser):
|
||||
def __init__(self, filepath):
|
||||
super().__init__(filepath)
|
||||
|
||||
def parse(self) -> List[Score]:
|
||||
items = []
|
||||
with sqlite3.connect(self.filepath) as st3_conn:
|
||||
cursor = st3_conn.cursor()
|
||||
db_scores = cursor.execute(
|
||||
"SELECT songId, songDifficulty, score, perfectCount, nearCount, missCount, date, modifier FROM scores"
|
||||
"SELECT songId, songDifficulty, score, perfectCount, nearCount, missCount, "
|
||||
"date, modifier FROM scores"
|
||||
).fetchall()
|
||||
for (
|
||||
song_id,
|
||||
@ -48,7 +46,7 @@ class St3ScoreParser(ArcaeaParser):
|
||||
date=fix_timestamp(date),
|
||||
modifier=modifier,
|
||||
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:
|
||||
logger.info(
|
||||
f"{repr(parsed_score)} skipped because "
|
||||
f"potential duplicate item {repr(query_score)} found."
|
||||
"%r skipped because potential duplicate item %r found.",
|
||||
parsed_score,
|
||||
query_score,
|
||||
)
|
||||
continue
|
||||
session.add(parsed_score)
|
||||
|
@ -122,7 +122,9 @@ class ArcSongJsonBuilder:
|
||||
|
||||
pack = self.session.scalar(select(Pack).where(Pack.id == song.set))
|
||||
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__")
|
||||
song_localized = self.session.scalar(
|
||||
select(SongLocalized).where(SongLocalized.id == song.id)
|
||||
|
1
src/arcaea_offline/external/chart_info_db/__init__.py
vendored
Normal file
1
src/arcaea_offline/external/chart_info_db/__init__.py
vendored
Normal file
@ -0,0 +1 @@
|
||||
from .parser import ChartInfoDbParser
|
35
src/arcaea_offline/external/chart_info_db/parser.py
vendored
Normal file
35
src/arcaea_offline/external/chart_info_db/parser.py
vendored
Normal 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)
|
@ -1,2 +1,2 @@
|
||||
from . import exporters
|
||||
from .types import ScoreExport
|
||||
from .types import ArcaeaOfflineDEFV2_Score, ScoreExport
|
||||
|
20
src/arcaea_offline/external/exports/exporters.py
vendored
20
src/arcaea_offline/external/exports/exporters.py
vendored
@ -1,5 +1,5 @@
|
||||
from ...models import Score
|
||||
from .types import ScoreExport
|
||||
from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport
|
||||
|
||||
|
||||
def score(score: Score) -> ScoreExport:
|
||||
@ -17,3 +17,21 @@ def score(score: Score) -> ScoreExport:
|
||||
"clear_type": score.clear_type,
|
||||
"comment": score.comment,
|
||||
}
|
||||
|
||||
|
||||
def score_def_v2(score: Score) -> ArcaeaOfflineDEFV2_ScoreItem:
|
||||
return {
|
||||
"id": score.id,
|
||||
"songId": score.song_id,
|
||||
"ratingClass": score.rating_class,
|
||||
"score": score.score,
|
||||
"pure": score.pure,
|
||||
"far": score.far,
|
||||
"lost": score.lost,
|
||||
"date": score.date,
|
||||
"maxRecall": score.max_recall,
|
||||
"modifier": score.modifier,
|
||||
"clearType": score.clear_type,
|
||||
"source": None,
|
||||
"comment": score.comment,
|
||||
}
|
||||
|
31
src/arcaea_offline/external/exports/types.py
vendored
31
src/arcaea_offline/external/exports/types.py
vendored
@ -1,4 +1,4 @@
|
||||
from typing import Optional, TypedDict
|
||||
from typing import List, Literal, Optional, TypedDict
|
||||
|
||||
|
||||
class ScoreExport(TypedDict):
|
||||
@ -14,3 +14,32 @@ class ScoreExport(TypedDict):
|
||||
modifier: Optional[int]
|
||||
clear_type: Optional[int]
|
||||
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],
|
||||
},
|
||||
)
|
||||
|
@ -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]
|
2
src/arcaea_offline/utils/formatters/__init__.py
Normal file
2
src/arcaea_offline/utils/formatters/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .play_result import PlayResultFormatter
|
||||
from .rating_class import RatingClassFormatter
|
143
src/arcaea_offline/utils/formatters/play_result.py
Normal file
143
src/arcaea_offline/utils/formatters/play_result.py
Normal file
@ -0,0 +1,143 @@
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from arcaea_offline.constants.enums import (
|
||||
ArcaeaPlayResultClearType,
|
||||
ArcaeaPlayResultModifier,
|
||||
)
|
||||
from arcaea_offline.constants.play_result import ScoreLowerLimits
|
||||
|
||||
|
||||
class PlayResultFormatter:
|
||||
SCORE_GRADE_FORMAT_RESULTS = Literal["EX+", "EX", "AA", "A", "B", "C", "D"]
|
||||
|
||||
@staticmethod
|
||||
def score_grade(score: int) -> SCORE_GRADE_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns the score grade, e.g. EX+.
|
||||
|
||||
Raises `ValueError` if the score is negative.
|
||||
"""
|
||||
if not isinstance(score, int):
|
||||
raise TypeError(f"Unsupported type {type(score)}, cannot format")
|
||||
|
||||
if score >= ScoreLowerLimits.EX_PLUS:
|
||||
return "EX+"
|
||||
elif score >= ScoreLowerLimits.EX:
|
||||
return "EX"
|
||||
elif score >= ScoreLowerLimits.AA:
|
||||
return "AA"
|
||||
elif score >= ScoreLowerLimits.A:
|
||||
return "A"
|
||||
elif score >= ScoreLowerLimits.B:
|
||||
return "B"
|
||||
elif score >= ScoreLowerLimits.C:
|
||||
return "C"
|
||||
elif score >= ScoreLowerLimits.D:
|
||||
return "D"
|
||||
else:
|
||||
raise ValueError("score cannot be negative")
|
||||
|
||||
CLEAR_TYPE_FORMAT_RESULTS = Literal[
|
||||
"TRACK LOST",
|
||||
"NORMAL CLEAR",
|
||||
"FULL RECALL",
|
||||
"PURE MEMORY",
|
||||
"EASY CLEAR",
|
||||
"HARD CLEAR",
|
||||
"UNKNOWN",
|
||||
"None",
|
||||
]
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def clear_type(
|
||||
cls, clear_type: ArcaeaPlayResultClearType
|
||||
) -> CLEAR_TYPE_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
||||
"""
|
||||
...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def clear_type(cls, clear_type: int) -> CLEAR_TYPE_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
||||
|
||||
The integer will be converted to `ArcaeaPlayResultClearType` enum,
|
||||
and will return "UNKNOWN" if the convertion fails.
|
||||
|
||||
Raises `ValueError` if the integer is negative.
|
||||
"""
|
||||
...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def clear_type(cls, clear_type: None) -> CLEAR_TYPE_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns "None"
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def clear_type(cls, clear_type: Any) -> CLEAR_TYPE_FORMAT_RESULTS:
|
||||
if clear_type is None:
|
||||
return "None"
|
||||
elif isinstance(clear_type, ArcaeaPlayResultClearType):
|
||||
return clear_type.name.replace("_", " ").upper() # type: ignore
|
||||
elif isinstance(clear_type, int):
|
||||
if clear_type < 0:
|
||||
raise ValueError("clear_type cannot be negative")
|
||||
try:
|
||||
return cls.clear_type(ArcaeaPlayResultClearType(clear_type))
|
||||
except ValueError:
|
||||
return "UNKNOWN"
|
||||
else:
|
||||
raise TypeError(f"Unsupported type {type(clear_type)}, cannot format")
|
||||
|
||||
MODIFIER_FORMAT_RESULTS = Literal["NORMAL", "EASY", "HARD", "UNKNOWN", "None"]
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def modifier(cls, modifier: ArcaeaPlayResultModifier) -> MODIFIER_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
||||
"""
|
||||
...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def modifier(cls, modifier: int) -> MODIFIER_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
|
||||
|
||||
The integer will be converted to `ArcaeaPlayResultModifier` enum,
|
||||
and will return "UNKNOWN" if the convertion fails.
|
||||
|
||||
Raises `ValueError` if the integer is negative.
|
||||
"""
|
||||
...
|
||||
|
||||
@overload
|
||||
@classmethod
|
||||
def modifier(cls, modifier: None) -> MODIFIER_FORMAT_RESULTS:
|
||||
"""
|
||||
Returns "None"
|
||||
"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def modifier(cls, modifier: Any) -> MODIFIER_FORMAT_RESULTS:
|
||||
if modifier is None:
|
||||
return "None"
|
||||
elif isinstance(modifier, ArcaeaPlayResultModifier):
|
||||
return modifier.name
|
||||
elif isinstance(modifier, int):
|
||||
if modifier < 0:
|
||||
raise ValueError("modifier cannot be negative")
|
||||
try:
|
||||
return cls.modifier(ArcaeaPlayResultModifier(modifier))
|
||||
except ValueError:
|
||||
return "UNKNOWN"
|
||||
else:
|
||||
raise TypeError(f"Unsupported type {type(modifier)}, cannot format")
|
83
src/arcaea_offline/utils/formatters/rating_class.py
Normal file
83
src/arcaea_offline/utils/formatters/rating_class.py
Normal 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")
|
@ -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)
|
@ -1,46 +0,0 @@
|
||||
from typing import Any, Sequence
|
||||
|
||||
SCORE_GRADE_FLOOR = [9900000, 9800000, 9500000, 9200000, 8900000, 8600000, 0]
|
||||
SCORE_GRADE_TEXTS = ["EX+", "EX", "AA", "A", "B", "C", "D"]
|
||||
MODIFIER_TEXTS = ["NORMAL", "EASY", "HARD"]
|
||||
CLEAR_TYPE_TEXTS = [
|
||||
"TRACK LOST",
|
||||
"NORMAL CLEAR",
|
||||
"FULL RECALL",
|
||||
"PURE MEMORY",
|
||||
"EASY CLEAR",
|
||||
"HARD CLEAR",
|
||||
]
|
||||
|
||||
|
||||
def zip_score_grade(score: int, __seq: Sequence, default: Any = "__PRESERVE__"):
|
||||
"""
|
||||
zip_score_grade is a simple wrapper that equals to:
|
||||
```py
|
||||
for score_floor, val in zip(SCORE_GRADE_FLOOR, __seq):
|
||||
if score >= score_floor:
|
||||
return val
|
||||
return seq[-1] if default == "__PRESERVE__" else default
|
||||
```
|
||||
Could be useful in specific cases.
|
||||
"""
|
||||
return next(
|
||||
(
|
||||
val
|
||||
for score_floor, val in zip(SCORE_GRADE_FLOOR, __seq)
|
||||
if score >= score_floor
|
||||
),
|
||||
__seq[-1] if default == "__PRESERVE__" else default,
|
||||
)
|
||||
|
||||
|
||||
def score_to_grade_text(score: int) -> str:
|
||||
return zip_score_grade(score, SCORE_GRADE_TEXTS)
|
||||
|
||||
|
||||
def modifier_to_text(modifier: int) -> str:
|
||||
return MODIFIER_TEXTS[modifier]
|
||||
|
||||
|
||||
def clear_type_to_text(clear_type: int) -> str:
|
||||
return CLEAR_TYPE_TEXTS[clear_type]
|
@ -1,6 +0,0 @@
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def recover_search_title(db_value: Optional[str]) -> List[str]:
|
||||
return json.loads(db_value) if db_value else []
|
22
tests/calculate/test_world_step.py
Normal file
22
tests/calculate/test_world_step.py
Normal file
@ -0,0 +1,22 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from arcaea_offline.calculate.world_step import (
|
||||
AwakenedAyuPartnerBonus,
|
||||
LegacyMapStepBooster,
|
||||
PlayResult,
|
||||
calculate_step_original,
|
||||
)
|
||||
|
||||
|
||||
def test_world_step():
|
||||
# the result was copied from https://arcaea.fandom.com/wiki/World_Mode_Mechanics#Calculation
|
||||
# CC BY-SA 3.0
|
||||
|
||||
booster = LegacyMapStepBooster(6, 250)
|
||||
partner_bonus = AwakenedAyuPartnerBonus("+3.6")
|
||||
play_result = PlayResult(play_rating=Decimal("11.299"), partner_step=92)
|
||||
result = calculate_step_original(
|
||||
play_result, partner_bonus=partner_bonus, step_booster=booster
|
||||
)
|
||||
|
||||
assert result.quantize(Decimal("0.000")) == Decimal("175.149")
|
0
tests/db/__init__.py
Normal file
0
tests/db/__init__.py
Normal file
5
tests/db/db.py
Normal file
5
tests/db/db.py
Normal file
@ -0,0 +1,5 @@
|
||||
from sqlalchemy import Engine, create_engine, inspect
|
||||
|
||||
|
||||
def create_engine_in_memory():
|
||||
return create_engine("sqlite:///:memory:")
|
0
tests/db/models/__init__.py
Normal file
0
tests/db/models/__init__.py
Normal file
118
tests/db/models/test_songs.py
Normal file
118
tests/db/models/test_songs.py
Normal 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~"
|
126
tests/utils/test_formatters.py
Normal file
126
tests/utils/test_formatters.py
Normal file
@ -0,0 +1,126 @@
|
||||
import pytest
|
||||
|
||||
from arcaea_offline.constants.enums import (
|
||||
ArcaeaPlayResultClearType,
|
||||
ArcaeaPlayResultModifier,
|
||||
ArcaeaRatingClass,
|
||||
)
|
||||
from arcaea_offline.utils.formatters.play_result import PlayResultFormatter
|
||||
from arcaea_offline.utils.formatters.rating_class import RatingClassFormatter
|
||||
|
||||
|
||||
class TestRatingClassFormatter:
|
||||
def test_name(self):
|
||||
assert RatingClassFormatter.name(ArcaeaRatingClass.PAST) == "Past"
|
||||
assert RatingClassFormatter.name(ArcaeaRatingClass.PRESENT) == "Present"
|
||||
assert RatingClassFormatter.name(ArcaeaRatingClass.FUTURE) == "Future"
|
||||
assert RatingClassFormatter.name(ArcaeaRatingClass.BEYOND) == "Beyond"
|
||||
assert RatingClassFormatter.name(ArcaeaRatingClass.ETERNAL) == "Eternal"
|
||||
|
||||
assert RatingClassFormatter.name(2) == "Future"
|
||||
|
||||
assert RatingClassFormatter.name(100) == "Unknown"
|
||||
assert RatingClassFormatter.name(-1) == "Unknown"
|
||||
|
||||
pytest.raises(TypeError, RatingClassFormatter.name, "2")
|
||||
pytest.raises(TypeError, RatingClassFormatter.name, [])
|
||||
pytest.raises(TypeError, RatingClassFormatter.name, None)
|
||||
|
||||
def test_abbreviation(self):
|
||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.PAST) == "PST"
|
||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.PRESENT) == "PRS"
|
||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.FUTURE) == "FTR"
|
||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.BEYOND) == "BYD"
|
||||
assert RatingClassFormatter.abbreviation(ArcaeaRatingClass.ETERNAL) == "ETR"
|
||||
|
||||
assert RatingClassFormatter.abbreviation(2) == "FTR"
|
||||
|
||||
assert RatingClassFormatter.abbreviation(100) == "UNK"
|
||||
assert RatingClassFormatter.abbreviation(-1) == "UNK"
|
||||
|
||||
pytest.raises(TypeError, RatingClassFormatter.abbreviation, "2")
|
||||
pytest.raises(TypeError, RatingClassFormatter.abbreviation, [])
|
||||
pytest.raises(TypeError, RatingClassFormatter.abbreviation, None)
|
||||
|
||||
|
||||
class TestPlayResultFormatter:
|
||||
def test_score_grade(self):
|
||||
assert PlayResultFormatter.score_grade(10001284) == "EX+"
|
||||
assert PlayResultFormatter.score_grade(9989210) == "EX+"
|
||||
assert PlayResultFormatter.score_grade(9900000) == "EX+"
|
||||
|
||||
assert PlayResultFormatter.score_grade(9899999) == "EX"
|
||||
assert PlayResultFormatter.score_grade(9843717) == "EX"
|
||||
assert PlayResultFormatter.score_grade(9800000) == "EX"
|
||||
|
||||
assert PlayResultFormatter.score_grade(9799999) == "AA"
|
||||
assert PlayResultFormatter.score_grade(9794015) == "AA"
|
||||
assert PlayResultFormatter.score_grade(9750000) == "AA"
|
||||
|
||||
assert PlayResultFormatter.score_grade(9499999) == "A"
|
||||
assert PlayResultFormatter.score_grade(9356855) == "A"
|
||||
assert PlayResultFormatter.score_grade(9200000) == "A"
|
||||
|
||||
assert PlayResultFormatter.score_grade(9199999) == "B"
|
||||
assert PlayResultFormatter.score_grade(9065785) == "B"
|
||||
assert PlayResultFormatter.score_grade(8900000) == "B"
|
||||
|
||||
assert PlayResultFormatter.score_grade(8899999) == "C"
|
||||
assert PlayResultFormatter.score_grade(8756211) == "C"
|
||||
assert PlayResultFormatter.score_grade(8600000) == "C"
|
||||
|
||||
assert PlayResultFormatter.score_grade(8599999) == "D"
|
||||
assert PlayResultFormatter.score_grade(5500000) == "D"
|
||||
assert PlayResultFormatter.score_grade(0) == "D"
|
||||
|
||||
pytest.raises(ValueError, PlayResultFormatter.score_grade, -1)
|
||||
pytest.raises(TypeError, PlayResultFormatter.score_grade, "10001284")
|
||||
pytest.raises(TypeError, PlayResultFormatter.score_grade, [])
|
||||
pytest.raises(TypeError, PlayResultFormatter.score_grade, None)
|
||||
|
||||
def test_clear_type(self):
|
||||
assert (
|
||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.TRACK_LOST)
|
||||
== "TRACK LOST"
|
||||
)
|
||||
assert (
|
||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.NORMAL_CLEAR)
|
||||
== "NORMAL CLEAR"
|
||||
)
|
||||
assert (
|
||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.FULL_RECALL)
|
||||
== "FULL RECALL"
|
||||
)
|
||||
assert (
|
||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.PURE_MEMORY)
|
||||
== "PURE MEMORY"
|
||||
)
|
||||
assert (
|
||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.EASY_CLEAR)
|
||||
== "EASY CLEAR"
|
||||
)
|
||||
assert (
|
||||
PlayResultFormatter.clear_type(ArcaeaPlayResultClearType.HARD_CLEAR)
|
||||
== "HARD CLEAR"
|
||||
)
|
||||
assert PlayResultFormatter.clear_type(None) == "None"
|
||||
|
||||
assert PlayResultFormatter.clear_type(1) == "NORMAL CLEAR"
|
||||
assert PlayResultFormatter.clear_type(6) == "UNKNOWN"
|
||||
|
||||
pytest.raises(ValueError, PlayResultFormatter.clear_type, -1)
|
||||
pytest.raises(TypeError, PlayResultFormatter.clear_type, "1")
|
||||
pytest.raises(TypeError, PlayResultFormatter.clear_type, [])
|
||||
|
||||
def test_modifier(self):
|
||||
assert PlayResultFormatter.modifier(ArcaeaPlayResultModifier.NORMAL) == "NORMAL"
|
||||
assert PlayResultFormatter.modifier(ArcaeaPlayResultModifier.EASY) == "EASY"
|
||||
assert PlayResultFormatter.modifier(ArcaeaPlayResultModifier.HARD) == "HARD"
|
||||
assert PlayResultFormatter.modifier(None) == "None"
|
||||
|
||||
assert PlayResultFormatter.modifier(1) == "EASY"
|
||||
assert PlayResultFormatter.modifier(6) == "UNKNOWN"
|
||||
|
||||
pytest.raises(ValueError, PlayResultFormatter.modifier, -1)
|
||||
pytest.raises(TypeError, PlayResultFormatter.modifier, "1")
|
||||
pytest.raises(TypeError, PlayResultFormatter.modifier, [])
|
Reference in New Issue
Block a user