async def read_match(self) -> Match: """Read an osu! match from the internal buffer.""" m = Match() # ignore match id (i16) and inprogress (i8). self._buf = self._buf[3:] m.type = MatchTypes(await self.read_i8()) m.mods = Mods(await self.read_i32()) m.name = await self.read_string() m.passwd = await self.read_string() # TODO: don't do this, do it like everyone else.. # ignore the map's name, we're going # to get all it's info from the md5. await self.read_string() map_id = await self.read_i32() map_md5 = await self.read_string() m.bmap = await Beatmap.from_md5(map_md5) if not m.bmap and map_id != (1 << 32) - 1: # if they pick an unsubmitted map, # just give them vivid [insane] lol. vivid_md5 = '1cf5b2c2edfafd055536d2cefcb89c0e' m.bmap = await Beatmap.from_md5(vivid_md5) for slot in m.slots: slot.status = await self.read_i8() for slot in m.slots: slot.team = Teams(await self.read_i8()) for slot in m.slots: if slot.status & SlotStatus.has_player: # we don't need this, ignore it. self._buf = self._buf[4:] host_id = await self.read_i32() m.host = await glob.players.get_by_id(host_id) m.mode = GameMode(await self.read_i8()) m.match_scoring = MatchScoringTypes(await self.read_i8()) m.team_type = MatchTeamTypes(await self.read_i8()) m.freemods = await self.read_i8() == 1 # if we're in freemods mode, # read individual slot mods. if m.freemods: for slot in m.slots: slot.mods = Mods(await self.read_i32()) # read the seed (used for mania) m.seed = await self.read_i32() return m
async def read_match(self) -> Match: """Read an osu! match from the internal buffer.""" m = Match() # ignore match id (i16) and inprogress (i8). self._buf = self._buf[3:] #m.type = MatchTypes(await self.read_i8()) if await self.read_i8() == 1: point_of_interest() # what is powerplay m.mods = Mods(await self.read_i32()) m.name = await self.read_string() m.passwd = await self.read_string() m.map_name = await self.read_string() m.map_id = await self.read_i32() m.map_md5 = await self.read_string() for slot in m.slots: slot.status = await self.read_i8() for slot in m.slots: slot.team = MatchTeams(await self.read_i8()) for slot in m.slots: if slot.status & SlotStatus.has_player: # we don't need this, ignore it. self._buf = self._buf[4:] host_id = await self.read_i32() m.host = await glob.players.get(id=host_id) m.mode = GameMode(await self.read_i8()) m.win_condition = MatchWinConditions(await self.read_i8()) m.team_type = MatchTeamTypes(await self.read_i8()) m.freemods = await self.read_i8() == 1 # if we're in freemods mode, # read individual slot mods. if m.freemods: for slot in m.slots: slot.mods = Mods(await self.read_i32()) # read the seed (used for mania) m.seed = await self.read_i32() return m
async def mods(p: Player, c: Messageable, msg: Sequence[str]) -> str: if isinstance(c, Channel) or c.id != 1: return 'This command can only be used in DM with Aika.' if not p.last_np: return 'Please /np a map first!' msg = ''.join(msg).replace(' ', '') if msg[0] == '+': # remove + msg = msg[1:] mods = Mods.from_str(msg) if mods not in p.last_np.pp_cache: # cach await p.last_np.cache_pp(mods) # Since this is a DM to the bot, we should # send back a list of general PP values. # TODO: !acc and !mods in commands to # modify these values :P _msg = [p.last_np.embed] if mods: _msg.append(f'{mods!r}') msg = f"{' '.join(_msg)}: " + ' | '.join( f'{acc}%: {pp:.2f}pp' for acc, pp in zip( (90, 95, 98, 99, 100), p.last_np.pp_cache[mods] )) return msg
async def maps_from_sql(self) -> None: """Retrieve all maps from sql to populate `self.maps`.""" query = ('SELECT map_id, mods, slot ' 'FROM tourney_pool_maps ' 'WHERE pool_id = %s') for row in await glob.db.fetchall(query, [self.id]): map_id = row['map_id'] bmap = await Beatmap.from_bid(map_id) if not bmap: # map not found? remove it from the # pool and log this incident to console. # NOTE: it's intentional that this removes # it from not only this pool, but all pools. # TODO: perhaps discord webhook? log(f'Removing {map_id} from pool {self.name} (not found).', Ansi.LRED) await glob.db.execute( 'DELETE FROM tourney_pool_maps ' 'WHERE map_id = %s', [map_id]) continue key = (Mods(row['mods']), row['slot']) self.maps[key] = bmap
async def maps_from_sql(self, db_cursor: aiomysql.DictCursor) -> None: """Retrieve all maps from sql to populate `self.maps`.""" await db_cursor.execute( "SELECT map_id, mods, slot FROM tourney_pool_maps WHERE pool_id = %s", [self.id], ) async for row in db_cursor: map_id = row["map_id"] bmap = await Beatmap.from_bid(map_id) if not bmap: # map not found? remove it from the # pool and log this incident to console. # NOTE: it's intentional that this removes # it from not only this pool, but all pools. # TODO: perhaps discord webhook? log(f"Removing {map_id} from pool {self.name} (not found).", Ansi.LRED) await db_cursor.execute( "DELETE FROM tourney_pool_maps WHERE map_id = %s", [map_id], ) continue key: tuple[Mods, int] = (Mods(row["mods"]), row["slot"]) self.maps[key] = bmap
def read_match(self) -> Match: """Read an osu! match from the internal buffer.""" m = Match() # ignore match id (i16) and inprogress (i8). self.body_view = self.body_view[3:] self.read_i8() # powerplay unused m.mods = Mods(self.read_i32()) m.name = self.read_string() m.passwd = self.read_string() m.map_name = self.read_string() m.map_id = self.read_i32() m.map_md5 = self.read_string() for slot in m.slots: slot.status = SlotStatus(self.read_i8()) for slot in m.slots: slot.team = MatchTeams(self.read_i8()) for slot in m.slots: if slot.status & SlotStatus.has_player: # we don't need this, ignore it. self.body_view = self.body_view[4:] host_id = self.read_i32() m.host = glob.players.get(id=host_id) m.mode = GameMode(self.read_i8()) m.win_condition = MatchWinConditions(self.read_i8()) m.team_type = MatchTeamTypes(self.read_i8()) m.freemods = self.read_i8() == 1 # if we're in freemods mode, # read individual slot mods. if m.freemods: for slot in m.slots: slot.mods = Mods(self.read_i32()) # read the seed (used for mania) m.seed = self.read_i32() return m
async def recalc(p: 'Player', c: Messageable, msg: Sequence[str]) -> str: """Performs a full PP recalc on a specified map, or all maps.""" if len(msg) != 1 or msg[0] not in ('map', 'all'): return 'Invalid syntax: !recalc <map/all>' score_counts = [] # keep track of # of scores recalced if msg[0] == 'map': # recalculate all scores on their last /np'ed map. if not p.last_np: return 'You must /np a map first!' ppcalc = await PPCalculator.from_id(p.last_np.id) if not ppcalc: return 'Could not retrieve map file.' await c.send(glob.bot, f'Performing full recalc on {p.last_np.embed}.') for table in ('scores_vn', 'scores_rx', 'scores_ap'): # fetch all scores from the table on this map scores = await glob.db.fetchall( 'SELECT id, acc, mods, max_combo, ' 'n300, n100, n50, nmiss, ngeki, nkatu ' f'FROM {table} WHERE map_md5 = %s ' 'AND status = 2 AND mode = 0', [p.last_np.md5] ) score_counts.append(len(scores)) if not scores: continue for score in scores: ppcalc.mods = Mods(score['mods']) ppcalc.combo = score['max_combo'] ppcalc.nmiss = score['nmiss'] ppcalc.acc = score['acc'] pp, _ = await ppcalc.perform() # sr not needed await glob.db.execute( f'UPDATE {table} ' 'SET pp = %s ' 'WHERE id = %s', [pp, score['id']] ) else: # recalculate all scores on every map if not p.priv & Privileges.Dangerous: return 'This command is limited to developers.' return 'TODO' recap = '{0} vn | {1} rx | {2} ap'.format(*score_counts) return f'Recalculated {sum(score_counts)} ({recap}) scores.'
async def from_sql( cls, sid: int, table: str, sort: str, t: int, ensure: bool = False, ) -> Optional["Score"]: score = await glob.db.fetchrow(f"SELECT * FROM {table} WHERE id = %s", [sid]) if not score: return self = cls() self.id = sid self.map = await Beatmap.from_md5(score["md5"]) if not self.map: return # ? self.user = await glob.players.get(id=score["uid"], sql=ensure) if not self.user: return self self.pp = score["pp"] self.score = score["score"] self.combo = score["combo"] self.mods = Mods(score["mods"]) self.acc = score["acc"] self.n300 = score["n300"] self.n100 = score["n100"] self.n50 = score["n50"] self.miss = score["miss"] self.geki = score["geki"] self.katu = score["katu"] self.grade = score["grade"] self.fc = score["fc"] self.status = scoreStatuses(score["status"]) self.mode = lbModes(score["mode"], self.mods) self.time = score["time"] self.passed = self.status.value != 0 if not self.user.restricted: self.rank = await self.calc_lb(table, sort, t) else: self.rank = 0 self.osuver = score["osuver"] return self
async def from_sql(cls, sid: int, table: str, sort: str, t: int, ensure: bool = False) -> Optional['Score']: score = await glob.db.fetchrow(f'SELECT * FROM {table} WHERE id = %s', [sid]) if not score: return self = cls() self.id = sid self.map = await Beatmap.from_md5(score['md5']) if not self.map: return # ? self.user = await glob.players.get(id=score['uid'], sql=ensure) if not self.user: return self self.pp = score['pp'] self.score = score['score'] self.combo = score['combo'] self.mods = Mods(score['mods']) self.acc = score['acc'] self.n300 = score['n300'] self.n100 = score['n100'] self.n50 = score['n50'] self.miss = score['miss'] self.geki = score['geki'] self.katu = score['katu'] self.grade = score['grade'] self.fc = score['fc'] self.status = scoreStatuses(score['status']) self.mode = lbModes(score['mode'].as_vn, self.mods) self.time = score['time'] self.passed = self.status.value != 0 if not self.user.restricted: self.rank = await self.calc_lb(table, sort, t) else: self.rank = 0 self.osuver = score['osuver'] return self
def update(self, action: int, info_text: str, map_md5: str, mods: int, mode: int, map_id: int) -> None: """Fully overwrite the class with new params.""" # osu! sends both map id and md5, but # we'll only need one since we fetch a # beatmap obj from cache/sql anyways.. self.action = Action(action) self.info_text = info_text self.map_md5 = map_md5 self.mods = Mods(mods) self.mode = GameMode.from_params(mode, self.mods) self.map_id = map_id
async def maps_from_sql(self) -> None: """Retrieve all maps from sql to populate `self.maps`.""" query = ('SELECT map_id, mods, slot ' 'FROM tourney_pool_maps ' 'WHERE pool_id = %s') async for row in glob.db.iterall(query, [self.id]): key = (Mods(row['mods']), row['slot']) bmap = await Beatmap.from_bid(row['map_id']) # TODO: should prolly delete the map from pool and # inform eventually webhook to disc if not found? self.maps[key] = bmap
async def _with(p: Player, c: Messageable, msg: Sequence[str]) -> str: """Specify custom accuracy & mod combinations with `/np`.""" if isinstance(c, Channel) or c.id != 1: return 'This command can only be used in DM with Aika.' if not p.last_np: return 'Please /np a map first!' # +?<mods> <acc>%? if 1 < len(msg) > 2: return 'Invalid syntax: !with <mods/acc> ...' mods = acc = None for param in (p.strip('+%') for p in msg): if cmyui._isdecimal(param, _float=True): acc = float(param) elif ~len(param) & 1: # len(param) % 2 == 0 mods = Mods.from_str(param) else: return 'Invalid syntax: !with <mods/acc> ...' _msg = [p.last_np.embed] if not mods: mods = Mods.NOMOD _msg.append(repr(mods)) if acc: # they're requesting pp for specified acc value. async with Owoppai(p.last_np.id, acc=acc, mods=mods) as owo: await owo.calc() pp_values = [(owo.acc, owo.pp)] else: # they're requesting pp for general accuracy values. if mods not in p.last_np.pp_cache: # cache await p.last_np.cache_pp(mods) pp_values = zip( (90, 95, 98, 99, 100), p.last_np.pp_cache[mods] ) pp_msg = ' | '.join(f'{acc:.2f}%: {pp:.2f}pp' for acc, pp in pp_values) return f"{' '.join(_msg)}: {pp_msg}"
async def handle(self, p: Player) -> None: # update the user's status. p.status.action = Action(self.action) p.status.info_text = self.info_text p.status.map_md5 = self.map_md5 p.status.mods = Mods(self.mods) if p.status.mods & Mods.RELAX: self.mode += 4 elif p.status.mods & Mods.AUTOPILOT: self.mode = 7 p.status.mode = GameMode(self.mode) p.status.map_id = self.map_id # broadcast it to all online players. glob.players.enqueue(packets.userStats(p))
async def from_submission(cls, data: list[str]) -> "Score": """Create a score object from an osu! submission string.""" s = cls() """ parse the following format # 0 online_checksum # 1 n300 # 2 n100 # 3 n50 # 4 ngeki # 5 nkatu # 6 nmiss # 7 score # 8 max_combo # 9 perfect # 10 grade # 11 mods # 12 passed # 13 gamemode # 14 play_time # yyMMddHHmmss # 15 osu_version + (" " * client_flags) """ s.online_checksum = data[0] s.n300, s.n100, s.n50, s.ngeki, s.nkatu, s.nmiss, s.score, s.max_combo = map( int, data[1:9], ) s.perfect = data[9] == "True" _grade = data[10] # letter grade s.mods = Mods(int(data[11])) s.passed = data[12] == "True" s.mode = GameMode.from_params(int(data[13]), s.mods) # TODO: we might want to use data[14] to get more # accurate submission time (client side) but # we'd probably want to check if it's close. s.play_time = datetime.now() s.client_flags = ClientFlags(data[15].count(" ") & ~4) s.grade = Grade.from_str(_grade) if s.passed else Grade.F return s
async def mp_mods(p: Player, m: Match, msg: Sequence[str]) -> str: """Set `m`'s mods, from string form.""" if len(msg) != 1 or not ~len(msg[0]) & 1: # len(msg[0]) % 2 == 0 return 'Invalid syntax: !mp mods <mods>' mods = Mods.from_str(msg[0]) if m.freemods: if p.id == m.host.id: # allow host to set speed-changing mods. m.mods = mods & Mods.SPEED_CHANGING # set slot mods m.get_slot(p).mods = mods & ~Mods.SPEED_CHANGING else: # not freemods, set match mods. m.mods = mods m.enqueue(packets.updateMatch(m)) return 'Match mods updated.'
async def mp_mods(p: 'Player', m: 'Match', msg: Sequence[str]) -> str: """Set the current match's mods, from string form.""" if len(msg) != 1 or not ~len(msg[0]) & 1: return 'Invalid syntax: !mp mods <mods>' mods = Mods.from_str(msg[0]) if m.freemods: if p is m.host: # allow host to set speed-changing mods. m.mods = mods & Mods.SPEED_CHANGING # set slot mods m.get_slot(p).mods = mods & ~Mods.SPEED_CHANGING else: # not freemods, set match mods. m.mods = mods m.enqueue_state() return 'Match mods updated.'
async def _with(p: 'Player', c: Messageable, msg: Sequence[str]) -> str: """Specify custom accuracy & mod combinations with `/np`.""" if c is not glob.bot: return 'This command can only be used in DM with Aika.' if not p.last_np: return 'Please /np a map first!' # +?<mods> <acc>%? if 1 < len(msg) > 2: return 'Invalid syntax: !with <mods/acc> ...' mods = acc = None for param in (p.strip('+%') for p in msg): if cmyui._isdecimal(param, _float=True): if not 0 <= (acc := float(param)) <= 100: return 'Invalid accuracy.' elif ~len(param) & 1: # len(param) % 2 == 0 mods = Mods.from_str(param)
async def from_sql(cls, scoreid: int, scores_table: str) -> Optional['Score']: """Create a score object from sql using it's scoreid.""" # XXX: perhaps in the future this should take a gamemode rather # than just the sql table? just faster on the current setup :P res = await glob.db.fetch( 'SELECT id, map_md5, userid, pp, score, ' 'max_combo, mods, acc, n300, n100, n50, ' 'nmiss, ngeki, nkatu, grade, perfect, ' 'status, mode, play_time, ' 'time_elapsed, client_flags, online_checksum ' f'FROM {scores_table} WHERE id = %s', [scoreid], _dict=False) if not res: return s = cls() s.id = res[0] s.bmap = await Beatmap.from_md5(res[1]) s.player = await glob.players.get_ensure(id=res[2]) (s.pp, s.score, s.max_combo, s.mods, s.acc, s.n300, s.n100, s.n50, s.nmiss, s.ngeki, s.nkatu, s.grade, s.perfect, s.status, mode_vn, s.play_time, s.time_elapsed, s.client_flags, s.online_checksum) = res[3:] # fix some types s.passed = s.status != 0 s.status = SubmissionStatus(s.status) s.grade = Grade.from_str(s.grade) s.mods = Mods(s.mods) s.mode = GameMode.from_params(mode_vn, s.mods) s.client_flags = ClientFlags(s.client_flags) if s.bmap: s.rank = await s.calc_lb_placement() return s
class SendPrivateMessage(BanchoPacket, type=Packets.OSU_SEND_PRIVATE_MESSAGE): msg: osuTypes.message async def handle(self, p: Player) -> None: if p.silenced: log(f'{p} tried to send a dm while silenced.', Ansi.YELLOW) return msg = self.msg.msg target = self.msg.target # allow this to get from sql - players can receive # messages offline, due to the mail system. B) if not (t := await glob.players.get(name=target, sql=True)): log(f'{p} tried to write to non-existent user {target}.', Ansi.YELLOW) return if t.pm_private and p.id not in t.friends: p.enqueue(packets.userDMBlocked(target)) log(f'{p} tried to message {t}, but they are blocking dms.') return if t.silenced: # if target is silenced, inform player. p.enqueue(packets.targetSilenced(target)) log(f'{p} tried to message {t}, but they are silenced.') return msg = f'{msg[:2045]}...' if msg[2048:] else msg if t.status.action == Action.Afk and t.away_msg: # send away message if target is afk and has one set. await p.send(t, t.away_msg) if t is glob.bot: # may have a command in the message. cmd = (msg.startswith(glob.config.command_prefix) and await commands.process_commands(p, t, msg)) if cmd: # command triggered, send response if any. if 'resp' in cmd: await p.send(t, cmd['resp']) else: # no commands triggered. if match := regexes.now_playing.match(msg): # user is /np'ing a map. # save it to their player instance # so we can use this elsewhere owo.. p.last_np = await Beatmap.from_bid(int(match['bid'])) if p.last_np: if match['mods']: # [1:] to remove leading whitespace mods = Mods.from_np(match['mods'][1:]) else: mods = Mods.NOMOD if mods not in p.last_np.pp_cache: await p.last_np.cache_pp(mods) # since this is a DM to the bot, we should # send back a list of general PP values. # TODO: !acc and !mods in commands to # modify these values :P _msg = [p.last_np.embed] if mods: _msg.append(f'+{mods!r}') msg = f"{' '.join(_msg)}: " + ' | '.join([ f'{acc}%: {pp:.2f}pp' for acc, pp in zip((90, 95, 98, 99, 100), p.last_np.pp_cache[mods]) ]) else: msg = 'Could not find map.' await p.send(t, msg)
class SendPrivateMessage(BanchoPacket, type=Packets.OSU_SEND_PRIVATE_MESSAGE): msg: osuTypes.message async def handle(self, p: Player) -> None: if p.silenced: log(f'{p} tried to send a dm while silenced.', Ansi.LYELLOW) return # remove leading/trailing whitespace msg = self.msg.msg.strip() t_name = self.msg.target # allow this to get from sql - players can receive # messages offline, due to the mail system. B) if not (t := await glob.players.get_ensure(name=t_name)): log(f'{p} tried to write to non-existent user {t_name}.', Ansi.LYELLOW) return if t.pm_private and p.id not in t.friends: p.enqueue(packets.userDMBlocked(t_name)) log(f'{p} tried to message {t}, but they are blocking dms.') return if t.silenced: # if target is silenced, inform player. p.enqueue(packets.targetSilenced(t_name)) log(f'{p} tried to message {t}, but they are silenced.') return # limit message length to 2k chars # perhaps this could be dangerous with !py..? if len(msg) > 2000: msg = f'{msg[:2000]}... (truncated)' p.enqueue( packets.notification('Your message was truncated\n' '(exceeded 2000 characters).')) if t.status.action == Action.Afk and t.away_msg: # send away message if target is afk and has one set. p.send(t.away_msg, sender=t) if t is glob.bot: # may have a command in the message. cmd = (msg.startswith(glob.config.command_prefix) and await commands.process_commands(p, t, msg)) if cmd: # command triggered, send response if any. if 'resp' in cmd: p.send(cmd['resp'], sender=t) else: # no commands triggered. if match := regexes.now_playing.match(msg): # user is /np'ing a map. # save it to their player instance # so we can use this elsewhere owo.. bmap = await Beatmap.from_bid(int(match['bid'])) if bmap: # parse mode_vn int from regex if match['mode_vn'] is not None: mode_vn = { 'Taiko': 1, 'CatchTheBeat': 2, 'osu!mania': 3 }[match['mode_vn']] else: # use beatmap mode if not specified mode_vn = bmap.mode.as_vanilla p.last_np = { 'bmap': bmap, 'mode_vn': mode_vn, 'timeout': time.time() + 300 # 5mins } # calc pp if possible if not glob.oppai_built: msg = 'No oppai-ng binary was found at startup.' elif mode_vn not in (0, 1): msg = 'PP not yet supported for that mode.' else: if match['mods'] is not None: # [1:] to remove leading whitespace mods = Mods.from_np(match['mods'][1:], mode_vn) else: mods = Mods.NOMOD if mods not in bmap.pp_cache: await bmap.cache_pp(mods) # since this is a DM to the bot, we should # send back a list of general PP values. _msg = [bmap.embed] if mods: _msg.append(f'+{mods!r}') msg = f"{' '.join(_msg)}: " + ' | '.join([ f'{acc}%: {pp:.2f}pp' for acc, pp in zip((90, 95, 98, 99, 100), bmap.pp_cache[mods]) ]) else: msg = 'Could not find map.' # time out their previous /np p.last_np['timeout'] = 0 p.send(msg, sender=t)
class Score: __slots__ = ('id', 'map', 'user', 'score', 'acc', 'n300', 'n100', 'n50', 'miss', 'geki', 'katu', 'grade', 'mods', 'readable_mods', 'combo', 'mode', 'rank', 'pp', 'sr', 'fc', 'passed', 'status', 'time', 'old_best', 'osuver', 'ur') def __init__(self) -> None: self.id: Optional[int] = None self.map: Optional[Beatmap] = None self.user: Optional[Player] = None self.score: Optional[int] = None self.acc: Optional[float] = None self.n300: Optional[int] = None self.n100: Optional[int] = None self.n50: Optional[int] = None self.miss: Optional[int] = None self.geki: Optional[int] = None self.katu: Optional[int] = None self.grade: Optional[Grade] = None self.mods: Optional[Mods] = None self.readable_mods: Optional[str] = None self.combo: Optional[int] = None self.mode: Optional[osuModes] = None self.rank: Optional[int] = None self.pp: Optional[float] = None self.sr: Optional[float] = None self.fc: Optional[bool] = None self.passed: Optional[bool] = None self.status: Optional[scoreStatuses] = None self.time: Optional[int] = None self.old_best: Optional[Score] = None self.osuver: Optional[int] = None self.ur: Optional[float] = None async def format(self) -> str: msg = ( f'{self.user.name} | {self.map.name} +{self.readable_mods} {self.acc:.2f}% ' f'{"FC" if not self.miss else f"{self.miss}xMiss"} {self.pp:,.0f}pp' ) if self.miss: fc_score = copy.copy(self) fc_score.fc = True fc_score.combo = 0 # oppai will take max combo pp, _ = await fc_score.calc_pp(self.mode.as_vn) msg += f' (~{round(pp):,}pp for FC)' if self.mode.value == 0 and self.ur: msg += f' | {self.ur:.2f} (cv)UR' return msg @classmethod async def from_sql(cls, sid: int, table: str, sort: str, t: int, ensure: bool = False) -> Optional['Score']: score = await glob.db.fetchrow(f'SELECT * FROM {table} WHERE id = %s', [sid]) if not score: return self = cls() self.id = sid self.map = await Beatmap.from_md5(score['md5']) if not self.map: return # ? self.user = await glob.players.get(id=score['uid'], sql=ensure) if not self.user: return self self.pp = score['pp'] self.score = score['score'] self.combo = score['combo'] self.mods = Mods(score['mods']) self.acc = score['acc'] self.n300 = score['n300'] self.n100 = score['n100'] self.n50 = score['n50'] self.miss = score['miss'] self.geki = score['geki'] self.katu = score['katu'] self.grade = score['grade'] self.fc = score['fc'] self.status = scoreStatuses(score['status']) self.mode = lbModes(score['mode'].as_vn, self.mods) self.time = score['time'] self.passed = self.status.value != 0 if not self.user.restricted: self.rank = await self.calc_lb(table, sort, t) else: self.rank = 0 self.osuver = score['osuver'] return self @classmethod async def from_submission(cls, base: str, iv: str, pw: str, ver: str) -> Optional['Score']: rijndael = RijndaelCbc( # much better f**k one liners key=f'osu!-scoreburgr---------{ver}', iv=b64decode(iv), padding=Pkcs7Padding(32), block_size=32) data = rijndael.decrypt(b64decode(base)).decode().split(':') self = cls() self.map = await Beatmap.from_md5(data[0]) if (u := await glob.players.get(name=data[1].rstrip())) and u.pw == pw: self.user = u if not self.user: return self # even if user isnt found, may be related to connection and we want to tell the client to retry if not self.map: return # ?? # i wanted to make everything be set in the same order as init but some require all score info to exist first so sadly not :c self.score = int(data[9]) self.n300 = int(data[3]) self.n100 = int(data[4]) self.n50 = int(data[5]) self.miss = int(data[8]) self.geki = int(data[6]) self.katu = int(data[7]) self.mods = Mods(int(data[13])) self.readable_mods = repr(Mods(int(data[13]))) self.combo = int(data[10]) self.mode = lbModes(int(data[15]), self.mods) self.fc = data[11] == 'True' # WHY IS OSU GIVING STRING FOR BOOL!!!!!! self.passed = data[14] == 'True' # AGAIN OSU WHY!!!! self.time = round( time.time()) # have to add round cast cus it gives float smh self.grade = data[12] if self.passed else 'F' await self.calc_info() self.pp, self.sr = await self.calc_pp(self.mode.as_vn) await self.score_order() if self.user.restricted: self.rank = 0 self.osuver = float(re.sub("[^0-9]", "", ver)) # lol return self
and await commands.process_commands(p, t, msg) if cmd and 'resp' in cmd: # Command triggered and there is a response to send. p.enqueue(await packets.sendMessage(t.name, cmd['resp'], client, t.id)) else: # No command triggered. if match := regexes.now_playing.match(msg): # User is /np'ing a map. # Save it to their player instance # so we can use this elsewhere owo.. p.last_np = await Beatmap.from_bid(int(match['bid'])) if p.last_np: if match['mods']: # [1:] to remove leading whitespace mods = Mods.from_np(match['mods'][1:]) else: mods = Mods.NOMOD if mods not in p.last_np.pp_cache: await p.last_np.cache_pp(mods) # Since this is a DM to the bot, we should # send back a list of general PP values. # TODO: !acc and !mods in commands to # modify these values :P _msg = [p.last_np.embed] if mods: _msg.append(f'{mods!r}') msg = f"{' '.join(_msg)}: " + ' | '.join(
class Score: __slots__ = ( "id", "map", "user", "score", "acc", "n300", "n100", "n50", "miss", "geki", "katu", "grade", "mods", "readable_mods", "combo", "mode", "rank", "pp", "sr", "fc", "passed", "status", "time", "old_best", "osuver", "ur", ) def __init__(self) -> None: self.id: Optional[int] = None self.map: Optional[Beatmap] = None self.user: Optional[Player] = None self.score: Optional[int] = None self.acc: Optional[float] = None self.n300: Optional[int] = None self.n100: Optional[int] = None self.n50: Optional[int] = None self.miss: Optional[int] = None self.geki: Optional[int] = None self.katu: Optional[int] = None self.grade: Optional[Grade] = None self.mods: Optional[Mods] = None self.readable_mods: Optional[str] = None self.combo: Optional[int] = None self.mode: Optional[osuModes] = None self.rank: Optional[int] = None self.pp: Optional[float] = None self.sr: Optional[float] = None self.fc: Optional[bool] = None self.passed: Optional[bool] = None self.status: Optional[scoreStatuses] = None self.time: Optional[int] = None self.old_best: Optional[Score] = None self.osuver: Optional[float] = None self.ur: Optional[float] = None async def format(self) -> str: msg = ( f"{self.user.name} | {self.map.name} +{self.readable_mods} {self.acc:.2f}% " f'{"FC" if not self.miss else f"{self.miss}xMiss"} {self.pp:,.0f}pp' ) if self.miss: fc_score = copy.copy(self) fc_score.fc = True fc_score.combo = 0 # oppai will take max combo pp, _ = await fc_score.calc_pp(self.mode.as_vn) msg += f" (~{round(pp):,}pp for FC)" if self.mode.value == 0 and self.ur: msg += f" | {self.ur:.2f} (cv)UR" return msg @classmethod async def from_sql( cls, sid: int, table: str, sort: str, t: int, ensure: bool = False, ) -> Optional["Score"]: score = await glob.db.fetchrow(f"SELECT * FROM {table} WHERE id = %s", [sid]) if not score: return self = cls() self.id = sid self.map = await Beatmap.from_md5(score["md5"]) if not self.map: return # ? self.user = await glob.players.get(id=score["uid"], sql=ensure) if not self.user: return self self.pp = score["pp"] self.score = score["score"] self.combo = score["combo"] self.mods = Mods(score["mods"]) self.acc = score["acc"] self.n300 = score["n300"] self.n100 = score["n100"] self.n50 = score["n50"] self.miss = score["miss"] self.geki = score["geki"] self.katu = score["katu"] self.grade = score["grade"] self.fc = score["fc"] self.status = scoreStatuses(score["status"]) self.mode = lbModes(score["mode"], self.mods) self.time = score["time"] self.passed = self.status.value != 0 if not self.user.restricted: self.rank = await self.calc_lb(table, sort, t) else: self.rank = 0 self.osuver = score["osuver"] return self @classmethod async def from_submission( cls, base: str, iv: str, pw: str, ver: str, ) -> Optional["Score"]: rijndael = RijndaelCbc( # much better f**k one liners key=f"osu!-scoreburgr---------{ver}".encode(), iv=b64decode(iv), padding=Pkcs7Padding(32), block_size=32, ) data = rijndael.decrypt(b64decode(base)).decode().split(":") self = cls() self.map = await Beatmap.from_md5(data[0]) if (u := await glob.players.get(name=data[1].rstrip())) and u.pw == pw: self.user = u if not self.user: return self # even if user isnt found, may be related to connection and we want to tell the client to retry if not self.map: return # ?? # i wanted to make everything be set in the same order as init but some require all score info to exist first so sadly not :c self.score = int(data[9]) self.n300 = int(data[3]) self.n100 = int(data[4]) self.n50 = int(data[5]) self.miss = int(data[8]) self.geki = int(data[6]) self.katu = int(data[7]) self.mods = Mods(int(data[13])) self.readable_mods = repr(Mods(int(data[13]))) self.combo = int(data[10]) self.mode = lbModes(int(data[15]), self.mods) self.fc = data[11] == "True" # WHY IS OSU GIVING STRING FOR BOOL!!!!!! self.passed = data[14] == "True" # AGAIN OSU WHY!!!! self.time = round(time.time()) # have to add round cast cus it gives float smh self.grade = data[12] if self.passed else "F" await self.calc_info() self.pp, self.sr = await self.calc_pp(self.mode.as_vn) await self.score_order() if self.user.restricted: self.rank = 0 self.osuver = float(re.sub("[^0-9]", "", ver)) # lol return self
class SendPrivateMessage(ClientPacket, type=ClientPacketType.SEND_PRIVATE_MESSAGE): msg = osuTypes.message async def handle(self, p: Player) -> None: if p.silenced: log(f'{p} tried to send a dm while silenced.', Ansi.YELLOW) return msg = self.msg.msg target = self.msg.target if not (t := await glob.players.get_by_name(target)): log(f'{p} tried to write to non-existant user {target}.', Ansi.YELLOW) return if t.pm_private and p.id not in t.friends: p.enqueue(packets.userPMBlocked(target)) log(f'{p} tried to message {t}, but they are blocking dms.') return if t.silenced: p.enqueue(packets.targetSilenced(target)) log(f'{p} tried to message {t}, but they are silenced.') return msg = f'{msg[:2045]}...' if msg[2048:] else msg client, client_id = p.name, p.id if t.status.action == Action.Afk and t.away_msg: # send away message if target is afk and has one set. p.enqueue( packets.sendMessage(client, t.away_msg, target, client_id)) if t.id == 1: # target is the bot, check if message is a command. cmd = msg.startswith(glob.config.command_prefix) \ and await commands.process_commands(p, t, msg) if cmd and 'resp' in cmd: # command triggered and there is a response to send. p.enqueue( packets.sendMessage(t.name, cmd['resp'], client, t.id)) else: # no commands triggered. if match := regexes.now_playing.match(msg): # user is /np'ing a map. # save it to their player instance # so we can use this elsewhere owo.. p.last_np = await Beatmap.from_bid(int(match['bid'])) if p.last_np: if match['mods']: # [1:] to remove leading whitespace mods = Mods.from_np(match['mods'][1:]) else: mods = Mods.NOMOD if mods not in p.last_np.pp_cache: await p.last_np.cache_pp(mods) # since this is a DM to the bot, we should # send back a list of general PP values. # TODO: !acc and !mods in commands to # modify these values :P _msg = [p.last_np.embed] if mods: _msg.append(f'{mods!r}') msg = f"{' '.join(_msg)}: " + ' | '.join( f'{acc}%: {pp:.2f}pp' for acc, pp in zip((90, 95, 98, 99, 100 ), p.last_np.pp_cache[mods])) else: msg = 'Could not find map.' p.enqueue(packets.sendMessage(t.name, msg, client, t.id))
class Score: """\ Server side representation of an osu! score; any gamemode. Attributes ----------- id: `int` The score's unique ID. bmap: Optional[`Beatmap`] A beatmap obj representing the osu map. player: Optional[`Player`] A player obj of the player who submitted the score. pp: `float` The score's performance points. score: `int` The score's osu! score value. max_combo: `int` The maximum combo reached in the score. mods: `Mods` A bitwise value of the osu! mods used in the score. acc: `float` The accuracy of the score. n300: `int` The number of 300s in the score. n100: `int` The number of 100s in the score (150s if taiko). n50: `int` The number of 50s in the score. nmiss: `int` The number of misses in the score. ngeki: `int` The number of gekis in the score. nkatu: `int` The number of katus in the score. grade: `str` The letter grade in the score. rank: `int` The leaderboard placement of the score. passed: `bool` Whether the score completed the map. perfect: `bool` Whether the score is a full-combo. status: `SubmissionStatus` The submission status of the score. mode: `GameMode` The game mode of the score. play_time: `datetime` A datetime obj of the time of score submission. time_elapsed: `int` The total elapsed time of the play (in milliseconds). client_flags: `int` osu!'s old anticheat flags. prev_best: Optional[`Score`] The previous best score before this play was submitted. NOTE: just because a score has a `prev_best` attribute does mean the score is our best score on the map! the `status` value will always be accurate for any score. """ __slots__ = ( 'id', 'bmap', 'player', 'pp', 'sr', 'score', 'max_combo', 'mods', 'acc', 'n300', 'n100', 'n50', 'nmiss', 'ngeki', 'nkatu', 'grade', 'rank', 'passed', 'perfect', 'status', 'mode', 'play_time', 'time_elapsed', 'client_flags', 'prev_best' ) def __init__(self): self.id: Optional[int] = None self.bmap: Optional[Beatmap] = None self.player: Optional['Player'] = None # pp & star rating self.pp: Optional[float] = None self.sr: Optional[float] = None self.score: Optional[int] = None self.max_combo: Optional[int] = None self.mods: Optional[Mods] = None self.acc: Optional[float] = None # TODO: perhaps abstract these differently # since they're mode dependant? feels weird.. self.n300: Optional[int] = None self.n100: Optional[int] = None # n150 for taiko self.n50: Optional[int] = None self.nmiss: Optional[int] = None self.ngeki: Optional[int] = None self.nkatu: Optional[int] = None self.grade: Optional[Rank] = None self.rank: Optional[int] = None self.passed: Optional[bool] = None self.perfect: Optional[bool] = None self.status: Optional[SubmissionStatus] = None self.mode: Optional[GameMode] = None self.play_time: Optional[datetime] = None self.time_elapsed: Optional[datetime] = None # osu!'s client 'anticheat'. self.client_flags: Optional[ClientFlags] = None self.prev_best: Optional[Score] = None @classmethod async def from_sql(cls, scoreid: int, sql_table: str): """Create a score object from sql using it's scoreid.""" # XXX: perhaps in the future this should take a gamemode rather # than just the sql table? just faster on the current setup :P res = await glob.db.fetch( 'SELECT id, map_md5, userid, pp, score, ' 'max_combo, mods, acc, n300, n100, n50, ' 'nmiss, ngeki, nkatu, grade, perfect, ' 'status, mode, play_time, ' 'time_elapsed, client_flags ' f'FROM {sql_table} WHERE id = %s', [scoreid], _dict=False ) if not res: return s = cls() s.id = res[0] s.bmap = await Beatmap.from_md5(res[1]) s.player = await glob.players.get(id=res[2], sql=True) (s.pp, s.score, s.max_combo, s.mods, s.acc, s.n300, s.n100, s.n50, s.nmiss, s.ngeki, s.nkatu, s.grade, s.perfect, s.status, mode_vn, s.play_time, s.time_elapsed, s.client_flags) = res[3:] # fix some types s.passed = s.status != 0 s.status = SubmissionStatus(s.status) s.mods = Mods(s.mods) s.mode = GameMode.from_params(mode_vn, s.mods) s.client_flags = ClientFlags(s.client_flags) if s.bmap: s.rank = await s.calc_lb_placement() return s @classmethod async def from_submission(cls, data_b64: str, iv_b64: str, osu_ver: str, pw_md5: str) -> Optional['Score']: """Create a score object from an osu! submission string.""" iv = b64decode(iv_b64).decode('latin_1') data_aes = b64decode(data_b64).decode('latin_1') aes_key = f'osu!-scoreburgr---------{osu_ver}' aes = RijndaelCbc(aes_key, iv, ZeroPadding(32), 32) # score data is delimited by colons (:). data = aes.decrypt(data_aes).decode().split(':') if len(data) != 18: log('Received an invalid score submission.', Ansi.LRED) return s = cls() if len(map_md5 := data[0]) != 32: return pname = data[1].rstrip() # why does osu! make me rstrip lol # get the map & player for the score. s.bmap = await Beatmap.from_md5(map_md5) s.player = await glob.players.get_login(pname, pw_md5) if not s.player: # return the obj with an empty player to # determine whether the score faield to # be parsed vs. the user could not be found # logged in (we want to not send a reply to # the osu! client if they're simply not logged # in, so that it will retry once they login). return s # XXX: unused idx 2: online score checksum # perhaps will use to improve security at some point? # ensure all ints are safe to cast. if not all(map(lambda x: x.isdecimal(), data[3:11] + [data[13], data[15]])): log('Invalid parameter passed into submit-modular.', Ansi.LRED) return (s.n300, s.n100, s.n50, s.ngeki, s.nkatu, s.nmiss, s.score, s.max_combo) = map(int, data[3:11]) s.perfect = data[11] == 'True' _grade = data[12] # letter grade s.mods = Mods(int(data[13])) s.passed = data[14] == 'True' s.mode = GameMode.from_params(int(data[15]), s.mods) s.play_time = datetime.now() s.client_flags = data[17].count(' ') # TODO: use osu!ver? (osuver\s+) s.grade = _grade if s.passed else 'F' # all data read from submission. # now we can calculate things based on our data. s.calc_accuracy() if s.bmap: # ignore sr for now. s.pp, s.sr = await s.calc_diff() await s.calc_status() s.rank = await s.calc_lb_placement() else: s.pp = s.sr = 0.0 s.status = SubmissionStatus.SUBMITTED if s.passed \ else SubmissionStatus.FAILED return s
async def from_submission(cls, data_b64: str, iv_b64: str, osu_ver: str, pw_md5: str) -> Optional['Score']: """Create a score object from an osu! submission string.""" aes = RijndaelCbc(key=f'osu!-scoreburgr---------{osu_ver}', iv=b64decode(iv_b64), padding=Pkcs7Padding(32), block_size=32) # score data is delimited by colons (:). data = aes.decrypt(b64decode(data_b64)).decode().split(':') if len(data) != 18: log('Received an invalid score submission.', Ansi.LRED) return s = cls() if len(data[0]) != 32 or len(data[2]) != 32: return map_md5 = data[0] pname = data[1].rstrip() # rstrip 1 space if client has supporter s.online_checksum = data[2] # get the map & player for the score. s.bmap = await Beatmap.from_md5(map_md5) s.player = await glob.players.get_login(pname, pw_md5) if not s.player: # return the obj with an empty player to # determine whether the score failed to # be parsed vs. the user could not be found # logged in (we want to not send a reply to # the osu! client if they're simply not logged # in, so that it will retry once they login). return s # XXX: unused idx 2: online score checksum # perhaps will use to improve security at some point? # ensure all ints are safe to cast. if not all(map(str.isdecimal, data[3:11] + [data[13], data[15]])): log('Invalid parameter passed into submit-modular.', Ansi.LRED) return (s.n300, s.n100, s.n50, s.ngeki, s.nkatu, s.nmiss, s.score, s.max_combo) = map(int, data[3:11]) s.perfect = data[11] == 'True' _grade = data[12] # letter grade s.mods = Mods(int(data[13])) s.passed = data[14] == 'True' s.mode = GameMode.from_params(int(data[15]), s.mods) s.play_time = datetime.now() # TODO: use data[16] s.client_flags = ClientFlags(data[17].count(' ') & ~4) s.grade = Grade.from_str(_grade) if s.passed else Grade.F # all data read from submission. # now we can calculate things based on our data. s.calc_accuracy() if s.bmap: osu_file_path = BEATMAPS_PATH / f'{s.bmap.id}.osu' if await ensure_local_osu_file(osu_file_path, s.bmap.id, s.bmap.md5): s.pp, s.sr = s.calc_diff(osu_file_path) if s.passed: await s.calc_status() if s.bmap.status != RankedStatus.Pending: s.rank = await s.calc_lb_placement() else: s.status = SubmissionStatus.FAILED else: s.pp = s.sr = 0.0 if s.passed: s.status = SubmissionStatus.SUBMITTED else: s.status = SubmissionStatus.FAILED return s
@mp_commands.add(Privileges.Normal) async def mp_ban(p: 'Player', m: 'Match', msg: Sequence[str]) -> str: """Ban a pick in the currently loaded mappool.""" if len(msg) != 1: return 'Invalid syntax: !mp ban <pick>' if not m.pool: return 'No pool currently selected!' mods_slot = msg[0] # separate mods & slot if not (rgx := regexes.mappool_pick.fullmatch(mods_slot)): return 'Invalid pick syntax; correct example: "HD2".' mods = Mods.from_str(rgx[1]) slot = int(rgx[2]) if (mods, slot) not in m.pool.maps: return f'Found no {mods_slot} pick in the pool.' if (mods, slot) in m.bans: return 'That pick is already banned!' m.bans.add((mods, slot)) return f'{mods_slot} banned.' @mp_commands.add(Privileges.Normal) async def mp_unban(p: 'Player', m: 'Match', msg: Sequence[str]) -> str: """Unban a pick in the currently loaded mappool."""
async def read_match(data: memoryview) -> tuple[Match, int]: """ Read an osu! match from `data`. """ m = Match() # Ignore match id (i32) & inprogress (i8). offset = 3 # Read match type (no idea what this is tbh). m.type = MatchTypes(data[offset]) offset += 1 # Read match mods. m.mods = Mods.from_bytes(data[offset:offset+4], 'little') offset += 4 # Read match name & password. m.name, offs = await read_string(data[offset:]) offset += offs m.passwd, offs = await read_string(data[offset:]) offset += offs # Ignore map's name. if data[offset] == 0x0b: offset += sum(await read_uleb128(data[offset + 1:])) offset += 1 # Read beatmap information (id & md5). map_id = int.from_bytes(data[offset:offset+4], 'little') offset += 4 map_md5, offs = await read_string(data[offset:]) offset += offs # Get beatmap object for map selected. m.bmap = await Beatmap.from_md5(map_md5) if not m.bmap and map_id != (1 << 32) - 1: # If they pick an unsubmitted map, # just give them Vivid [Insane] lol. vivid_md5 = '1cf5b2c2edfafd055536d2cefcb89c0e' m.bmap = await Beatmap.from_md5(vivid_md5) # Read slot statuses. for s in m.slots: s.status = data[offset] offset += 1 # Read slot teams. for s in m.slots: s.team = Teams(data[offset]) offset += 1 for s in m.slots: if s.status & SlotStatus.has_player: # Dont think we need this? offset += 4 # Read match host. user_id = int.from_bytes(data[offset:offset+4], 'little') m.host = await glob.players.get_by_id(user_id) offset += 4 # Read match mode, match scoring, # team type, and freemods. m.mode = GameMode(data[offset]) offset += 1 m.match_scoring = MatchScoringTypes(data[offset]) offset += 1 m.team_type = MatchTeamTypes(data[offset]) offset += 1 m.freemods = data[offset] == 1 offset += 1 # If we're in freemods mode, # read individual slot mods. if m.freemods: for s in m.slots: s.mods = Mods.from_bytes(data[offset:offset+4], 'little') offset += 4 # Read the seed from multi. # XXX: Used for mania random mod. m.seed = int.from_bytes(data[offset:offset+4], 'little') return m, offset + 4