[Enhance] Link Play 2.0 e.t.c.

- For Arcaea 5.10.1(c)
- Add support for Link Play 2.0.
- New partners "Luna & Ilot" and "Eto & Hoppe"
- Add support for the skill of "Eto & Hoppe".
- Add support for refreshing ratings of Recent 30 via API and webpage.

Note: This is a bug testing version.
This commit is contained in:
Lost-MSth
2024-09-06 22:43:38 +08:00
parent 59422f96b5
commit 014531f3f1
21 changed files with 1184 additions and 253 deletions

View File

@@ -12,7 +12,7 @@ class Config:
SONG_FILE_HASH_PRE_CALCULATE = True
GAME_API_PREFIX = '/geriraraiu/31' # str | list[str]
GAME_API_PREFIX = '/pastatabetai/32' # str | list[str]
OLD_GAME_API_PREFIX = [] # str | list[str]
ALLOW_APPVERSION = [] # list[str]
@@ -102,6 +102,10 @@ class Config:
API_LOGIN_RATE_LIMIT = '10/5 minutes'
NOTIFICATION_EXPIRE_TIME = 3 * 60 * 1000
class ConfigManager:
@staticmethod

View File

@@ -1,7 +1,7 @@
from .config_manager import Config
ARCAEA_SERVER_VERSION = 'v2.11.3.16'
ARCAEA_DATABASE_VERSION = 'v2.11.3.16'
ARCAEA_SERVER_VERSION = 'v2.11.3.17'
ARCAEA_DATABASE_VERSION = 'v2.11.3.17'
ARCAEA_LOG_DATBASE_VERSION = 'v1.1'
@@ -66,6 +66,13 @@ class Constant:
LINKPLAY_TCP_SECRET_KEY = Config.LINKPLAY_TCP_SECRET_KEY
LINKPLAY_TCP_MAX_LENGTH = 0x0FFFFFFF
LINKPLAY_MATCH_GET_ROOMS_INTERVAL = 4 # Units: seconds
LINKPLAY_MATCH_PTT_ABS = [5, 20, 50, 100, 200, 500, 1000, 2000]
LINKPLAY_MATCH_UNLOCK_MIN = [1000, 800, 500, 300, 200, 100, 50, 1]
LINKPLAY_MATCH_TIMEOUT = 15 # Units: seconds
LINKPLAY_MATCH_MEMORY_CLEAN_INTERVAL = 60 # Units: seconds
# Well, I can't say a word when I see this.
FINALE_SWITCH = [
(0x0015F0, 0x00B032), (0x014C9A, 0x014408), (0x062585, 0x02783B),

View File

@@ -1,6 +1,8 @@
import socket
from base64 import b64decode, b64encode
from json import dumps, loads
from threading import RLock
from time import time
from core.error import ArcError, Timeout
@@ -36,6 +38,10 @@ class Player(UserInfo):
self.__song_unlock: bytes = None
self.client_song_map: dict = None
self.last_match_timestamp: int = 0
self.match_times: int = None # 已匹配次数,减 1 后乘 5 就大致是匹配时间
self.match_room: Room = None # 匹配到的房间,这个仅用来在两个人同时匹配时使用,一人建房,通知另一个人加入
def to_dict(self) -> dict:
return {
'userId': self.user_id,
@@ -55,6 +61,16 @@ class Player(UserInfo):
self.client_song_map = client_song_map
self.__song_unlock = get_song_unlock(self.client_song_map)
def calc_available_chart_num(self, song_unlock: bytes) -> int:
'''计算交叠后可用谱面数量'''
new_unlock = [i & j for i, j in zip(self.song_unlock, song_unlock)]
s = 0
for i in range(len(new_unlock)):
for j in range(8):
if new_unlock[i] & (1 << j):
s += 1
return s
class Room:
def __init__(self) -> None:
@@ -63,11 +79,14 @@ class Room:
self.song_unlock: bytes = None
self.share_token: str = 'abcde12345'
def to_dict(self) -> dict:
return {
'roomId': str(self.room_id),
'roomCode': self.room_code,
'orderedAllowedSongs': (b64encode(self.song_unlock)).decode()
'orderedAllowedSongs': (b64encode(self.song_unlock)).decode(),
'shareToken': self.share_token
}
@@ -97,7 +116,7 @@ class RemoteMultiPlayer:
raise ArcError(
'Too long body from link play server', status=400)
iv = sock.recv(12)
tag = sock.recv(12)
tag = sock.recv(16)
ciphertext = sock.recv(cipher_len)
received = aes_gcm_128_decrypt(
RemoteMultiPlayer.TCP_AES_KEY, b'', iv, ciphertext, tag)
@@ -112,7 +131,7 @@ class RemoteMultiPlayer:
iv, ciphertext, tag = aes_gcm_128_encrypt(
self.TCP_AES_KEY, dumps(data).encode('utf-8'), b'')
send_data = Constant.LINKPLAY_AUTHENTICATION.encode(
'utf-8') + len(ciphertext).to_bytes(8, byteorder='little') + iv + tag[:12] + ciphertext
'utf-8') + len(ciphertext).to_bytes(8, byteorder='little') + iv + tag + ciphertext
recv_data = self.tcp(send_data)
self.data_recv = loads(recv_data)
@@ -126,12 +145,15 @@ class RemoteMultiPlayer:
'''创建房间'''
if user is not None:
self.user = user
user.select_user_one_column('name')
user.select_user_about_link_play()
self.data_swap({
'endpoint': 'create_room',
'data': {
'name': self.user.name,
'song_unlock': b64encode(self.user.song_unlock).decode('utf-8')
'song_unlock': b64encode(self.user.song_unlock).decode('utf-8'),
'rating_ptt': self.user.rating_ptt,
'is_hide_rating': self.user.is_hide_rating,
'match_times': self.user.match_times
}
})
@@ -151,13 +173,16 @@ class RemoteMultiPlayer:
if room is not None:
self.room = room
self.user.select_user_one_column('name')
self.user.select_user_about_link_play()
self.data_swap({
'endpoint': 'join_room',
'data': {
'name': self.user.name,
'song_unlock': b64encode(self.user.song_unlock).decode('utf-8'),
'room_code': self.room.room_code
'room_code': self.room.room_code,
'rating_ptt': self.user.rating_ptt,
'is_hide_rating': self.user.is_hide_rating,
'match_times': self.user.match_times
}
})
x = self.data_recv['data']
@@ -172,10 +197,14 @@ class RemoteMultiPlayer:
'''更新房间'''
if user is not None:
self.user = user
self.user.select_user_about_link_play()
self.data_swap({
'endpoint': 'update_room',
'data': {
'token': self.user.token
'token': self.user.token,
'rating_ptt': self.user.rating_ptt,
'is_hide_rating': self.user.is_hide_rating
}
})
@@ -198,3 +227,128 @@ class RemoteMultiPlayer:
})
return self.data_recv['data']
def select_room(self, room_code: str = None, share_token: str = None) -> dict:
self.data_swap({
'endpoint': 'select_room',
'data': {
'room_code': room_code,
'share_token': share_token
}
})
return self.data_recv['data']
def get_match_rooms(self) -> dict:
'''获取一定数量的公共房间列表'''
self.data_swap({
'endpoint': 'get_match_rooms',
'data': {
'limit': 100
}
})
return self.data_recv['data']
class MatchStore:
last_get_rooms_timestamp = 0
room_cache: 'list[Room]' = []
player_queue: 'dict[int, Player]' = {}
lock = RLock()
last_memory_clean_timestamp = 0
def __init__(self, c=None) -> None:
self.c = c
self.remote = RemoteMultiPlayer()
def refresh_rooms(self):
now = time()
if now - self.last_get_rooms_timestamp < Constant.LINKPLAY_MATCH_GET_ROOMS_INTERVAL:
return
MatchStore.room_cache = self.remote.get_match_rooms()['rooms']
MatchStore.last_get_rooms_timestamp = now
def init_player(self, user: 'Player'):
user.match_times = 0
MatchStore.player_queue[user.user_id] = user
user.last_match_timestamp = time()
user.c = self.c
user.select_user_about_link_play()
user.c = None
def clear_player(self, user_id: int):
MatchStore.player_queue.pop(user_id, None)
def clean_room_cache(self):
MatchStore.room_cache = []
MatchStore.last_get_rooms_timestamp = 0
def memory_clean(self):
now = time()
if now - self.last_memory_clean_timestamp < Constant.LINKPLAY_MEMORY_CLEAN_INTERVAL:
return
with self.lock:
for i in MatchStore.player_queue:
if now - i.last_match_timestamp > Constant.LINKPLAY_MATCH_TIMEOUT:
self.clear_player(i)
def match(self, user_id: int):
user = MatchStore.player_queue.get(user_id)
if user is None:
raise ArcError(
f'User `{user_id}` not found in match queue.', code=999)
if user.match_room is not None:
# 二人开新房,第二人加入
user.c = self.c
self.remote.join_room(user.match_room, user)
self.clear_player(user_id)
return self.remote.to_dict()
self.refresh_rooms()
rule = min(user.match_times, len(Constant.LINKPLAY_MATCH_PTT_ABS) -
1, len(Constant.LINKPLAY_MATCH_UNLOCK_MIN) - 1)
ptt_abs = Constant.LINKPLAY_MATCH_PTT_ABS[rule]
unlock_min = Constant.LINKPLAY_MATCH_UNLOCK_MIN[rule]
# 加入已有房间
for i in MatchStore.room_cache:
f = True
for j in i['players']:
if j['player_id'] != 0 and abs(user.rating_ptt - j['rating_ptt']) >= ptt_abs:
f = False
break
if f and user.calc_available_chart_num(b64decode(i['song_unlock'])) >= unlock_min and ((time() + 2) * 1000000 < i['next_state_timestamp'] or i['next_state_timestamp'] <= 0):
room = Room()
room.room_code = i['room_code']
user.c = self.c
self.remote.join_room(room, user)
self.clean_room_cache()
self.clear_player(user_id)
return self.remote.to_dict()
now = time()
# 二人开新房,第一人开房
for p in MatchStore.player_queue.values():
if p.user_id == user_id or now - p.last_match_timestamp > Constant.LINKPLAY_MATCH_TIMEOUT:
continue
new_rule = min(rule, p.match_times)
if abs(user.rating_ptt - p.rating_ptt) < Constant.LINKPLAY_MATCH_PTT_ABS[new_rule] and user.calc_available_chart_num(p.song_unlock) >= Constant.LINKPLAY_MATCH_UNLOCK_MIN[new_rule]:
user.c = self.c
self.remote.create_room(user)
self.clear_player(user_id)
p.match_room = self.remote.room
return self.remote.to_dict()
user.match_times += 1
user.last_match_timestamp = now
return None

