1
0
mirror of synced 2024-12-02 17:47:18 +01:00
bemaniutils/bemani/backend/ddr/base.py

363 lines
12 KiB
Python

# vim: set fileencoding=utf-8
from typing import Optional, List
from typing_extensions import Final
from bemani.backend.base import Base
from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler
from bemani.common import (
Model,
Profile,
ValidatedDict,
GameConstants,
DBConstants,
Time,
)
from bemani.data import Config, Data, Score, UserID, ScoreSaveException
from bemani.protocol import Node
class DDRBase(CoreHandler, CardManagerHandler, PASELIHandler, Base):
"""
Base game class for all DDR versions. Handles common functionality for getting
profiles based on refid, creating new profiles, looking up and saving scores.
"""
game: GameConstants = GameConstants.DDR
HALO_NONE: Final[int] = DBConstants.DDR_HALO_NONE
HALO_GOOD_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_GOOD_FULL_COMBO
HALO_GREAT_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_GREAT_FULL_COMBO
HALO_PERFECT_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_PERFECT_FULL_COMBO
HALO_MARVELOUS_FULL_COMBO: Final[int] = DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO
RANK_E: Final[int] = DBConstants.DDR_RANK_E
RANK_D: Final[int] = DBConstants.DDR_RANK_D
RANK_D_PLUS: Final[int] = DBConstants.DDR_RANK_D_PLUS
RANK_C_MINUS: Final[int] = DBConstants.DDR_RANK_C_MINUS
RANK_C: Final[int] = DBConstants.DDR_RANK_C
RANK_C_PLUS: Final[int] = DBConstants.DDR_RANK_C_PLUS
RANK_B_MINUS: Final[int] = DBConstants.DDR_RANK_B_MINUS
RANK_B: Final[int] = DBConstants.DDR_RANK_B
RANK_B_PLUS: Final[int] = DBConstants.DDR_RANK_B_PLUS
RANK_A_MINUS: Final[int] = DBConstants.DDR_RANK_A_MINUS
RANK_A: Final[int] = DBConstants.DDR_RANK_A
RANK_A_PLUS: Final[int] = DBConstants.DDR_RANK_A_PLUS
RANK_AA_MINUS: Final[int] = DBConstants.DDR_RANK_AA_MINUS
RANK_AA: Final[int] = DBConstants.DDR_RANK_AA
RANK_AA_PLUS: Final[int] = DBConstants.DDR_RANK_AA_PLUS
RANK_AAA: Final[int] = DBConstants.DDR_RANK_AAA
# These constants must agree with read.py for importing charts from the game.
CHART_SINGLE_BEGINNER: Final[int] = 0
CHART_SINGLE_BASIC: Final[int] = 1
CHART_SINGLE_DIFFICULT: Final[int] = 2
CHART_SINGLE_EXPERT: Final[int] = 3
CHART_SINGLE_CHALLENGE: Final[int] = 4
CHART_DOUBLE_BEGINNER: Final[int] = 5
CHART_DOUBLE_BASIC: Final[int] = 6
CHART_DOUBLE_DIFFICULT: Final[int] = 7
CHART_DOUBLE_EXPERT: Final[int] = 8
CHART_DOUBLE_CHALLENGE: Final[int] = 9
# Return the local2 service so that DDR Ace will send certain packets.
extra_services: List[str] = [
"local2",
]
def __init__(self, data: Data, config: Config, model: Model) -> None:
super().__init__(data, config, model)
if model.rev == "X":
self.omnimix = True
else:
self.omnimix = False
@property
def music_version(self) -> int:
if self.omnimix:
return DBConstants.OMNIMIX_VERSION_BUMP + self.version
return self.version
def game_to_db_rank(self, game_rank: int) -> int:
"""
Given a game's rank constant, return the rank as defined above.
"""
raise Exception("Implement in sub-class!")
def db_to_game_rank(self, db_rank: int) -> int:
"""
Given a rank as defined above, return the game's rank constant.
"""
raise Exception("Implement in sub-class!")
def game_to_db_chart(self, game_chart: int) -> int:
"""
Given a game's chart for a song, return the chart as defined above.
"""
raise Exception("Implement in sub-class!")
def db_to_game_chart(self, db_chart: int) -> int:
"""
Given a chart as defined above, return the game's chart constant.
"""
raise Exception("Implement in sub-class!")
def game_to_db_halo(self, game_halo: int) -> int:
"""
Given a game's halo constant, return the halo as defined above.
"""
raise Exception("Implement in sub-class!")
def db_to_game_halo(self, db_halo: int) -> int:
"""
Given a halo as defined above, return the game's halo constant.
"""
raise Exception("Implement in sub-class!")
def previous_version(self) -> Optional["DDRBase"]:
"""
Returns the previous version of the game, based on this game. Should
be overridden.
"""
return None
def format_profile(self, userid: UserID, profile: Profile) -> Node:
"""
Base handler for a profile. Given a userid and a profile dictionary,
return a Node representing a profile. Should be overridden.
"""
return Node.void("game")
def format_scores(
self, userid: UserID, profile: Profile, scores: List[Score]
) -> Node:
"""
Base handler for a score list. Given a userid, profile and a score list,
return a Node representing a score list. Should be overridden.
"""
return Node.void("game")
def unformat_profile(
self, userid: UserID, request: Node, oldprofile: Profile
) -> Profile:
"""
Base handler for profile parsing. Given a request and an old profile,
return a new profile that's been updated with the contents of the request.
Should be overridden.
"""
return oldprofile
def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]:
"""
Given a RefID, return a formatted profile node. Basically every game
needs a profile lookup, even if it handles where that happens in
a different request. This is provided for code deduplication.
"""
if refid is None:
return None
# First try to load the actual profile
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
profile = self.get_profile(userid)
if profile is None:
return None
# Now, return it
return self.format_profile(userid, profile)
def new_profile_by_refid(
self, refid: Optional[str], name: Optional[str], area: Optional[int]
) -> None:
"""
Given a RefID and a name/area, create a new profile.
"""
if refid is None:
return
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
if userid is None:
return
defaultprofile = Profile(
self.game,
self.version,
refid,
0,
{
"name": name,
"area": area,
},
)
self.put_profile(userid, defaultprofile)
def put_profile_by_refid(self, refid: Optional[str], request: Node) -> None:
"""
Given a RefID and a request node, unformat the profile and save it.
"""
if refid is None:
return
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
if userid is None:
return
oldprofile = self.get_profile(userid)
newprofile = self.unformat_profile(userid, request, oldprofile)
if newprofile is not None:
self.put_profile(userid, newprofile)
def update_score(
self,
userid: Optional[UserID],
songid: int,
chart: int,
points: int,
rank: int,
halo: int,
combo: int,
trace: Optional[List[int]] = None,
ghost: Optional[str] = None,
) -> None:
"""
Given various pieces of a score, update the user's high score and score
history in a controlled manner, so all games in DDR series can expect
the same attributes in a score.
"""
if chart not in [
self.CHART_SINGLE_BEGINNER,
self.CHART_SINGLE_BASIC,
self.CHART_SINGLE_DIFFICULT,
self.CHART_SINGLE_EXPERT,
self.CHART_SINGLE_CHALLENGE,
self.CHART_DOUBLE_BEGINNER,
self.CHART_DOUBLE_BASIC,
self.CHART_DOUBLE_DIFFICULT,
self.CHART_DOUBLE_EXPERT,
self.CHART_DOUBLE_CHALLENGE,
]:
raise Exception(f"Invalid chart {chart}")
if halo not in [
self.HALO_NONE,
self.HALO_GOOD_FULL_COMBO,
self.HALO_GREAT_FULL_COMBO,
self.HALO_PERFECT_FULL_COMBO,
self.HALO_MARVELOUS_FULL_COMBO,
]:
raise Exception(f"Invalid halo {halo}")
if rank not in [
self.RANK_E,
self.RANK_D,
self.RANK_D_PLUS,
self.RANK_C_MINUS,
self.RANK_C,
self.RANK_C_PLUS,
self.RANK_B_MINUS,
self.RANK_B,
self.RANK_B_PLUS,
self.RANK_A_MINUS,
self.RANK_A,
self.RANK_A_PLUS,
self.RANK_AA_MINUS,
self.RANK_AA,
self.RANK_AA_PLUS,
self.RANK_AAA,
]:
raise Exception(f"Invalid rank {rank}")
if userid is not None:
oldscore = self.data.local.music.get_score(
self.game,
self.music_version,
userid,
songid,
chart,
)
else:
oldscore = None
# Score history is verbatum, instead of highest score
now = Time.now()
history = ValidatedDict({})
oldpoints = points
if oldscore is None:
# If it is a new score, create a new dictionary to add to
scoredata = ValidatedDict({})
raised = True
highscore = True
else:
# Set the score to any new record achieved
raised = points > oldscore.points
highscore = points >= oldscore.points
points = max(oldscore.points, points)
scoredata = oldscore.data
# Save combo
history.replace_int("combo", combo)
scoredata.replace_int("combo", max(scoredata.get_int("combo"), combo))
# Save halo
history.replace_int("halo", halo)
scoredata.replace_int("halo", max(scoredata.get_int("halo"), halo))
# Save rank
history.replace_int("rank", rank)
scoredata.replace_int("rank", max(scoredata.get_int("rank"), rank))
# Save ghost steps
if trace is not None:
history.replace_int_array("trace", len(trace), trace)
if raised:
scoredata.replace_int_array("trace", len(trace), trace)
if ghost is not None:
history.replace_str("ghost", ghost)
if raised:
scoredata.replace_str("ghost", ghost)
# Look up where this score was earned
lid = self.get_machine_id()
# DDR sometimes happens to send all songs that were played by a player
# at the end of the round. It sends timestamps for the songs, but as of
# Colette they were identical for each song in the round. So, if a user
# plays the same song/chart# more than once in a round, we will end up
# failing to store the attempt since we don't allow two of the same
# attempt at the same time for the same user and song/chart. So, bump
# the timestamp by one second and retry well past the maximum number of
# songs.
for bump in range(10):
timestamp = now + bump
if userid is not None:
# Write the new score back
self.data.local.music.put_score(
self.game,
self.music_version,
userid,
songid,
chart,
lid,
points,
scoredata,
highscore,
timestamp=timestamp,
)
try:
# Save the history of this score too
self.data.local.music.put_attempt(
self.game,
self.music_version,
userid,
songid,
chart,
lid,
oldpoints,
history,
raised,
timestamp=timestamp,
)
except ScoreSaveException:
# Try again one second in the future
continue
# We saved successfully
break