mirror of
https://github.com/Lost-MSth/Arcaea-server.git
synced 2025-12-14 08:06:23 +08:00
[Enhance] World rank score mechanism
- Adjust world rank mechanism to be closer to the official one. Note: You need to refresh rating in web admin backend after updating, and the users need to get a better or new score to refresh world rank.
This commit is contained in:
@@ -12,7 +12,7 @@ class Config:
|
||||
|
||||
SONG_FILE_HASH_PRE_CALCULATE = True
|
||||
|
||||
GAME_API_PREFIX = '/hanami/29' # str | list[str]
|
||||
GAME_API_PREFIX = '/natsugakuru/30' # str | list[str]
|
||||
OLD_GAME_API_PREFIX = [] # str | list[str]
|
||||
|
||||
ALLOW_APPVERSION = [] # list[str]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .config_manager import Config
|
||||
|
||||
ARCAEA_SERVER_VERSION = 'v2.11.3.12'
|
||||
ARCAEA_DATABASE_VERSION = 'v2.11.3.11'
|
||||
ARCAEA_SERVER_VERSION = 'v2.11.3.13'
|
||||
ARCAEA_DATABASE_VERSION = 'v2.11.3.13'
|
||||
ARCAEA_LOG_DATBASE_VERSION = 'v1.1'
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class BaseOperation:
|
||||
class RefreshAllScoreRating(BaseOperation):
|
||||
'''
|
||||
刷新所有成绩的评分
|
||||
包括 score_v2
|
||||
'''
|
||||
_name = 'refresh_all_score_rating'
|
||||
|
||||
@@ -44,11 +45,11 @@ class RefreshAllScoreRating(BaseOperation):
|
||||
|
||||
for i in x:
|
||||
for j in range(0, 4):
|
||||
defnum = -10 # 没在库里的全部当做定数-10
|
||||
defnum = -10 # 没在库里的全部当做定数 -10
|
||||
if i[j+1] is not None and i[j+1] > 0:
|
||||
defnum = float(i[j+1]) / 10
|
||||
|
||||
c.execute('''select user_id, score from best_score where song_id=:a and difficulty=:b''', {
|
||||
c.execute('''select user_id, score, shiny_perfect_count, perfect_count, near_count, miss_count from best_score where song_id=:a and difficulty=:b''', {
|
||||
'a': i[0], 'b': j})
|
||||
y = c.fetchall()
|
||||
values = []
|
||||
@@ -56,10 +57,12 @@ class RefreshAllScoreRating(BaseOperation):
|
||||
for k in y:
|
||||
ptt = Score.calculate_rating(defnum, k[1])
|
||||
ptt = max(ptt, 0)
|
||||
values.append((ptt,))
|
||||
score_v2 = Score.calculate_score_v2(
|
||||
defnum, k[2], k[3], k[4], k[5])
|
||||
values.append((ptt, score_v2,))
|
||||
where_values.append((k[0], i[0], j))
|
||||
if values:
|
||||
Sql(c).update_many('best_score', ['rating'], values, [
|
||||
Sql(c).update_many('best_score', ['rating', 'score_v2'], values, [
|
||||
'user_id', 'song_id', 'difficulty'], where_values)
|
||||
|
||||
|
||||
@@ -133,11 +136,16 @@ class SaveUpdateScore(BaseOperation):
|
||||
new_scores = []
|
||||
for i in save.scores_data:
|
||||
rating = 0
|
||||
score_v2 = 0
|
||||
if i['song_id'] in song_chart_const:
|
||||
rating = Score.calculate_rating(
|
||||
song_chart_const[i['song_id']][i['difficulty']] / 10, i['score'])
|
||||
defnum = song_chart_const[i['song_id']
|
||||
][i['difficulty']] / 10
|
||||
rating = Score.calculate_rating(defnum, i['score'])
|
||||
rating = max(rating, 0)
|
||||
|
||||
score_v2 = Score.calculate_score_v2(
|
||||
defnum, i['shiny_perfect_count'], i['perfect_count'], i['near_count'], i['miss_count'])
|
||||
|
||||
y = f'{i["song_id"]}{i["difficulty"]}'
|
||||
if y in clear_state:
|
||||
clear_type = clear_state[y]
|
||||
@@ -145,10 +153,10 @@ class SaveUpdateScore(BaseOperation):
|
||||
clear_type = 0
|
||||
|
||||
new_scores.append((self.user.user_id, i['song_id'], i['difficulty'], i['score'], i['shiny_perfect_count'], i['perfect_count'],
|
||||
i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating))
|
||||
i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating, score_v2))
|
||||
|
||||
c.executemany(
|
||||
'''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores)
|
||||
'''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores)
|
||||
|
||||
def _all_update(self):
|
||||
with Connect() as c:
|
||||
@@ -169,11 +177,16 @@ class SaveUpdateScore(BaseOperation):
|
||||
new_scores = []
|
||||
for i in save.scores_data:
|
||||
rating = 0
|
||||
score_v2 = 0
|
||||
if i['song_id'] in song_chart_const:
|
||||
rating = Score.calculate_rating(
|
||||
song_chart_const[i['song_id']][i['difficulty']] / 10, i['score'])
|
||||
defnum = song_chart_const[i['song_id']
|
||||
][i['difficulty']] / 10
|
||||
rating = Score.calculate_rating(defnum, i['score'])
|
||||
rating = max(rating, 0)
|
||||
|
||||
score_v2 = Score.calculate_score_v2(
|
||||
defnum, i['shiny_perfect_count'], i['perfect_count'], i['near_count'], i['miss_count'])
|
||||
|
||||
y = f'{i["song_id"]}{i["difficulty"]}'
|
||||
if y in clear_state:
|
||||
clear_type = clear_state[y]
|
||||
@@ -181,10 +194,10 @@ class SaveUpdateScore(BaseOperation):
|
||||
clear_type = 0
|
||||
|
||||
new_scores.append((user.user_id, i['song_id'], i['difficulty'], i['score'], i['shiny_perfect_count'], i['perfect_count'],
|
||||
i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating))
|
||||
i['near_count'], i['miss_count'], i['health'], i['modifier'], i['time_played'], clear_type, clear_type, rating, score_v2))
|
||||
|
||||
c.executemany(
|
||||
'''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores)
|
||||
'''insert or replace into best_score values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''', new_scores)
|
||||
|
||||
|
||||
class UnlockUserItem(BaseOperation):
|
||||
|
||||
@@ -30,6 +30,7 @@ class Score:
|
||||
self.best_clear_type: int = None
|
||||
self.clear_type: int = None
|
||||
self.rating: float = None
|
||||
self.score_v2: float = None # for `world_rank_score` of global rank
|
||||
|
||||
def set_score(self, score: int, shiny_perfect_count: int, perfect_count: int, near_count: int, miss_count: int, health: int, modifier: int, time_played: int, clear_type: int):
|
||||
self.score = int(score) if score is not None else 0
|
||||
@@ -124,12 +125,31 @@ class Score:
|
||||
|
||||
return ptt
|
||||
|
||||
@staticmethod
|
||||
def calculate_score_v2(defnum: float, shiny_perfect_count: int, perfect_count: int, near_count: int, miss_count: int) -> float:
|
||||
# 计算score_v2 refer: https://www.bilibili.com/video/BV1ys421u7BY
|
||||
# 谱面定数小于等于 0 视为 unranked,返回值会为 0
|
||||
if not defnum or defnum <= 0:
|
||||
return 0
|
||||
|
||||
all_note = perfect_count + near_count + miss_count
|
||||
if all_note == 0:
|
||||
return 0
|
||||
shiny_ratio = shiny_perfect_count / all_note
|
||||
score_ratio = (perfect_count + near_count/2) / \
|
||||
all_note + shiny_perfect_count / 10000000
|
||||
acc_rating = max(0, min(shiny_ratio - 0.9, 0.095)) / 9.5 * 25
|
||||
score_rating = max(0, min(score_ratio - 0.99, 0.01)) * 75
|
||||
return defnum * (acc_rating + score_rating)
|
||||
|
||||
def get_rating_by_calc(self) -> float:
|
||||
# 通过计算得到本成绩的rating
|
||||
# 通过计算得到本成绩的 rating & score_v2
|
||||
if not self.song.defnum:
|
||||
self.song.c = self.c
|
||||
self.song.select()
|
||||
self.rating = self.calculate_rating(self.song.chart_const, self.score)
|
||||
self.score_v2 = self.calculate_score_v2(
|
||||
self.song.chart_const, self.shiny_perfect_count, self.perfect_count, self.near_count, self.miss_count)
|
||||
return self.rating
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -181,6 +201,7 @@ class UserScore(Score):
|
||||
self.set_score(x[3], x[4], x[5], x[6], x[7], x[8], x[9], x[10], x[12])
|
||||
self.best_clear_type = int(x[11])
|
||||
self.rating = float(x[13])
|
||||
self.score_v2 = float(x[14])
|
||||
|
||||
return self
|
||||
|
||||
@@ -430,8 +451,9 @@ class UserPlay(UserScore):
|
||||
x = self.c.fetchone()
|
||||
if not x:
|
||||
self.new_best_protect_flag = True # 初见保护
|
||||
self.c.execute('''insert into best_score values(:a,:b,:c,:d,:e,:f,:g,:h,:i,:j,:k,:l,:m,:n)''', {
|
||||
'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.score, 'e': self.shiny_perfect_count, 'f': self.perfect_count, 'g': self.near_count, 'h': self.miss_count, 'i': self.health, 'j': self.modifier, 'k': self.time_played, 'l': self.clear_type, 'm': self.clear_type, 'n': self.rating})
|
||||
self.c.execute('''insert into best_score values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
(self.user.user_id, self.song.song_id, self.song.difficulty, self.score, self.shiny_perfect_count, self.perfect_count, self.near_count, self.miss_count,
|
||||
self.health, self.modifier, self.time_played, self.clear_type, self.clear_type, self.rating, self.score_v2))
|
||||
self.user.update_global_rank()
|
||||
else:
|
||||
self.new_best_protect_flag = False
|
||||
@@ -440,8 +462,8 @@ class UserPlay(UserScore):
|
||||
'a': self.clear_type, 'b': self.user.user_id, 'c': self.song.song_id, 'd': self.song.difficulty})
|
||||
if self.score >= int(x[0]): # best成绩更新
|
||||
self.new_best_protect_flag = True
|
||||
self.c.execute('''update best_score set score = :d, shiny_perfect_count = :e, perfect_count = :f, near_count = :g, miss_count = :h, health = :i, modifier = :j, clear_type = :k, rating = :l, time_played = :m where user_id = :a and song_id = :b and difficulty = :c ''', {
|
||||
'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.score, 'e': self.shiny_perfect_count, 'f': self.perfect_count, 'g': self.near_count, 'h': self.miss_count, 'i': self.health, 'j': self.modifier, 'k': self.clear_type, 'l': self.rating, 'm': self.time_played})
|
||||
self.c.execute('''update best_score set score = :d, shiny_perfect_count = :e, perfect_count = :f, near_count = :g, miss_count = :h, health = :i, modifier = :j, clear_type = :k, rating = :l, time_played = :m, score_v2 = :n where user_id = :a and song_id = :b and difficulty = :c ''', {
|
||||
'a': self.user.user_id, 'b': self.song.song_id, 'c': self.song.difficulty, 'd': self.score, 'e': self.shiny_perfect_count, 'f': self.perfect_count, 'g': self.near_count, 'h': self.miss_count, 'i': self.health, 'j': self.modifier, 'k': self.clear_type, 'l': self.rating, 'm': self.time_played, 'n': self.score_v2})
|
||||
self.user.update_global_rank()
|
||||
|
||||
self.ptt = Potential(self.c, self.user)
|
||||
|
||||
@@ -351,7 +351,8 @@ class Sql:
|
||||
class DatabaseMigrator:
|
||||
|
||||
SPECIAL_UPDATE_VERSION = {
|
||||
'2.11.3.11': '_version_2_11_3_11'
|
||||
'2.11.3.11': '_version_2_11_3_11',
|
||||
'2.11.3.13': '_version_2_11_3.13'
|
||||
}
|
||||
|
||||
def __init__(self, c1_path: str, c2_path: str) -> None:
|
||||
@@ -463,6 +464,12 @@ class DatabaseMigrator:
|
||||
self.c2.executemany(
|
||||
'''insert into recent30(user_id, r_index, time_played, song_id, difficulty, rating) values(?,?,?,?,?,?)''', sql_list)
|
||||
|
||||
def _version_2_11_3_13(self):
|
||||
'''
|
||||
2.11.3.13 版本特殊更新,world_rank_score 机制调整,需清空用户分数
|
||||
'''
|
||||
self.c1.execute('''update user set world_rank_score = 0''')
|
||||
|
||||
|
||||
class LogDatabaseMigrator:
|
||||
|
||||
|
||||
@@ -528,7 +528,11 @@ class UserInfo(User):
|
||||
'world_mode_locked_end_ts': self.world_mode_locked_end_ts,
|
||||
'locked_char_ids': [], # [1]
|
||||
'user_missions': UserMissionList(self.c, self).select_all().to_dict_list(),
|
||||
'pick_ticket': self.pick_ticket
|
||||
'pick_ticket': self.pick_ticket,
|
||||
|
||||
# 'custom_banner': 'online_banner_2024_06',
|
||||
# 'subscription_multiplier': 114,
|
||||
# 'memory_boost_ticket': 5,
|
||||
}
|
||||
|
||||
def from_list(self, x: list) -> 'UserInfo':
|
||||
@@ -648,8 +652,8 @@ class UserInfo(User):
|
||||
'''用户世界排名,如果超过设定最大值,返回0'''
|
||||
if self.world_rank_score is None:
|
||||
self.select_user_one_column('world_rank_score', 0)
|
||||
if self.world_rank_score is None:
|
||||
return 0
|
||||
if not self.world_rank_score:
|
||||
return 0
|
||||
|
||||
self.c.execute(
|
||||
'''select count(*) from user where world_rank_score > ?''', (self.world_rank_score,))
|
||||
@@ -665,14 +669,14 @@ class UserInfo(User):
|
||||
self.c.execute(
|
||||
'''
|
||||
with user_scores as (
|
||||
select song_id, difficulty, score from best_score where user_id = ? and difficulty in (2, 3, 4)
|
||||
select song_id, difficulty, score_v2 from best_score where user_id = ? and difficulty in (2, 3, 4)
|
||||
)
|
||||
select sum(a) from(
|
||||
select sum(score) as a from user_scores where difficulty = 2 and song_id in (select song_id from chart where rating_ftr > 0)
|
||||
select sum(score_v2) as a from user_scores where difficulty = 2 and song_id in (select song_id from chart where rating_ftr > 0)
|
||||
union
|
||||
select sum(score) as a from user_scores where difficulty = 3 and song_id in (select song_id from chart where rating_byn > 0)
|
||||
select sum(score_v2) as a from user_scores where difficulty = 3 and song_id in (select song_id from chart where rating_byn > 0)
|
||||
union
|
||||
select sum(score) as a from user_scores where difficulty = 4 and song_id in (select song_id from chart where rating_etr > 0)
|
||||
select sum(score_v2) as a from user_scores where difficulty = 4 and song_id in (select song_id from chart where rating_etr > 0)
|
||||
)
|
||||
''',
|
||||
(self.user_id,)
|
||||
|
||||
@@ -265,6 +265,7 @@ class UserMap(Map):
|
||||
r['curr_capture'] = self.curr_capture
|
||||
r['is_locked'] = self.is_locked
|
||||
r['user_id'] = self.user.user_id
|
||||
# memory_boost_ticket
|
||||
if not has_steps:
|
||||
del r['steps']
|
||||
if has_rewards:
|
||||
@@ -668,6 +669,9 @@ class BaseWorldPlay(WorldSkillMixin):
|
||||
'world_mode_locked_end_ts': self.user.world_mode_locked_end_ts,
|
||||
'beyond_boost_gauge': self.user.beyond_boost_gauge,
|
||||
# 'wpaid': 'helloworld', # world play id ???
|
||||
# progress_before_sub_boost
|
||||
# progress_sub_boost_amount
|
||||
# subscription_multiply
|
||||
}
|
||||
|
||||
if self.character_used.skill_id_displayed == 'skill_maya':
|
||||
|
||||
@@ -70,7 +70,7 @@ class InitData:
|
||||
'core_ambivalent', 'core_scarlet', 'core_groove', 'core_generic', 'core_binary', 'core_colorful', 'core_course_skip_purchase', 'core_umbral', 'core_wacca', 'core_sunset', 'core_tanoc']
|
||||
|
||||
world_songs = ["babaroque", "shadesoflight", "kanagawa", "lucifer", "anokumene", "ignotus", "rabbitintheblackroom", "qualia", "redandblue", "bookmaker", "darakunosono", "espebranch", "blacklotus", "givemeanightmare", "vividtheory", "onefr", "gekka", "vexaria3", "infinityheaven3", "fairytale3", "goodtek3", "suomi", "rugie", "faintlight", "harutopia", "goodtek", "dreaminattraction", "syro", "diode", "freefall", "grimheart", "blaster",
|
||||
"cyberneciacatharsis", "monochromeprincess", "revixy", "vector", "supernova", "nhelv", "purgatorium3", "dement3", "crossover", "guardina", "axiumcrisis", "worldvanquisher", "sheriruth", "pragmatism", "gloryroad", "etherstrike", "corpssansorganes", "lostdesire", "blrink", "essenceoftwilight", "lapis", "solitarydream", "lumia3", "purpleverse", "moonheart3", "glow", "enchantedlove", "take", "lifeispiano", "vandalism", "nexttoyou3", "lostcivilization3", "turbocharger", "bookmaker3", "laqryma3", "kyogenkigo", "hivemind", "seclusion", "quonwacca3", "bluecomet", "energysynergymatrix", "gengaozo", "lastendconductor3", "antithese3", "qualia3", "kanagawa3", "heavensdoor3", "pragmatism3", "nulctrl", "avril", "ddd", "merlin3", "omakeno3", "nekonote", "sanskia", 'altair', 'mukishitsu', 'trapcrow', 'redandblue3', 'ignotus3', 'singularity3', 'dropdead3', 'arcahv', 'freefall3', 'partyvinyl3', 'tsukinimurakumo', 'mantis', 'worldfragments', 'astrawalkthrough', 'chronicle', 'trappola3', 'letsrock', 'shadesoflight3', 'teriqma3', 'impact3', 'lostemotion', 'gimmick', 'lawlesspoint', 'hybris', 'ultimatetaste', 'rgb', 'matenrou', 'dynitikos', 'amekagura', 'fantasy', 'aloneandlorn', 'felys', 'onandon', 'hotarubinoyuki', 'oblivia3', 'libertas3', 'einherjar3', 'purpleverse3', 'viciousheroism3', 'inkarusi3', 'cyberneciacatharsis3', 'alephzero', 'hellohell', 'ichirin', 'awakeninruins', 'morningloom', 'lethalvoltage', 'leaveallbehind', 'desive']
|
||||
"cyberneciacatharsis", "monochromeprincess", "revixy", "vector", "supernova", "nhelv", "purgatorium3", "dement3", "crossover", "guardina", "axiumcrisis", "worldvanquisher", "sheriruth", "pragmatism", "gloryroad", "etherstrike", "corpssansorganes", "lostdesire", "blrink", "essenceoftwilight", "lapis", "solitarydream", "lumia3", "purpleverse", "moonheart3", "glow", "enchantedlove", "take", "lifeispiano", "vandalism", "nexttoyou3", "lostcivilization3", "turbocharger", "bookmaker3", "laqryma3", "kyogenkigo", "hivemind", "seclusion", "quonwacca3", "bluecomet", "energysynergymatrix", "gengaozo", "lastendconductor3", "antithese3", "qualia3", "kanagawa3", "heavensdoor3", "pragmatism3", "nulctrl", "avril", "ddd", "merlin3", "omakeno3", "nekonote", "sanskia", 'altair', 'mukishitsu', 'trapcrow', 'redandblue3', 'ignotus3', 'singularity3', 'dropdead3', 'arcahv', 'freefall3', 'partyvinyl3', 'tsukinimurakumo', 'mantis', 'worldfragments', 'astrawalkthrough', 'chronicle', 'trappola3', 'letsrock', 'shadesoflight3', 'teriqma3', 'impact3', 'lostemotion', 'gimmick', 'lawlesspoint', 'hybris', 'ultimatetaste', 'rgb', 'matenrou', 'dynitikos', 'amekagura', 'fantasy', 'aloneandlorn', 'felys', 'onandon', 'hotarubinoyuki', 'oblivia3', 'libertas3', 'einherjar3', 'purpleverse3', 'viciousheroism3', 'inkarusi3', 'cyberneciacatharsis3', 'alephzero', 'hellohell', 'ichirin', 'awakeninruins', 'morningloom', 'lethalvoltage', 'leaveallbehind', 'desive', 'oldschoolsalvage']
|
||||
|
||||
world_unlocks = ["scenery_chap1", "scenery_chap2",
|
||||
"scenery_chap3", "scenery_chap4", "scenery_chap5", "scenery_chap6", "scenery_chap7", "scenery_beyond"]
|
||||
|
||||
@@ -1774,5 +1774,59 @@
|
||||
],
|
||||
"orig_price": 100,
|
||||
"price": 100
|
||||
},
|
||||
{
|
||||
"name": "backtobasics",
|
||||
"items": [
|
||||
{
|
||||
"type": "single",
|
||||
"id": "backtobasics",
|
||||
"is_available": true
|
||||
},
|
||||
{
|
||||
"type": "core",
|
||||
"amount": 1,
|
||||
"id": "core_generic",
|
||||
"is_available": true
|
||||
}
|
||||
],
|
||||
"orig_price": 100,
|
||||
"price": 100
|
||||
},
|
||||
{
|
||||
"name": "tasogare",
|
||||
"items": [
|
||||
{
|
||||
"type": "single",
|
||||
"id": "tasogare",
|
||||
"is_available": true
|
||||
},
|
||||
{
|
||||
"type": "core",
|
||||
"amount": 1,
|
||||
"id": "core_generic",
|
||||
"is_available": true
|
||||
}
|
||||
],
|
||||
"orig_price": 100,
|
||||
"price": 100
|
||||
},
|
||||
{
|
||||
"name": "beautifuldreamer",
|
||||
"items": [
|
||||
{
|
||||
"type": "single",
|
||||
"id": "beautifuldreamer",
|
||||
"is_available": true
|
||||
},
|
||||
{
|
||||
"type": "core",
|
||||
"amount": 1,
|
||||
"id": "core_generic",
|
||||
"is_available": true
|
||||
}
|
||||
],
|
||||
"orig_price": 100,
|
||||
"price": 100
|
||||
}
|
||||
]
|
||||
@@ -61,7 +61,8 @@ modifier int,
|
||||
time_played int,
|
||||
best_clear_type int,
|
||||
clear_type int,
|
||||
rating real,
|
||||
rating real default 0,
|
||||
score_v2 real default 0,
|
||||
primary key(user_id, song_id, difficulty)
|
||||
);
|
||||
create table if not exists user_char(user_id int,
|
||||
|
||||
@@ -106,6 +106,8 @@ def buy_special(user_id):
|
||||
x.discount_from = -1
|
||||
x.discount_to = -1
|
||||
x.items = [ItemFactory(c).get_item(item_id)]
|
||||
# request.form['ticket_used'] == 'true'
|
||||
# memory_boost_ticket: x-1
|
||||
x.buy()
|
||||
|
||||
r = {'user_id': x.user.user_id, 'ticket': x.user.ticket}
|
||||
|
||||
Reference in New Issue
Block a user