View File

@@ -0,0 +1,97 @@
from .config_manager import Config
from .user import User
from .sql import Connect
from time import time
class BaseNotification:
notification_type = None
def __init__(self, c_m=None) -> None:
self.receiver = None
self.sender = None
self.timestamp = None
self.content = None
self.c_m = c_m
@property
def is_expired(self) -> bool:
now = round(time() * 1000)
return now - self.timestamp > Config.NOTIFICATION_EXPIRE_TIME
def to_dict(self) -> dict:
raise NotImplementedError()
def insert(self):
self.c_m.execute(
'''select max(id) from notification where user_id = ?''', (self.receiver.user_id,))
x = self.c_m.fetchone()
if x is None or x[0] is None:
x = 0
else:
x = x[0] + 1
self.c_m.execute(
'''insert into notification values (?, ?, ?, ?, ?, ?, ?)''',
(self.receiver.user_id, x, self.notification_type, self.content,
self.sender.user_id, self.sender.name, self.timestamp)
)
class RoomInviteNotification(BaseNotification):
notification_type = 'room_inv'
@classmethod
def from_list(cls, l: list, user: User = None) -> 'RoomInviteNotification':
x = cls()
x.sender = User()
x.sender.user_id = l[2]
x.sender.name = l[3]
x.content = l[1]
x.timestamp = l[4]
x.receiver = user
return x
@classmethod
def from_sender(cls, sender: User, receiver: User, share_token: str, c_m) -> 'RoomInviteNotification':
x = cls()
x.c_m = c_m
x.sender = sender
x.receiver = receiver
x.content = share_token
x.timestamp = round(time() * 1000)
return x
def to_dict(self) -> dict:
return {
'sender': self.sender.name,
'type': self.notification_type,
'shareToken': self.content,
'sendTs': self.timestamp
}
class NotificationFactory:
def __init__(self, c_m: Connect, user=None):
self.c_m = c_m
self.user = user
def get_notification(self) -> 'list[BaseNotification]':
r = []
self.c_m.execute('''select type, content, sender_user_id, sender_name, timestamp from notification where user_id = ?''',
(self.user.user_id,))
for i in self.c_m.fetchall():
x = None
if i[0] == 'room_inv':
x = RoomInviteNotification.from_list(i, self.user)
if x is not None and not x.is_expired:
r.append(x)
self.c_m.execute(
'''delete from notification where user_id = ?''', (self.user.user_id,))
return r

