11 Commits

11 changed files with 228 additions and 13 deletions

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "arcaea-offline" name = "arcaea-offline"
version = "0.2.0" version = "0.2.1"
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"

View File

@ -84,11 +84,15 @@ class AndrealImageGeneratorApiDataConverter:
raise ValueError("No score available.") raise ValueError("No score available.")
best30_avg = self.session.scalar(select(CalculatedPotential.b30)) best30_avg = self.session.scalar(select(CalculatedPotential.b30))
best30_overflow = (
[self.score(score) for score in scores[30:40]] if len(scores) > 30 else []
)
return { return {
"content": { "content": {
"account_info": self.account_info(), "account_info": self.account_info(),
"best30_avg": best30_avg, "best30_avg": best30_avg,
"best30_list": [self.score(score) for score in scores[:30]], "best30_list": [self.score(score) for score in scores[:30]],
"best30_overflow": [self.score(score) for score in scores[-10:]], "best30_overflow": best30_overflow,
} }
} }

View File

@ -1,3 +1,4 @@
from .online import ArcaeaOnlineParser
from .packlist import PacklistParser from .packlist import PacklistParser
from .songlist import SonglistDifficultiesParser, SonglistParser from .songlist import SonglistDifficultiesParser, SonglistParser
from .st3 import St3ScoreParser from .st3 import St3ScoreParser

View File

@ -1,11 +1,41 @@
import contextlib import contextlib
import json import json
import math
import time
from os import PathLike from os import PathLike
from typing import Any, List, Optional, Union from typing import Any, List, Optional, Union
from sqlalchemy.orm import DeclarativeBase, Session from sqlalchemy.orm import DeclarativeBase, Session
def fix_timestamp(timestamp: int) -> Union[int, None]:
"""
Some of the `date` column in st3 are strangely truncated. For example,
a `1670283375` may be truncated to `167028`, even `1`. Yes, a single `1`.
To properly handle this situation, we check the timestamp's digits.
If `digits < 5`, we treat this timestamp as a `None`. Otherwise, we try to
fix the timestamp.
:param timestamp: a POSIX timestamp
:return: `None` if the timestamp's digits < 5, otherwise a fixed POSIX timestamp
"""
# find digit length from https://stackoverflow.com/a/2189827/16484891
# CC BY-SA 2.5
# this might give incorrect result when timestamp > 999999999999997,
# see https://stackoverflow.com/a/28883802/16484891 (CC BY-SA 4.0).
# but that's way too later than 9999-12-31 23:59:59, 253402271999,
# I don't think Arcaea would still be an active updated game by then.
# so don't mind those small issues, just use this.
digits = int(math.log10(abs(timestamp))) + 1 if timestamp != 0 else 1
if digits < 5:
return None
timestamp_str = str(timestamp)
current_timestamp_digits = int(math.log10(int(time.time()))) + 1
timestamp_str = timestamp_str.ljust(current_timestamp_digits, "0")
return int(timestamp_str, 10)
def to_db_value(val: Any) -> Any: def to_db_value(val: Any) -> Any:
if not val: if not val:
return None return None

View File

@ -0,0 +1,75 @@
import json
import logging
from datetime import datetime
from typing import Dict, List, Literal, Optional, TypedDict
from ...models import Score
from .common import ArcaeaParser, fix_timestamp
logger = logging.getLogger(__name__)
class TWebApiRatingMeScoreItem(TypedDict):
song_id: str
difficulty: int
modifier: int
rating: float
score: int
perfect_count: int
near_count: int
miss_count: int
clear_type: int
title: Dict[Literal["ja", "en"], str]
artist: str
time_played: int
bg: str
class TWebApiRatingMeValue(TypedDict):
best_rated_scores: List[TWebApiRatingMeScoreItem]
recent_rated_scores: List[TWebApiRatingMeScoreItem]
class TWebApiRatingMeResult(TypedDict):
success: bool
error_code: Optional[int]
value: Optional[TWebApiRatingMeValue]
class ArcaeaOnlineParser(ArcaeaParser):
def __init__(self, filepath):
super().__init__(filepath)
def parse(self) -> List[Score]:
api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text())
api_result_value = api_result_root.get("value")
if not api_result_value:
error_code = api_result_root.get("error_code")
raise ValueError(f"Cannot parse API result, error code {error_code}")
best30_score_items = api_result_value.get("best_rated_scores", [])
recent_score_items = api_result_value.get("recent_rated_scores", [])
score_items = best30_score_items + recent_score_items
date_text = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
results: List[Score] = []
for score_item in score_items:
score = Score()
score.song_id = score_item["song_id"]
score.rating_class = score_item["difficulty"]
score.score = score_item["score"]
score.pure = score_item["perfect_count"]
score.far = score_item["near_count"]
score.lost = score_item["miss_count"]
score.date = fix_timestamp(int(score_item["time_played"] / 1000))
score.modifier = score_item["modifier"]
score.clear_type = score_item["clear_type"]
if score.lost == 0:
score.max_recall = score.pure + score.far
score.comment = f"Parsed from web API at {date_text}"
results.append(score)
return results