View File

@@ -37,7 +37,7 @@ class RefreshAllScoreRating(BaseOperation):
# 但其实还是很慢
with Connect() as c:
c.execute(
'''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn from chart''')
'''select song_id, rating_pst, rating_prs, rating_ftr, rating_byn, rating_etr from chart''')
x = c.fetchall()
songs = [i[0] for i in x]
@@ -45,7 +45,7 @@ class RefreshAllScoreRating(BaseOperation):
f'''update best_score set rating=0 where song_id not in ({','.join(['?']*len(songs))})''', songs)
for i in x:
for j in range(0, 4):
for j in range(0, 5):
defnum = -10 # 没在库里的全部当做定数 -10
if i[j+1] is not None and i[j+1] > 0:
defnum = float(i[j+1]) / 10
@@ -66,6 +66,38 @@ class RefreshAllScoreRating(BaseOperation):
Sql(c).update_many('best_score', ['rating', 'score_v2'], values, [
'user_id', 'song_id', 'difficulty'], where_values)
# 更新 recent30
song_defum: 'dict[str, list[int]]' = {}
for i in x:
song_defum[i[0]] = []
for j in range(0, 5):
defnum = -10
if i[j+1] is not None and i[j+1] > 0:
defnum = float(i[j+1]) / 10
song_defum[i[0]].append(defnum)
users = c.execute('''select user_id from user''').fetchall()
for i in users:
values = []
where_values = []
user_id = i[0]
c.execute(
'''select r_index, song_id, difficulty, score from recent30 where user_id = ?''', (user_id,))
for j in c.fetchall():
if j[1] in song_defum:
defnum = song_defum[j[1]][j[2]]
else:
defnum = -10
ptt = Score.calculate_rating(defnum, j[3])
ptt = max(ptt, 0)
values.append((ptt,))
where_values.append((user_id, j[0]))
if values:
Sql(c).update_many('recent30', ['rating'], values, [
'user_id', 'r_index'], where_values)
class RefreshSongFileCache(BaseOperation):
'''

View File

@@ -510,6 +510,15 @@ class MemoryDatabase:
file_path text, time int, device_id text);''')
self.c.execute(
'''create index if not exists download_token_1 on download_token (song_id, file_name);''')
self.c.execute('''
create table if not exists notification(
user_id int, id int,
type text, content text,
sender_user_id int, sender_name text,
timestamp int,
primary key(user_id, id)
)
''')
self.conn.commit()

View File

@@ -647,6 +647,20 @@ class UserInfo(User):
self.beyond_boost_gauge = x[8] if x[8] else 0
self.kanae_stored_prog = x[9] if x[9] else 0
def select_user_about_link_play(self) -> None:
'''
查询 user 表有关 link play 的信息
'''
self.c.execute(
'''select name, rating_ptt, is_hide_rating from user where user_id=?''', (self.user_id,))
x = self.c.fetchone()
if not x:
raise NoData('No user.', 108, -3)
self.name = x[0]
self.rating_ptt = x[1]
self.is_hide_rating = x[2] == 1
@property
def global_rank(self) -> int:
'''用户世界排名如果超过设定最大值返回0'''

View File

@@ -10,7 +10,7 @@ def aes_gcm_128_encrypt(key, plaintext, associated_data):
iv = os.urandom(12)
encryptor = Cipher(
algorithms.AES(key),
modes.GCM(iv, min_tag_length=12),
modes.GCM(iv, min_tag_length=16),
).encryptor()
encryptor.authenticate_additional_data(associated_data)
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
@@ -20,7 +20,7 @@ def aes_gcm_128_encrypt(key, plaintext, associated_data):
def aes_gcm_128_decrypt(key, associated_data, iv, ciphertext, tag):
decryptor = Cipher(
algorithms.AES(key),
modes.GCM(iv, tag, min_tag_length=12),
modes.GCM(iv, tag, min_tag_length=16),
).decryptor()
decryptor.authenticate_additional_data(associated_data)
return decryptor.update(ciphertext) + decryptor.finalize()

View File

@@ -490,7 +490,8 @@ class WorldSkillMixin:
'skill_amane': self._skill_amane,
'skill_maya': self._skill_maya,
'luna_uncap': self._luna_uncap,
'skill_kanae_uncap': self._skill_kanae_uncap
'skill_kanae_uncap': self._skill_kanae_uncap,
'skill_eto_hoppe': self._skill_eto_hoppe,
}
if self.character_used.skill_id_displayed in factory_dict:
factory_dict[self.character_used.skill_id_displayed]()
@@ -633,6 +634,14 @@ class WorldSkillMixin:
self.kanae_stored_progress = self.progress_normalized
self.user.current_map.reclimb(self.final_progress)
def _skill_eto_hoppe(self) -> None:
'''
eto_hoppe 技能,体力大于等于 6 格时,世界进度翻倍
'''
if self.user.stamina.stamina >= 6:
self.character_bonus_progress_normalized = self.progress_normalized
self.user.current_map.reclimb(self.final_progress)
class BaseWorldPlay(WorldSkillMixin):
'''