View File

@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...models.scores import Score from ...models.scores import Score
from .common import ArcaeaParser from .common import ArcaeaParser, fix_timestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,9 +37,6 @@ class St3ScoreParser(ArcaeaParser):
(song_id, rating_class), (song_id, rating_class),
).fetchone()[0] ).fetchone()[0]
date_str = str(date)
date = None if len(date_str) < 7 else int(date_str.ljust(10, "0"))
items.append( items.append(
Score( Score(
song_id=song_id, song_id=song_id,
@ -48,10 +45,10 @@ class St3ScoreParser(ArcaeaParser):
pure=pure, pure=pure,
far=far, far=far,
lost=lost, lost=lost,
date=date, date=fix_timestamp(date),
modifier=modifier, modifier=modifier,
clear_type=clear_type, clear_type=clear_type,
comment="Imported from st3", comment=f"Parsed from st3",
) )
) )

View File

@ -0,0 +1 @@
from .b30_csv import SmartRteB30CsvConverter

View File

@ -0,0 +1,64 @@
from sqlalchemy.orm import Session
from ...models import Chart, ScoreBest
from ...utils.rating import rating_class_to_text
class SmartRteB30CsvConverter:
CSV_ROWS = [
"songname",
"songId",
"Difficulty",
"score",
"Perfect",
"criticalPerfect",
"Far",
"Lost",
"Constant",
"singlePTT",
]
def __init__(
self,
session: Session,
):
self.session = session
def rows(self) -> list:
csv_rows = [self.CSV_ROWS.copy()]
with self.session as session:
results = (
session.query(
Chart.title,
ScoreBest.song_id,
ScoreBest.rating_class,
ScoreBest.score,
ScoreBest.pure,
ScoreBest.shiny_pure,
ScoreBest.far,
ScoreBest.lost,
Chart.constant,
ScoreBest.potential,
)
.join(
Chart,
(Chart.song_id == ScoreBest.song_id)
& (Chart.rating_class == ScoreBest.rating_class),
)
.all()
)
for result in results:
# replace the comma in song title because the target project
# cannot handle quoted string
result = list(result)
result[0] = result[0].replace(",", "")
result[2] = rating_class_to_text(result[2])
# divide constant to float
result[-2] = result[-2] / 10
# round potential
result[-1] = round(result[-1], 5)
csv_rows.append(result)
return csv_rows

View File

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from sqlalchemy import TEXT, case, func, inspect, select from sqlalchemy import TEXT, case, func, inspect, select, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy_utils import create_view from sqlalchemy_utils import create_view
@ -78,10 +78,21 @@ class ScoreCalculated(ScoresViewBase):
Score.score, Score.score,
Score.pure, Score.pure,
( (
Score.score case(
- func.floor( (
(Score.pure * 10000000.0 / ChartInfo.notes) (
+ (Score.far * 0.5 * 10000000.0 / ChartInfo.notes) ChartInfo.notes.is_not(None)
& Score.pure.is_not(None)
& Score.far.is_not(None)
& (ChartInfo.notes != 0)
),
Score.score
- func.floor(
(Score.pure * 10000000.0 / ChartInfo.notes)
+ (Score.far * 0.5 * 10000000.0 / ChartInfo.notes)
),
),
else_=text("NULL"),
) )
).label("shiny_pure"), ).label("shiny_pure"),
Score.far, Score.far,

View File

@ -0,0 +1,15 @@
from datetime import datetime
from enum import IntEnum
class KanaeDayNight(IntEnum):
Day = 0
Night = 1
def kanae_day_night(timestamp: int) -> KanaeDayNight:
"""
:param timestamp: POSIX timestamp, which is passed to `datetime.fromtimestamp(timestamp)`.
"""
dt = datetime.fromtimestamp(timestamp)
return KanaeDayNight.Day if 6 <= dt.hour <= 19 else KanaeDayNight.Night

View File

@ -2,6 +2,15 @@ from typing import Any, Sequence
SCORE_GRADE_FLOOR = [9900000, 9800000, 9500000, 9200000, 8900000, 8600000, 0] SCORE_GRADE_FLOOR = [9900000, 9800000, 9500000, 9200000, 8900000, 8600000, 0]
SCORE_GRADE_TEXTS = ["EX+", "EX", "AA", "A", "B", "C", "D"] 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__"): def zip_score_grade(score: int, __seq: Sequence, default: Any = "__PRESERVE__"):
@ -27,3 +36,11 @@ def zip_score_grade(score: int, __seq: Sequence, default: Any = "__PRESERVE__"):
def score_to_grade_text(score: int) -> str: def score_to_grade_text(score: int) -> str:
return zip_score_grade(score, SCORE_GRADE_TEXTS) 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]