[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 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] OLD_GAME_API_PREFIX = [] # str | list[str]
ALLOW_APPVERSION = [] # list[str] ALLOW_APPVERSION = [] # list[str]
@@ -102,6 +102,10 @@ class Config:
API_LOGIN_RATE_LIMIT = '10/5 minutes' API_LOGIN_RATE_LIMIT = '10/5 minutes'
NOTIFICATION_EXPIRE_TIME = 3 * 60 * 1000
class ConfigManager: class ConfigManager:
@staticmethod @staticmethod

View File

@@ -1,7 +1,7 @@
from .config_manager import Config from .config_manager import Config
ARCAEA_SERVER_VERSION = 'v2.11.3.16' ARCAEA_SERVER_VERSION = 'v2.11.3.17'
ARCAEA_DATABASE_VERSION = 'v2.11.3.16' ARCAEA_DATABASE_VERSION = 'v2.11.3.17'
ARCAEA_LOG_DATBASE_VERSION = 'v1.1' ARCAEA_LOG_DATBASE_VERSION = 'v1.1'
@@ -66,6 +66,13 @@ class Constant:
LINKPLAY_TCP_SECRET_KEY = Config.LINKPLAY_TCP_SECRET_KEY LINKPLAY_TCP_SECRET_KEY = Config.LINKPLAY_TCP_SECRET_KEY
LINKPLAY_TCP_MAX_LENGTH = 0x0FFFFFFF 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. # Well, I can't say a word when I see this.
FINALE_SWITCH = [ FINALE_SWITCH = [
(0x0015F0, 0x00B032), (0x014C9A, 0x014408), (0x062585, 0x02783B), (0x0015F0, 0x00B032), (0x014C9A, 0x014408), (0x062585, 0x02783B),

View File

@@ -1,6 +1,8 @@
import socket import socket
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from json import dumps, loads from json import dumps, loads
from threading import RLock
from time import time
from core.error import ArcError, Timeout from core.error import ArcError, Timeout
@@ -36,6 +38,10 @@ class Player(UserInfo):
self.__song_unlock: bytes = None self.__song_unlock: bytes = None
self.client_song_map: dict = 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: def to_dict(self) -> dict:
return { return {
'userId': self.user_id, 'userId': self.user_id,
@@ -55,6 +61,16 @@ class Player(UserInfo):
self.client_song_map = client_song_map self.client_song_map = client_song_map
self.__song_unlock = get_song_unlock(self.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: class Room:
def __init__(self) -> None: def __init__(self) -> None:
@@ -63,11 +79,14 @@ class Room:
self.song_unlock: bytes = None self.song_unlock: bytes = None
self.share_token: str = 'abcde12345'
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
'roomId': str(self.room_id), 'roomId': str(self.room_id),
'roomCode': self.room_code, '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( raise ArcError(
'Too long body from link play server', status=400) 'Too long body from link play server', status=400)
iv = sock.recv(12) iv = sock.recv(12)
tag = sock.recv(12) tag = sock.recv(16)
ciphertext = sock.recv(cipher_len) ciphertext = sock.recv(cipher_len)
received = aes_gcm_128_decrypt( received = aes_gcm_128_decrypt(
RemoteMultiPlayer.TCP_AES_KEY, b'', iv, ciphertext, tag) RemoteMultiPlayer.TCP_AES_KEY, b'', iv, ciphertext, tag)
@@ -112,7 +131,7 @@ class RemoteMultiPlayer:
iv, ciphertext, tag = aes_gcm_128_encrypt( iv, ciphertext, tag = aes_gcm_128_encrypt(
self.TCP_AES_KEY, dumps(data).encode('utf-8'), b'') self.TCP_AES_KEY, dumps(data).encode('utf-8'), b'')
send_data = Constant.LINKPLAY_AUTHENTICATION.encode( 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) recv_data = self.tcp(send_data)
self.data_recv = loads(recv_data) self.data_recv = loads(recv_data)
@@ -126,12 +145,15 @@ class RemoteMultiPlayer:
'''创建房间''' '''创建房间'''
if user is not None: if user is not None:
self.user = user self.user = user
user.select_user_one_column('name') user.select_user_about_link_play()
self.data_swap({ self.data_swap({
'endpoint': 'create_room', 'endpoint': 'create_room',
'data': { 'data': {
'name': self.user.name, '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: if room is not None:
self.room = room self.room = room
self.user.select_user_one_column('name') self.user.select_user_about_link_play()
self.data_swap({ self.data_swap({
'endpoint': 'join_room', 'endpoint': 'join_room',
'data': { 'data': {
'name': self.user.name, 'name': self.user.name,
'song_unlock': b64encode(self.user.song_unlock).decode('utf-8'), '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'] x = self.data_recv['data']
@@ -172,10 +197,14 @@ class RemoteMultiPlayer:
'''更新房间''' '''更新房间'''
if user is not None: if user is not None:
self.user = user self.user = user
self.user.select_user_about_link_play()
self.data_swap({ self.data_swap({
'endpoint': 'update_room', 'endpoint': 'update_room',
'data': { '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'] 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: with Connect() as c:
c.execute( 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() x = c.fetchall()
songs = [i[0] for i in x] 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) f'''update best_score set rating=0 where song_id not in ({','.join(['?']*len(songs))})''', songs)
for i in x: for i in x:
for j in range(0, 4): for j in range(0, 5):
defnum = -10 # 没在库里的全部当做定数 -10 defnum = -10 # 没在库里的全部当做定数 -10
if i[j+1] is not None and i[j+1] > 0: if i[j+1] is not None and i[j+1] > 0:
defnum = float(i[j+1]) / 10 defnum = float(i[j+1]) / 10
@@ -66,6 +66,38 @@ class RefreshAllScoreRating(BaseOperation):
Sql(c).update_many('best_score', ['rating', 'score_v2'], values, [ Sql(c).update_many('best_score', ['rating', 'score_v2'], values, [
'user_id', 'song_id', 'difficulty'], where_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): class RefreshSongFileCache(BaseOperation):
''' '''

View File

@@ -510,6 +510,15 @@ class MemoryDatabase:
file_path text, time int, device_id text);''') file_path text, time int, device_id text);''')
self.c.execute( self.c.execute(
'''create index if not exists download_token_1 on download_token (song_id, file_name);''') '''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() self.conn.commit()

View File

@@ -647,6 +647,20 @@ class UserInfo(User):
self.beyond_boost_gauge = x[8] if x[8] else 0 self.beyond_boost_gauge = x[8] if x[8] else 0
self.kanae_stored_prog = x[9] if x[9] 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 @property
def global_rank(self) -> int: def global_rank(self) -> int:
'''用户世界排名如果超过设定最大值返回0''' '''用户世界排名如果超过设定最大值返回0'''

View File

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

View File

@@ -490,7 +490,8 @@ class WorldSkillMixin:
'skill_amane': self._skill_amane, 'skill_amane': self._skill_amane,
'skill_maya': self._skill_maya, 'skill_maya': self._skill_maya,
'luna_uncap': self._luna_uncap, '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: if self.character_used.skill_id_displayed in factory_dict:
factory_dict[self.character_used.skill_id_displayed]() factory_dict[self.character_used.skill_id_displayed]()
@@ -633,6 +634,14 @@ class WorldSkillMixin:
self.kanae_stored_progress = self.progress_normalized self.kanae_stored_progress = self.progress_normalized
self.user.current_map.reclimb(self.final_progress) 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): class BaseWorldPlay(WorldSkillMixin):
''' '''

View File

@@ -1,45 +1,45 @@
class InitData: class InitData:
char = ['hikari', 'tairitsu', 'kou', 'sapphire', 'lethe', 'hikari&tairitsu(reunion)', 'Tairitsu(Axium)', 'Tairitsu(Grievous Lady)', 'stella', 'Hikari & Fisica', 'ilith', 'eto', 'luna', 'shirabe', 'Hikari(Zero)', 'Hikari(Fracture)', 'Hikari(Summer)', 'Tairitsu(Summer)', 'Tairitsu & Trin', char = ['hikari', 'tairitsu', 'kou', 'sapphire', 'lethe', 'hikari&tairitsu(reunion)', 'Tairitsu(Axium)', 'Tairitsu(Grievous Lady)', 'stella', 'Hikari & Fisica', 'ilith', 'eto', 'luna', 'shirabe', 'Hikari(Zero)', 'Hikari(Fracture)', 'Hikari(Summer)', 'Tairitsu(Summer)', 'Tairitsu & Trin',
'ayu', 'Eto & Luna', 'yume', 'Seine & Hikari', 'saya', 'Tairitsu & Chuni Penguin', 'Chuni Penguin', 'haruna', 'nono', 'MTA-XXX', 'MDA-21', 'kanae', 'Hikari(Fantasia)', 'Tairitsu(Sonata)', 'sia', 'DORO*C', 'Tairitsu(Tempest)', 'brillante', 'Ilith(Summer)', 'etude', 'Alice & Tenniel', 'Luna & Mia', 'areus', 'seele', 'isabelle', 'mir', 'lagrange', 'linka', 'nami', 'Saya & Elizabeth', 'lily', 'kanae(midsummer)', 'alice&tenniel(minuet)', 'tairitsu(elegy)', 'marija', 'vita', 'hikari(fatalis)', 'saki', 'setsuna', 'amane', 'kou(winter)', 'lagrange(aria)', 'lethe(apophenia)', 'shama(UNiVERSE)', 'milk(UNiVERSE)', 'shikoku', 'mika yurisaki', 'Mithra Tercera', 'Toa Kozukata', 'Nami(Twilight)', 'Ilith & Ivy', 'Hikari & Vanessa', 'Maya', 'Insight(Ascendant - 8th Seeker)', 'Luin', 'Vita(Cadenza)', 'Ai-chan'] 'ayu', 'Eto & Luna', 'yume', 'Seine & Hikari', 'saya', 'Tairitsu & Chuni Penguin', 'Chuni Penguin', 'haruna', 'nono', 'MTA-XXX', 'MDA-21', 'kanae', 'Hikari(Fantasia)', 'Tairitsu(Sonata)', 'sia', 'DORO*C', 'Tairitsu(Tempest)', 'brillante', 'Ilith(Summer)', 'etude', 'Alice & Tenniel', 'Luna & Mia', 'areus', 'seele', 'isabelle', 'mir', 'lagrange', 'linka', 'nami', 'Saya & Elizabeth', 'lily', 'kanae(midsummer)', 'alice&tenniel(minuet)', 'tairitsu(elegy)', 'marija', 'vita', 'hikari(fatalis)', 'saki', 'setsuna', 'amane', 'kou(winter)', 'lagrange(aria)', 'lethe(apophenia)', 'shama(UNiVERSE)', 'milk(UNiVERSE)', 'shikoku', 'mika yurisaki', 'Mithra Tercera', 'Toa Kozukata', 'Nami(Twilight)', 'Ilith & Ivy', 'Hikari & Vanessa', 'Maya', 'Insight(Ascendant - 8th Seeker)', 'Luin', 'Vita(Cadenza)', 'Ai-chan', 'Luna & Ilot', 'Eto & Hoppe']
skill_id = ['gauge_easy', '', '', '', 'note_mirror', 'skill_reunion', '', 'gauge_hard', 'frag_plus_10_pack_stellights', 'gauge_easy|frag_plus_15_pst&prs', 'gauge_hard|fail_frag_minus_100', 'frag_plus_5_side_light', 'visual_hide_hp', 'frag_plus_5_side_conflict', 'challenge_fullcombo_0gauge', 'gauge_overflow', 'gauge_easy|note_mirror', 'note_mirror', 'visual_tomato_pack_tonesphere', skill_id = ['gauge_easy', '', '', '', 'note_mirror', 'skill_reunion', '', 'gauge_hard', 'frag_plus_10_pack_stellights', 'gauge_easy|frag_plus_15_pst&prs', 'gauge_hard|fail_frag_minus_100', 'frag_plus_5_side_light', 'visual_hide_hp', 'frag_plus_5_side_conflict', 'challenge_fullcombo_0gauge', 'gauge_overflow', 'gauge_easy|note_mirror', 'note_mirror', 'visual_tomato_pack_tonesphere',
'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', '', '', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', '', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', '', '', 'visual_ghost_skynotes', 'skill_vita', 'skill_fatalis', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', '', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_intruder', 'skill_luin', '', 'skill_aichan'] 'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', '', '', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', '', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', '', '', 'visual_ghost_skynotes', 'skill_vita', 'skill_fatalis', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', '', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_intruder', 'skill_luin', '', 'skill_aichan', 'skill_luna_ilot', 'skill_eto_hoppe']
skill_id_uncap = ['', '', 'frags_kou', '', 'visual_ink', '', '', '', '', '', 'ilith_awakened_skill', 'eto_uncap', 'luna_uncap', 'shirabe_entry_fee', skill_id_uncap = ['', '', 'frags_kou', '', 'visual_ink', '', '', '', '', '', 'ilith_awakened_skill', 'eto_uncap', 'luna_uncap', 'shirabe_entry_fee',
'', '', '', '', '', 'ayu_uncap', '', 'frags_yume', '', 'skill_saya_uncap', '', '', '', '', '', '', 'skill_kanae_uncap', '', '', '', 'skill_doroc_uncap', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'skill_luin_uncap', '', ''] '', '', '', '', '', 'ayu_uncap', '', 'frags_yume', '', 'skill_saya_uncap', '', '', '', '', '', '', 'skill_kanae_uncap', '', '', '', 'skill_doroc_uncap', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'skill_luin_uncap', '', '', '', '']
skill_unlock_level = [0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, skill_unlock_level = [0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0,
0, 0, 0, 8, 0, 14, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] 0, 0, 0, 8, 0, 14, 0, 0, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
frag1 = [55, 55, 60, 50, 47, 79, 47, 57, 41, 22, 50, 54, 60, 56, 78, 42, 41, 61, 52, 50, 52, 32, frag1 = [55, 55, 60, 50, 47, 79, 47, 57, 41, 22, 50, 54, 60, 56, 78, 42, 41, 61, 52, 50, 52, 32,
42, 55, 45, 58, 43, 0.5, 68, 50, 62, 45, 45, 52, 44, 27, 59, 0, 45, 50, 50, 47, 47, 61, 43, 42, 38, 25, 58, 50, 61, 45, 45, 38, 34, 27, 18, 56, 47, 30, 45, 57, 56, 47, 33, 26, 29, 66, 40, 33, 51, 27, 50, 60, 45, 50] 42, 55, 45, 58, 43, 0.5, 68, 50, 62, 45, 45, 52, 44, 27, 59, 0, 45, 50, 50, 47, 47, 61, 43, 42, 38, 25, 58, 50, 61, 45, 45, 38, 34, 27, 18, 56, 47, 30, 45, 57, 56, 47, 33, 26, 29, 66, 40, 33, 51, 27, 50, 60, 45, 50, 38, 22]
prog1 = [35, 55, 47, 50, 60, 70, 60, 70, 58, 45, 70, 45, 42, 46, 61, 67, 49, 44, 28, 45, 24, 46, 52, prog1 = [35, 55, 47, 50, 60, 70, 60, 70, 58, 45, 70, 45, 42, 46, 61, 67, 49, 44, 28, 45, 24, 46, 52,
59, 62, 33, 58, 25, 63, 69, 50, 45, 45, 51, 34, 70, 62, 70, 45, 32, 32, 61, 47, 47, 37, 42, 50, 50, 45, 41, 61, 45, 45, 58, 50, 130, 18, 57, 55, 50, 45, 70, 37.5, 29, 44, 26, 26, 35, 40, 33, 58, 31, 50, 50, 45, 41] 59, 62, 33, 58, 25, 63, 69, 50, 45, 45, 51, 34, 70, 62, 70, 45, 32, 32, 61, 47, 47, 37, 42, 50, 50, 45, 41, 61, 45, 45, 58, 50, 130, 18, 57, 55, 50, 45, 70, 37.5, 29, 44, 26, 26, 35, 40, 33, 58, 31, 50, 50, 45, 41, 12, 31]
overdrive1 = [35, 55, 25, 50, 47, 70, 72, 57, 41, 7, 10, 32, 65, 31, 61, 53, 31, 47, 38, 12, 39, 18, overdrive1 = [35, 55, 25, 50, 47, 70, 72, 57, 41, 7, 10, 32, 65, 31, 61, 53, 31, 47, 38, 12, 39, 18,
48, 65, 45, 55, 44, 25, 46, 44, 33, 45, 45, 37, 25, 27, 50, 20, 45, 63, 21, 47, 61, 47, 65, 80, 38, 30, 49, 15, 34, 45, 45, 38, 67, 120, 44, 33, 55, 50, 45, 57, 31, 29, 65, 26, 29, 42.5, 40, 33, 58, 31, 50, 34, 45, 41] 48, 65, 45, 55, 44, 25, 46, 44, 33, 45, 45, 37, 25, 27, 50, 20, 45, 63, 21, 47, 61, 47, 65, 80, 38, 30, 49, 15, 34, 45, 45, 38, 67, 120, 44, 33, 55, 50, 45, 57, 31, 29, 65, 26, 29, 42.5, 40, 33, 58, 31, 50, 34, 45, 41, 12, 19]
frag20 = [78, 80, 90, 75, 70, 79, 70, 79, 65, 40, 50, 80, 90, 82, 0, 61, 67, 92, 85, 50, 86, 52, frag20 = [78, 80, 90, 75, 70, 79, 70, 79, 65, 40, 50, 80, 90, 82, 0, 61, 67, 92, 85, 50, 86, 52,
65, 85, 67, 88, 64, 0.5, 95, 70, 95, 50, 80, 87, 71, 50, 85, 0, 80, 75, 50, 70, 70, 90, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 54, 100, 50, 58, 51, 40, 50, 70, 50, 61.6] 65, 85, 67, 88, 64, 0.5, 95, 70, 95, 50, 80, 87, 71, 50, 85, 0, 80, 75, 50, 70, 70, 90, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 54, 100, 50, 58, 51, 40, 50, 70, 50, 61.6, 48, 37]
prog20 = [61, 80, 70, 75, 90, 70, 90, 102, 84, 78, 105, 67, 63, 68, 0, 99, 80, 66, 46, 83, 40, 73, prog20 = [61, 80, 70, 75, 90, 70, 90, 102, 84, 78, 105, 67, 63, 68, 0, 99, 80, 66, 46, 83, 40, 73,
80, 90, 93, 50, 86, 78, 89, 98, 75, 80, 50, 64, 55, 100, 90, 110, 80, 50, 74, 90, 70, 70, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 50, 53, 85, 58, 96, 47, 50, 80, 67, 41] 80, 90, 93, 50, 86, 78, 89, 98, 75, 80, 50, 64, 55, 100, 90, 110, 80, 50, 74, 90, 70, 70, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 50, 53, 85, 58, 96, 47, 50, 80, 67, 41, 55, 50]
overdrive20 = [61, 80, 47, 75, 70, 70, 95, 79, 65, 31, 50, 59, 90, 58, 0, 78, 50, 70, 62, 49, 64, overdrive20 = [61, 80, 47, 75, 70, 70, 95, 79, 65, 31, 50, 59, 90, 58, 0, 78, 50, 70, 62, 49, 64,
46, 73, 95, 67, 84, 70, 78, 69, 70, 50, 80, 80, 63, 25, 50, 72, 55, 50, 95, 55, 70, 90, 70, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 54, 65.5, 59.5, 58, 96, 47, 50, 54, 90, 41] 46, 73, 95, 67, 84, 70, 78, 69, 70, 50, 80, 80, 63, 25, 50, 72, 55, 50, 95, 55, 70, 90, 70, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 54, 65.5, 59.5, 58, 96, 47, 50, 54, 90, 41, 34, 30]
frag30 = [88, 90, 100, 75, 80, 89, 70, 79, 65, 40, 50, 90, 100, 92, 0, 61, 67, 92, 85, 50, 86, 62, frag30 = [88, 90, 100, 75, 80, 89, 70, 79, 65, 40, 50, 90, 100, 92, 0, 61, 67, 92, 85, 50, 86, 62,
65, 95, 67, 88, 74, 0.5, 105, 80, 105, 50, 80, 87, 81, 50, 95, 0, 80, 75, 50, 70, 80, 100, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 64, 100, 50, 58, 51, 40, 50, 80, 50, 61.6] 65, 95, 67, 88, 74, 0.5, 105, 80, 105, 50, 80, 87, 81, 50, 95, 0, 80, 75, 50, 70, 80, 100, 65, 80, 61, 50, 68, 60, 90, 67, 50, 60, 51, 50, 35, 85, 47, 50, 75, 80, 90, 80, 50, 51, 64, 100, 50, 58, 51, 40, 50, 80, 50, 61.6, 48, 37]
prog30 = [71, 90, 80, 75, 100, 80, 90, 102, 84, 78, 110, 77, 73, 78, 0, 99, 80, 66, 46, 93, 40, 83, prog30 = [71, 90, 80, 75, 100, 80, 90, 102, 84, 78, 110, 77, 73, 78, 0, 99, 80, 66, 46, 93, 40, 83,
80, 100, 93, 50, 96, 88, 99, 108, 85, 80, 50, 64, 65, 100, 100, 110, 80, 50, 74, 90, 80, 80, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 60, 53, 85, 58, 96, 47, 50, 90, 67, 41] 80, 100, 93, 50, 96, 88, 99, 108, 85, 80, 50, 64, 65, 100, 100, 110, 80, 50, 74, 90, 80, 80, 56, 80, 79, 55, 65, 59, 90, 50, 90, 90, 75, 210, 35, 86, 92, 80, 75, 100, 60, 50, 68, 51, 60, 53, 85, 58, 96, 47, 50, 90, 67, 41, 55, 50]
overdrive30 = [71, 90, 57, 75, 80, 80, 95, 79, 65, 31, 50, 69, 100, 68, 0, 78, 50, 70, 62, 59, 64, overdrive30 = [71, 90, 57, 75, 80, 80, 95, 79, 65, 31, 50, 69, 100, 68, 0, 78, 50, 70, 62, 59, 64,
56, 73, 105, 67, 84, 80, 88, 79, 80, 60, 80, 80, 63, 35, 50, 82, 55, 50, 95, 55, 70, 100, 80, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 64, 65.5, 59.5, 58, 96, 47, 50, 64, 90, 41] 56, 73, 105, 67, 84, 80, 88, 79, 80, 60, 80, 80, 63, 35, 50, 82, 55, 50, 95, 55, 70, 100, 80, 99, 80, 61, 40, 69, 62, 51, 90, 67, 60, 100, 200, 85, 50, 92, 50, 75, 80, 49.5, 50, 100, 51, 64, 65.5, 59.5, 58, 96, 47, 50, 64, 90, 41, 34, 30]
char_type = [1, 0, 0, 0, 0, 0, 0, 2, 0, 1, 2, 0, 0, 0, 2, 3, 1, 0, 0, 0, 1, char_type = [1, 0, 0, 0, 0, 0, 0, 2, 0, 1, 2, 0, 0, 0, 2, 3, 1, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 2, 2, 2, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 2, 2, 0, 0, 2, 0, 0, 2, 0, 2, 2, 1, 0, 2, 0, 0, 2, 0, 0] 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 2, 2, 2, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2, 3, 0, 2, 2, 0, 0, 2, 0, 0, 2, 0, 2, 2, 1, 0, 2, 0, 0, 2, 0, 0, 0, 0]
char_core = { char_core = {
0: [{'core_id': 'core_hollow', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}], 0: [{'core_id': 'core_hollow', 'amount': 25}, {'core_id': 'core_desolate', 'amount': 5}],
@@ -71,7 +71,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', 'core_serene'] 'core_ambivalent', 'core_scarlet', 'core_groove', 'core_generic', 'core_binary', 'core_colorful', 'core_course_skip_purchase', 'core_umbral', 'core_wacca', 'core_sunset', 'core_tanoc', 'core_serene']
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", 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', 'oldschoolsalvage', 'distortionhuman'] "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', 'distortionhuman', 'epitaxy']
world_unlocks = ["scenery_chap1", "scenery_chap2", world_unlocks = ["scenery_chap1", "scenery_chap2",
"scenery_chap3", "scenery_chap4", "scenery_chap5", "scenery_chap6", "scenery_chap7", "scenery_beyond"] "scenery_chap3", "scenery_chap4", "scenery_chap5", "scenery_chap6", "scenery_chap7", "scenery_beyond"]

View File

@@ -844,5 +844,23 @@
], ],
"orig_price": 500, "orig_price": 500,
"price": 500 "price": 500
},
{
"name": "rotaeno",
"items": [
{
"type": "pack",
"id": "rotaeno",
"is_available": true
},
{
"type": "core",
"amount": 5,
"id": "core_generic",
"is_available": true
}
],
"orig_price": 500,
"price": 500
} }
] ]

View File

@@ -8,7 +8,7 @@ def encrypt(key, plaintext, associated_data):
iv = urandom(12) iv = urandom(12)
encryptor = Cipher( encryptor = Cipher(
algorithms.AES(key), algorithms.AES(key),
modes.GCM(iv, min_tag_length=12), modes.GCM(iv, min_tag_length=16),
).encryptor() ).encryptor()
encryptor.authenticate_additional_data(associated_data) encryptor.authenticate_additional_data(associated_data)
ciphertext = encryptor.update(plaintext) + encryptor.finalize() ciphertext = encryptor.update(plaintext) + encryptor.finalize()
@@ -18,7 +18,7 @@ def encrypt(key, plaintext, associated_data):
def decrypt(key, associated_data, iv, ciphertext, tag): def decrypt(key, associated_data, iv, ciphertext, tag):
decryptor = Cipher( decryptor = Cipher(
algorithms.AES(key), algorithms.AES(key),
modes.GCM(iv, tag, min_tag_length=12), modes.GCM(iv, tag, min_tag_length=16),
).decryptor() ).decryptor()
decryptor.authenticate_additional_data(associated_data) decryptor.authenticate_additional_data(associated_data)
return decryptor.update(ciphertext) + decryptor.finalize() return decryptor.update(ciphertext) + decryptor.finalize()

View File

@@ -24,9 +24,16 @@ class Config:
COMMAND_INTERVAL = 1000000 COMMAND_INTERVAL = 1000000
COUNTDOWM_TIME = 3999
PLAYER_PRE_TIMEOUT = 3000000 PLAYER_PRE_TIMEOUT = 3000000
PLAYER_TIMEOUT = 20000000 PLAYER_TIMEOUT = 15000000
LINK_PLAY_UNLOCK_LENGTH = 512 LINK_PLAY_UNLOCK_LENGTH = 512
COUNTDOWN_SONG_READY = 4 * 1000000
COUNTDOWN_SONG_START = 6 * 1000000
# 计时模式
COUNTDOWN_MATCHING = 15 * 1000000
COUNTDOWN_SELECT_SONG = 45 * 1000000
COUNTDOWN_SELECT_DIFFICULTY = 45 * 1000000
COUNTDOWN_RESULT = 60 * 1000000

View File

@@ -1,4 +1,4 @@
# import binascii import binascii
import logging import logging
import socketserver import socketserver
import threading import threading
@@ -21,11 +21,12 @@ class UDP_handler(socketserver.BaseRequestHandler):
try: try:
token = client_msg[:8] token = client_msg[:8]
iv = client_msg[8:20] iv = client_msg[8:20]
tag = client_msg[20:32] tag = client_msg[20:36]
ciphertext = client_msg[32:] ciphertext = client_msg[36:]
if bi(token) not in Store.link_play_data:
user = Store.link_play_data.get(bi(token))
if user is None:
return None return None
user = Store.link_play_data[bi(token)]
plaintext = decrypt(user['key'], b'', iv, ciphertext, tag) plaintext = decrypt(user['key'], b'', iv, ciphertext, tag)
except Exception as e: except Exception as e:
@@ -52,8 +53,7 @@ class UDP_handler(socketserver.BaseRequestHandler):
# logging.info( # logging.info(
# f'UDP-To-{self.client_address[0]}-{binascii.b2a_hex(i)}') # f'UDP-To-{self.client_address[0]}-{binascii.b2a_hex(i)}')
server.sendto(token + iv + tag[:12] + server.sendto(token + iv + tag + ciphertext, self.client_address)
ciphertext, self.client_address)
AUTH_LEN = len(Config.AUTHENTICATION) AUTH_LEN = len(Config.AUTHENTICATION)
@@ -77,7 +77,7 @@ class TCP_handler(socketserver.StreamRequestHandler):
return None return None
iv = self.rfile.read(12) iv = self.rfile.read(12)
tag = self.rfile.read(12) tag = self.rfile.read(16)
ciphertext = self.rfile.read(cipher_len) ciphertext = self.rfile.read(cipher_len)
self.data = decrypt(TCP_AES_KEY, b'', iv, ciphertext, tag) self.data = decrypt(TCP_AES_KEY, b'', iv, ciphertext, tag)
@@ -96,8 +96,8 @@ class TCP_handler(socketserver.StreamRequestHandler):
if Config.DEBUG: if Config.DEBUG:
logging.info(f'TCP-To-{self.client_address[0]}-{r}') logging.info(f'TCP-To-{self.client_address[0]}-{r}')
iv, ciphertext, tag = encrypt(TCP_AES_KEY, r.encode('utf-8'), b'') iv, ciphertext, tag = encrypt(TCP_AES_KEY, r.encode('utf-8'), b'')
r = len(ciphertext).to_bytes(8, byteorder='little') + \ r = len(ciphertext).to_bytes(
iv + tag[:12] + ciphertext 8, byteorder='little') + iv + tag + ciphertext
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
return None return None

View File

@@ -7,15 +7,18 @@ from time import time
from .config import Config from .config import Config
from .udp_class import Player, Room, bi from .udp_class import Player, Room, bi
from .udp_sender import CommandSender
class Store: class Store:
# token: {'key': key, 'room': Room, 'player_index': player_index, 'player_id': player_id} # token: {'key': key, 'room': Room, 'player_index': player_index, 'player_id': player_id}
link_play_data = {} link_play_data = {}
room_id_dict = {} # 'room_id': Room room_id_dict: "dict[int, Room]" = {} # 'room_id': Room
room_code_dict = {} # 'room_code': Room room_code_dict = {} # 'room_code': Room
player_dict = {} # 'player_id' : Player player_dict = {} # 'player_id' : Player
share_token_dict = {} # 'share_token': Room
lock = RLock() lock = RLock()
@@ -28,6 +31,14 @@ def random_room_code():
return re return re
def random_share_token():
CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789'
re = ''
for _ in range(10):
re += CHARSET[randint(0, 35)]
return re
def unique_random(dataset, length=8, random_func=None): def unique_random(dataset, length=8, random_func=None):
'''无重复随机且默认非0没处理可能的死循环''' '''无重复随机且默认非0没处理可能的死循环'''
if random_func is None: if random_func is None:
@@ -45,18 +56,27 @@ def clear_player(token):
# 清除玩家信息和token # 清除玩家信息和token
player_id = Store.link_play_data[token]['player_id'] player_id = Store.link_play_data[token]['player_id']
logging.info(f'Clean player `{Store.player_dict[player_id].name}`') logging.info(f'Clean player `{Store.player_dict[player_id].name}`')
del Store.player_dict[player_id] with Store.lock:
del Store.link_play_data[token] if player_id in Store.player_dict:
del Store.player_dict[player_id]
if token in Store.link_play_data:
del Store.link_play_data[token]
def clear_room(room): def clear_room(room):
# 清除房间信息 # 清除房间信息
room_id = room.room_id room_id = room.room_id
room_code = room.room_code room_code = room.room_code
share_token = room.share_token
logging.info(f'Clean room `{room_code}`') logging.info(f'Clean room `{room_code}`')
del Store.room_id_dict[room_id] with Store.lock:
del Store.room_code_dict[room_code] if room_id in Store.room_id_dict:
del room del Store.room_id_dict[room_id]
if room_code in Store.room_code_dict:
del Store.room_code_dict[room_code]
if share_token in Store.share_token_dict:
del Store.share_token_dict[share_token]
del room
def memory_clean(now): def memory_clean(now):
@@ -92,6 +112,8 @@ class TCPRouter:
'join_room', 'join_room',
'update_room', 'update_room',
'get_rooms', 'get_rooms',
'select_room',
'get_match_rooms'
} }
def __init__(self, raw_data: 'dict | list'): def __init__(self, raw_data: 'dict | list'):
@@ -115,7 +137,7 @@ class TCPRouter:
def handle(self) -> dict: def handle(self) -> dict:
self.clean_check() self.clean_check()
if self.endpoint not in self.router: if self.endpoint not in self.router:
return None return {'code': 999}
try: try:
r = getattr(self, self.endpoint)() r = getattr(self, self.endpoint)()
except Exception as e: except Exception as e:
@@ -144,7 +166,7 @@ class TCPRouter:
room_id = unique_random(Store.room_id_dict) room_id = unique_random(Store.room_id_dict)
room = Room() room = Room()
room.room_id = room_id room.room_id = room_id
room.timestamp = round(time() * 1000) room.timestamp = round(time() * 1000000)
Store.room_id_dict[room_id] = room Store.room_id_dict[room_id] = room
room_code = unique_random( room_code = unique_random(
@@ -152,6 +174,11 @@ class TCPRouter:
room.room_code = room_code room.room_code = room_code
Store.room_code_dict[room_code] = room Store.room_code_dict[room_code] = room
share_token = unique_random(
Store.share_token_dict, random_func=random_share_token)
room.share_token = share_token
Store.share_token_dict[share_token] = room
return room return room
def create_room(self) -> dict: def create_room(self) -> dict:
@@ -160,6 +187,9 @@ class TCPRouter:
# song_unlock: base64 str # song_unlock: base64 str
name = self.data['name'] name = self.data['name']
song_unlock = b64decode(self.data['song_unlock']) song_unlock = b64decode(self.data['song_unlock'])
rating_ptt = self.data.get('rating_ptt', 0)
is_hide_rating = self.data.get('is_hide_rating', False)
match_times = self.data.get('match_times', None)
key = urandom(16) key = urandom(16)
with Store.lock: with Store.lock:
@@ -167,6 +197,9 @@ class TCPRouter:
player = self.generate_player(name) player = self.generate_player(name)
player.song_unlock = song_unlock player.song_unlock = song_unlock
player.rating_ptt = rating_ptt
player.is_hide_rating = is_hide_rating
player.player_index = 0
room.song_unlock = song_unlock room.song_unlock = song_unlock
room.host_id = player.player_id room.host_id = player.player_id
room.players[0] = player room.players[0] = player
@@ -174,6 +207,12 @@ class TCPRouter:
token = room.room_id token = room.room_id
player.token = token player.token = token
# 匹配模式追加
if match_times is not None:
room.is_public = 1
room.round_mode = 3
room.timed_mode = 1
Store.link_play_data[token] = { Store.link_play_data[token] = {
'key': key, 'key': key,
'room': room, 'room': room,
@@ -198,6 +237,9 @@ class TCPRouter:
key = urandom(16) key = urandom(16)
name = self.data['name'] name = self.data['name']
song_unlock = b64decode(self.data['song_unlock']) song_unlock = b64decode(self.data['song_unlock'])
rating_ptt = self.data.get('rating_ptt', 0)
is_hide_rating = self.data.get('is_hide_rating', False)
match_times = self.data.get('match_times', None)
with Store.lock: with Store.lock:
if room_code not in Store.room_code_dict: if room_code not in Store.room_code_dict:
@@ -212,7 +254,7 @@ class TCPRouter:
if player_num == 0: if player_num == 0:
# 房间不存在 # 房间不存在
return 1202 return 1202
if room.state != 2: if room.state not in (0, 1, 2) or (room.is_public and match_times is None):
# 无法加入 # 无法加入
return 1205 return 1205
@@ -221,16 +263,18 @@ class TCPRouter:
player = self.generate_player(name) player = self.generate_player(name)
player.token = token player.token = token
player.song_unlock = song_unlock player.song_unlock = song_unlock
player.rating_ptt = rating_ptt
player.is_hide_rating = is_hide_rating
room.update_song_unlock() room.update_song_unlock()
for i in range(4): for i in range(4):
if room.players[i].player_id == 0: if room.players[i].player_id == 0:
room.players[i] = player room.players[i] = player
player_index = i player.player_index = i
break break
Store.link_play_data[token] = { Store.link_play_data[token] = {
'key': key, 'key': key,
'room': room, 'room': room,
'player_index': player_index, 'player_index': player.player_index,
'player_id': player.player_id 'player_id': player.player_id
} }
@@ -248,11 +292,23 @@ class TCPRouter:
# 房间信息更新 # 房间信息更新
# data = ['3', token] # data = ['3', token]
token = int(self.data['token']) token = int(self.data['token'])
rating_ptt = self.data.get('rating_ptt', 0)
is_hide_rating = self.data.get('is_hide_rating', False)
with Store.lock: with Store.lock:
if token not in Store.link_play_data: if token not in Store.link_play_data:
return 108 return 108
r = Store.link_play_data[token] r = Store.link_play_data[token]
room = r['room'] room = r['room']
# 更新玩家信息
player_index = r['player_index']
player = room.players[player_index]
player.rating_ptt = rating_ptt
player.is_hide_rating = is_hide_rating
cs = CommandSender(room)
room.command_queue.append(cs.command_12(player_index))
logging.info(f'TCP-Room `{room.room_code}` info update') logging.info(f'TCP-Room `{room.room_code}` info update')
return { return {
'room_code': room.room_code, 'room_code': room.room_code,
@@ -300,3 +356,55 @@ class TCPRouter:
'has_more': f2, 'has_more': f2,
'rooms': rooms 'rooms': rooms
} }
def select_room(self) -> dict:
# 查询房间信息
room_code = self.data.get('room_code', None)
share_token = self.data.get('share_token', None)
if room_code is not None:
room = Store.room_code_dict.get(room_code, None)
elif share_token is not None:
room = Store.share_token_dict.get(share_token, None)
if room is None:
return 108
return {
'room_id': room.room_id,
'room_code': room.room_code,
'share_token': room.share_token,
'is_enterable': room.is_enterable,
'is_matchable': room.is_matchable,
'is_playing': room.is_playing,
'is_public': room.is_public == 1,
'timed_mode': room.timed_mode == 1,
}
def get_match_rooms(self):
n = 0
rooms = []
for room in Store.room_id_dict.values():
if not room.is_matchable:
continue
rooms.append({
'room_id': room.room_id,
'room_code': room.room_code,
'share_token': room.share_token,
'is_matchable': room.is_matchable,
'next_state_timestamp': room.next_state_timestamp,
'song_unlock': b64encode(room.song_unlock).decode('utf-8'),
'players': [{
'player_id': i.player_id,
'name': i.name,
'rating_ptt': i.rating_ptt
} for i in room.players]
})
if n >= 100:
break
return {
'amount': n,
'rooms': rooms
}

View File

@@ -1,5 +1,6 @@
import logging import logging
from time import time from time import time
from random import randint
from .config import Config from .config import Config
@@ -12,26 +13,73 @@ def bi(value):
return int.from_bytes(value, byteorder='little') return int.from_bytes(value, byteorder='little')
class Player: class Score:
def __init__(self) -> None: def __init__(self) -> None:
self.difficulty = 0xff
self.score = 0
self.cleartype = 0
self.timer = 0
self.best_score_flag = 0 # personal best
self.best_player_flag = 0 # high score
# 5.10 新增
self.shiny_perfect_count = 0 # 2 bytes
self.perfect_count = 0 # 2 bytes
self.near_count = 0 # 2 bytes
self.miss_count = 0 # 2 bytes
self.early_count = 0 # 2 bytes
self.late_count = 0 # 2 bytes
self.healthy = 0 # 4 bytes signed? 不确定,但似乎没影响
def copy(self, x: 'Score'):
self.difficulty = x.difficulty
self.score = x.score
self.cleartype = x.cleartype
self.timer = x.timer
self.best_score_flag = x.best_score_flag
self.best_player_flag = x.best_player_flag
self.shiny_perfect_count = x.shiny_perfect_count
self.perfect_count = x.perfect_count
self.near_count = x.near_count
self.miss_count = x.miss_count
self.early_count = x.early_count
self.late_count = x.late_count
self.healthy = x.healthy
def clear(self):
self.difficulty = 0xff
self.score = 0
self.cleartype = 0
self.timer = 0
self.best_score_flag = 0
self.best_player_flag = 0
self.shiny_perfect_count = 0
self.perfect_count = 0
self.near_count = 0
self.miss_count = 0
self.early_count = 0
self.late_count = 0
self.healthy = 0
def __str__(self):
return f'Score: {self.score}, Cleartype: {self.cleartype}, Difficulty: {self.difficulty}, Timer: {self.timer}, Best Score Flag: {self.best_score_flag}, Best Player Flag: {self.best_player_flag}, Shiny Perfect: {self.shiny_perfect_count}, Perfect: {self.perfect_count}, Near: {self.near_count}, Miss: {self.miss_count}, Early: {self.early_count}, Late: {self.late_count}, Healthy: {self.healthy}'
class Player:
def __init__(self, player_index: int = 0) -> None:
self.player_id = 0 self.player_id = 0
self.player_name = b'\x45\x6d\x70\x74\x79\x50\x6c\x61\x79\x65\x72\x00\x00\x00\x00\x00' self.player_name = b'\x45\x6d\x70\x74\x79\x50\x6c\x61\x79\x65\x72\x00\x00\x00\x00\x00'
self.token = 0 self.token = 0
self.character_id = 0xff self.character_id = 0xff
self.last_character_id = 0xff
self.is_uncapped = 0 self.is_uncapped = 0
self.difficulty = 0xff self.score = Score()
self.last_difficulty = 0xff self.last_score = Score()
self.score = 0
self.last_score = 0
self.timer = 0
self.last_timer = 0
self.cleartype = 0
self.last_cleartype = 0
self.best_score_flag = 0
self.best_player_flag = 0
self.finish_flag = 0 self.finish_flag = 0
self.player_state = 1 self.player_state = 1
@@ -45,6 +93,16 @@ class Player:
self.start_command_num = 0 self.start_command_num = 0
# 5.10 新增
self.voting: int = 0x8000 # 2 bytes, song_idx, 0xffff 为不选择0x8000 为默认值
self.player_index: int = player_index # 1 byte 不确定对不对
self.switch_2: int = 0 # 1 byte
self.rating_ptt: int = 0 # 2 bytes
self.is_hide_rating: int = 0 # 1 byte
self.switch_4: int = 0 # 1 byte 只能确定有 00 和 01
@property @property
def name(self) -> str: def name(self) -> str:
return self.player_name.decode('ascii').rstrip('\x00') return self.player_name.decode('ascii').rstrip('\x00')
@@ -56,15 +114,23 @@ class Player:
'is_online': self.online == 1, 'is_online': self.online == 1,
'character_id': self.character_id, 'character_id': self.character_id,
'is_uncapped': self.is_uncapped == 1, 'is_uncapped': self.is_uncapped == 1,
'rating_ptt': self.rating_ptt,
'is_hide_rating': self.is_hide_rating == 1,
'last_song': { 'last_song': {
'difficulty': self.last_difficulty, 'difficulty': self.last_score.difficulty,
'score': self.last_score, 'score': self.last_score.score,
'cleartype': self.last_cleartype, 'cleartype': self.last_score.cleartype,
'shine_perfect': self.last_score.shiny_perfect_count,
'perfect': self.last_score.perfect_count,
'near': self.last_score.near_count,
'miss': self.last_score.miss_count,
'early': self.last_score.early_count,
'late': self.last_score.late_count,
}, },
'song': { 'song': {
'difficulty': self.difficulty, 'difficulty': self.score.difficulty,
'score': self.score, 'score': self.score.score,
'cleartype': self.cleartype, 'cleartype': self.score.cleartype,
}, },
'player_state': self.player_state, 'player_state': self.player_state,
'last_timestamp': self.last_timestamp, 'last_timestamp': self.last_timestamp,
@@ -77,30 +143,92 @@ class Player:
else: else:
self.player_name += b'\x00' * (16 - len(self.player_name)) self.player_name += b'\x00' * (16 - len(self.player_name))
@property
def info(self) -> bytes:
re = bytearray()
re.extend(b(self.player_id, 8))
re.append(self.character_id)
re.append(self.is_uncapped)
re.append(self.score.difficulty)
re.extend(b(self.score.score, 4))
re.extend(b(self.score.timer, 4))
re.append(self.score.cleartype)
re.append(self.player_state)
re.append(self.download_percent)
re.append(self.online)
re.extend(b(self.voting, 2))
re.append(self.player_index)
re.append(self.switch_2)
re.extend(b(self.rating_ptt, 2))
re.append(self.is_hide_rating)
re.append(self.switch_4)
return bytes(re)
@property
def last_score_info(self) -> bytes:
if self.player_id == 0:
return b'\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
x = self.last_score
re = bytearray()
re.append(self.character_id)
re.append(x.difficulty)
re.extend(b(x.score, 4))
re.append(x.cleartype)
re.append(x.best_score_flag)
re.append(x.best_player_flag)
re.extend(b(x.shiny_perfect_count, 2))
re.extend(b(x.perfect_count, 2))
re.extend(b(x.near_count, 2))
re.extend(b(x.miss_count, 2))
re.extend(b(x.early_count, 2))
re.extend(b(x.late_count, 2))
re.extend(b(x.healthy, 4))
return bytes(re)
class Room: class Room:
def __init__(self) -> None: def __init__(self) -> None:
self.room_id = 0 self.room_id = 0
self.room_code = 'AAAA00' self.room_code = 'AAAA00'
self.share_token = 'abcde12345' # 5.10 新增
self.countdown = 0xffffffff self.countdown = 0xffffffff
self.timestamp = 0 self.timestamp = 0
self.state = 0 self._state = 0
self.song_idx = 0xffff self.song_idx = 0xffff # 疑似 idx * 5
self.last_song_idx = 0xffff self.last_song_idx = 0xffff # 疑似 idx * 5
self.song_unlock = b'\xFF' * Config.LINK_PLAY_UNLOCK_LENGTH self.song_unlock = b'\xFF' * Config.LINK_PLAY_UNLOCK_LENGTH
self.host_id = 0 self.host_id = 0
self.players = [Player(), Player(), Player(), Player()] self.players = [Player(0), Player(1), Player(2), Player(3)]
self.interval = 1000 self.interval = 1000
self.times = 100 self.times = 100 # ???
self.round_switch = 0 self.round_mode: int = 1 # 5.10 从 bool 修改为 int 1~3
self.is_public = 0 # 5.10 新增
self.timed_mode = 0 # 5.10 新增
self.selected_voter_player_id: int = 0 # 5.10 新增
self.command_queue = [] self.command_queue = []
self.next_state_timestamp = 0 # 计时模式下一个状态时间
@property
def state(self) -> int:
return self._state
@state.setter
def state(self, value: int):
self._state = value
self.countdown = 0xffffffff
def to_dict(self) -> dict: def to_dict(self) -> dict:
p = [i.to_dict() for i in self.players if i.player_id != 0] p = [i.to_dict() for i in self.players if i.player_id != 0]
for i in p: for i in p:
@@ -108,21 +236,47 @@ class Room:
return { return {
'room_id': self.room_id, 'room_id': self.room_id,
'room_code': self.room_code, 'room_code': self.room_code,
'share_token': self.share_token,
'state': self.state, 'state': self.state,
'song_idx': self.song_idx, 'song_idx': self.song_idx,
'last_song_idx': self.last_song_idx if not self.is_playing else 0xffff, 'last_song_idx': self.last_song_idx if not self.is_playing else 0xffff,
'host_id': self.host_id, 'host_id': self.host_id,
'players': p, 'players': p,
'round_switch': self.round_switch == 1, 'round_mode': self.round_mode,
'last_timestamp': self.timestamp, 'last_timestamp': self.timestamp,
'is_enterable': self.is_enterable, 'is_enterable': self.is_enterable,
'is_matchable': self.is_matchable,
'is_playing': self.is_playing, 'is_playing': self.is_playing,
'is_public': self.is_public == 1,
'timed_mode': self.timed_mode == 1,
} }
@property
def room_info(self) -> bytes:
re = bytearray()
re.extend(b(self.host_id, 8))
re.append(self.state)
re.extend(b(self.countdown, 4))
re.extend(b(self.timestamp, 8))
re.extend(b(self.song_idx, 2))
re.extend(b(self.interval, 2))
re.extend(b(self.times, 7))
re.extend(self.get_player_last_score())
re.extend(b(self.last_song_idx, 2))
re.append(self.round_mode)
re.append(self.is_public)
re.append(self.timed_mode)
re.extend(b(self.selected_voter_player_id, 8))
return bytes(re)
@property @property
def is_enterable(self) -> bool: def is_enterable(self) -> bool:
return 0 < self.player_num < 4 and self.state == 2 return 0 < self.player_num < 4 and self.state == 2
@property
def is_matchable(self) -> bool:
return self.is_public and 0 < self.player_num < 4 and self.state == 1
@property @property
def is_playing(self) -> bool: def is_playing(self) -> bool:
return self.state in (4, 5, 6, 7) return self.state in (4, 5, 6, 7)
@@ -133,7 +287,9 @@ class Room:
@property @property
def player_num(self) -> int: def player_num(self) -> int:
self.check_player_online() now = round(time() * 1000000)
if now - self.timestamp >= 1000000:
self.check_player_online(now)
return sum(i.player_id != 0 for i in self.players) return sum(i.player_id != 0 for i in self.players)
def check_player_online(self, now: int = None): def check_player_online(self, now: int = None):
@@ -156,29 +312,18 @@ class Room:
def get_players_info(self): def get_players_info(self):
# 获取所有玩家信息 # 获取所有玩家信息
re = b'' re = bytearray()
for i in self.players: for i in self.players:
re += b(i.player_id, 8) + b(i.character_id) + b(i.is_uncapped) + b(i.difficulty) + b(i.score, 4) + \ re.extend(i.info)
b(i.timer, 4) + b(i.cleartype) + b(i.player_state) + \ re.append(0)
b(i.download_percent) + b(i.online) + b'\x00' + i.player_name re.extend(i.player_name)
return re return bytes(re)
def get_player_last_score(self): def get_player_last_score(self):
# 获取上次曲目玩家分数返回bytes # 获取上次曲目玩家分数返回bytes
if self.last_song_idx == 0xffff: if self.last_song_idx == 0xffff:
return b'\xff\xff\x00\x00\x00\x00\x00\x00\x00' * 4 return b'\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' * 4
re = b'' return b''.join(i.last_score_info for i in self.players)
for i in range(4):
player = self.players[i]
if player.player_id != 0:
re += b(player.last_character_id) + b(player.last_difficulty) + b(player.last_score, 4) + b(
player.last_cleartype) + b(player.best_score_flag) + b(player.best_player_flag)
else:
re += b'\xff\xff\x00\x00\x00\x00\x00\x00\x00'
return re
def make_round(self): def make_round(self):
# 轮换房主 # 轮换房主
@@ -203,9 +348,19 @@ class Room:
f'Player `{player.name}` leaves room `{self.room_code}`') f'Player `{player.name}` leaves room `{self.room_code}`')
self.players[player_index].online = 0 self.players[player_index].online = 0
self.players[player_index] = Player() self.players[player_index] = Player(player_index)
self.update_song_unlock() self.update_song_unlock()
if self.state in (2, 3):
self.state = 1
self.song_idx = 0xffff
self.voting_clear()
print(self.player_num)
if self.state in (1, 2) and self.timed_mode and self.player_num <= 1:
self.next_state_timestamp = 0
self.countdown = 0xffffffff
def update_song_unlock(self): def update_song_unlock(self):
# 更新房间可用歌曲 # 更新房间可用歌曲
r = bi(b'\xff' * Config.LINK_PLAY_UNLOCK_LENGTH) r = bi(b'\xff' * Config.LINK_PLAY_UNLOCK_LENGTH)
@@ -245,27 +400,110 @@ class Room:
max_score_i = [] max_score_i = []
for i in range(4): for i in range(4):
player = self.players[i] player = self.players[i]
if player.player_id != 0: if player.player_id == 0:
player.finish_flag = 0 continue
player.last_timer = player.timer player.finish_flag = 0
player.last_score = player.score player.last_score.copy(player.score)
player.last_cleartype = player.cleartype player.last_score.best_player_flag = 0
player.last_character_id = player.character_id
player.last_difficulty = player.difficulty
player.best_player_flag = 0
if player.last_score > max_score: if player.last_score.score > max_score:
max_score = player.last_score max_score = player.last_score.score
max_score_i = [i] max_score_i = [i]
elif player.last_score == max_score: elif player.last_score.score == max_score:
max_score_i.append(i) max_score_i.append(i)
for i in max_score_i: for i in max_score_i:
self.players[i].best_player_flag = 1 self.players[i].last_score.best_player_flag = 1
self.voting_clear()
for i in self.players:
i.score.clear()
logging.info( logging.info(
f'Room `{self.room_code}` finishes song `{self.song_idx}`') f'Room `{self.room_code}` finishes song `{self.song_idx}`')
for i in self.players: for i in self.players:
if i.player_id != 0: if i.player_id != 0:
logging.info( logging.info(f'- Player `{i.name}` - {i.last_score}')
f'- Player `{i.name}` - Score: {i.last_score} Cleartype: {i.last_cleartype} Difficulty: {i.last_difficulty}')
@property
def is_all_player_voted(self) -> bool:
# 是否所有玩家都投票
if self.state != 2:
return False
for i in self.players:
if i.player_id != 0 and i.voting == 0x8000:
return False
return True
def random_song(self):
random_list = []
for i in range(Config.LINK_PLAY_UNLOCK_LENGTH):
for j in range(8):
if self.song_unlock[i] & (1 << j):
random_list.append(i * 8 + j)
if not random_list:
self.song_idx = 0
else:
self.song_idx = random_list[randint(0, len(random_list) - 1)]
def make_voting(self):
# 投票
self.state = 3
self.selected_voter_player_id = 0
random_list = []
random_list_player_id = []
for i in self.players:
if i.player_id == 0 or i.voting == 0xffff or i.voting == 0x8000:
continue
random_list.append(i.voting)
random_list_player_id.append(i.player_id)
if random_list:
idx = randint(0, len(random_list) - 1)
self.song_idx = random_list[idx] * 5
self.selected_voter_player_id = random_list_player_id[idx]
else:
self.random_song()
logging.info(
f'Room `{self.room_code}` votes song `{self.song_idx}`')
def voting_clear(self):
# 清除投票
self.selected_voter_player_id = 0
for i in self.players:
i.voting = 0x8000
@property
def should_next_state(self) -> bool:
if not self.timed_mode and self.state not in (4, 5, 6):
self.countdown = 0xffffffff
return False
now = round(time() * 1000000)
if self.countdown == 0xffffffff:
# 还没开始计时
if self.is_public and self.state == 1:
self.next_state_timestamp = now + Config.COUNTDOWN_MATCHING
elif self.state == 2:
self.next_state_timestamp = now + Config.COUNTDOWN_SELECT_SONG
elif self.state == 3:
self.next_state_timestamp = now + Config.COUNTDOWN_SELECT_DIFFICULTY
elif self.state == 4:
self.next_state_timestamp = now + Config.COUNTDOWN_SONG_READY
elif self.state == 5 or self.state == 6:
self.next_state_timestamp = now + Config.COUNTDOWN_SONG_START
elif self.state == 8:
self.next_state_timestamp = now + Config.COUNTDOWN_RESULT
else:
return False
# 不是哥们616 你脑子怎么长的,上个版本是毫秒时间戳,新版本变成了微秒???那你这倒计时怎么还是毫秒啊!!!
self.countdown = (self.next_state_timestamp - now) // 1000
if self.countdown <= 0:
self.countdown = 0
return True
return False

View File

@@ -1,14 +1,27 @@
import logging import logging
import time
from .udp_class import Room, bi
from .config import Config from .config import Config
from .udp_class import Room, bi
from .udp_sender import CommandSender from .udp_sender import CommandSender
class CommandParser: class CommandParser:
route = [None, 'command_01', 'command_02', 'command_03', 'command_04', 'command_05',
'command_06', 'command_07', 'command_08', 'command_09', 'command_0a', 'command_0b'] route = {
0x01: 'command_01',
0x02: 'command_02',
0x03: 'command_03',
0x04: 'command_04',
0x06: 'command_06',
0x07: 'command_07',
0x08: 'command_08',
0x09: 'command_09',
0x0a: 'command_0a',
0x0b: 'command_0b',
0x20: 'command_20',
0x22: 'command_22',
0x23: 'command_23',
}
def __init__(self, room: Room, player_index: int = 0) -> None: def __init__(self, room: Room, player_index: int = 0) -> None:
self.room = room self.room = room
@@ -31,7 +44,7 @@ class CommandParser:
re.append(self.room.command_queue[i]) re.append(self.room.command_queue[i])
if self.room.players[self.player_index].extra_command_queue: if self.room.players[self.player_index].extra_command_queue:
re += self.room.players[self.player_index].extra_command_queue re += self.room.players[self.player_index].extra_command_queue[-12:]
self.room.players[self.player_index].extra_command_queue = [] self.room.players[self.player_index].extra_command_queue = []
if r: if r:
@@ -52,10 +65,14 @@ class CommandParser:
self.room.command_queue.append(self.s.command_10()) self.room.command_queue.append(self.s.command_10())
def command_02(self): def command_02(self):
# 房主选歌
if self.room.round_mode == 3:
logging.warning('Error: round_mode == 3 in command 02')
return None
self.s.random_code = self.command[16:24] self.s.random_code = self.command[16:24]
song_idx = bi(self.command[24:26]) song_idx = bi(self.command[24:26])
flag = 2 flag = 5
if self.room.state == 2: if self.room.state == 2:
flag = 0 flag = 0
self.room.state = 3 self.room.state = 3
@@ -69,10 +86,17 @@ class CommandParser:
# 尝试进入结算 # 尝试进入结算
self.s.random_code = self.command[16:24] self.s.random_code = self.command[16:24]
player = self.room.players[self.player_index] player = self.room.players[self.player_index]
player.score = bi(self.command[24:28]) player.score.score = bi(self.command[24:28])
player.cleartype = self.command[28] player.score.cleartype = self.command[28]
player.difficulty = self.command[29] player.score.difficulty = self.command[29]
player.best_score_flag = self.command[30] player.score.best_score_flag = self.command[30]
player.score.shiny_perfect_count = bi(self.command[31:33])
player.score.perfect_count = bi(self.command[33:35])
player.score.near_count = bi(self.command[35:37])
player.score.miss_count = bi(self.command[37:39])
player.score.early_count = bi(self.command[39:41])
player.score.late_count = bi(self.command[41:43])
player.score.healthy = bi(self.command[43:47])
player.finish_flag = 1 player.finish_flag = 1
player.last_timestamp -= Config.COMMAND_INTERVAL player.last_timestamp -= Config.COMMAND_INTERVAL
self.room.last_song_idx = self.room.song_idx self.room.last_song_idx = self.room.song_idx
@@ -94,19 +118,16 @@ class CommandParser:
flag = 1 flag = 1
self.room.delete_player(i) self.room.delete_player(i)
self.room.command_queue.append(self.s.command_12(i)) self.room.command_queue.append(self.s.command_12(i))
self.room.update_song_unlock()
self.room.command_queue.append(self.s.command_14()) self.room.command_queue.append(self.s.command_14())
break break
return [self.s.command_0d(flag)] return [self.s.command_0d(flag)]
def command_05(self):
pass
def command_06(self): def command_06(self):
self.s.random_code = self.command[16:24] self.s.random_code = self.command[16:24]
self.room.state = 1 self.room.state = 1
self.room.song_idx = 0xffff self.room.song_idx = 0xffff
self.room.voting_clear()
self.room.command_queue.append(self.s.command_13()) self.room.command_queue.append(self.s.command_13())
@@ -117,13 +138,17 @@ class CommandParser:
self.room.command_queue.append(self.s.command_14()) self.room.command_queue.append(self.s.command_14())
# 07 可能需要一个 0d 响应code = 0x0b
def command_08(self): def command_08(self):
self.room.round_switch = bi(self.command[24:25]) # 可能弃用
self.s.random_code = self.command[16:24] logging.warning('Command 08 is outdated')
self.room.command_queue.append(self.s.command_13()) pass
# self.room.round_mode = bi(self.command[24:25])
# self.s.random_code = self.command[16:24]
# self.room.command_queue.append(self.s.command_13())
def command_09(self): def command_09(self):
re = []
self.s.random_code = self.command[16:24] self.s.random_code = self.command[16:24]
player = self.room.players[self.player_index] player = self.room.players[self.player_index]
@@ -133,133 +158,166 @@ class CommandParser:
self.room.update_song_unlock() self.room.update_song_unlock()
player.start_command_num = self.room.command_queue_length player.start_command_num = self.room.command_queue_length
self.room.command_queue.append(self.s.command_15()) self.room.command_queue.append(self.s.command_15())
else: return None
if self.s.timestamp - player.last_timestamp >= Config.COMMAND_INTERVAL:
re.append(self.s.command_0c())
player.last_timestamp = self.s.timestamp
# 离线判断 flag_0c = False
flag_13, player_index_list = self.room.check_player_online(
self.s.timestamp)
for i in player_index_list:
self.room.command_queue.append(self.s.command_12(i))
flag_11 = False if self.s.timestamp - player.last_timestamp >= Config.COMMAND_INTERVAL:
flag_12 = False flag_0c = True
player.last_timestamp = self.s.timestamp
if player.online == 0: # 离线判断
flag_12 = True flag_13, player_index_list = self.room.check_player_online(
player.online = 1 self.s.timestamp)
for i in player_index_list:
self.room.command_queue.append(self.s.command_12(i))
if self.room.is_ready(1, 1): flag_11 = False
flag_13 = True flag_12 = False
self.room.state = 2
if player.player_state != self.command[32]: if player.online == 0:
flag_12 = True flag_12 = True
player.player_state = self.command[32] player.online = 1
if player.difficulty != self.command[33] and player.player_state != 5 and player.player_state != 6 and player.player_state != 7 and player.player_state != 8: if self.room.state in (1, 2) and player.player_state == 8:
flag_12 = True # 还在结算给踢了
player.difficulty = self.command[33] # 冗余,为了保险
self.room.delete_player(self.player_index)
self.room.command_queue.append(
self.s.command_12(self.player_index))
self.room.command_queue.append(self.s.command_14())
if player.cleartype != self.command[34] and player.player_state != 7 and player.player_state != 8: if self.room.is_ready(1, 1) and ((self.room.player_num > 1 and not self.room.is_public) or (self.room.is_public and self.room.player_num == 4)):
flag_12 = True flag_13 = True
player.cleartype = self.command[34] self.room.state = 2
if player.download_percent != self.command[35]: if self.room.state == 1 and self.room.is_public and self.room.player_num > 1 and self.room.should_next_state:
flag_12 = True flag_0c = True
player.download_percent = self.command[35] flag_13 = True
self.room.state = 2
if player.character_id != self.command[36]: if self.room.state in (2, 3) and self.room.player_num < 2:
flag_12 = True flag_13 = True
player.character_id = self.command[36] self.room.state = 1
if player.is_uncapped != self.command[37]: if self.room.state == 2 and self.room.should_next_state:
flag_12 = True flag_0c = True
player.is_uncapped = self.command[37] self.room.state = 3
flag_13 = True
if self.room.round_mode == 3:
self.room.make_voting()
else:
self.room.random_song()
if self.room.state == 3 and player.score != bi(self.command[24:28]): if player.player_state != self.command[32]:
flag_12 = True flag_12 = True
player.score = bi(self.command[24:28]) player.player_state = self.command[32]
if self.room.is_ready(3, 4): if player.score.difficulty != self.command[33] and player.player_state not in (5, 6, 7, 8):
flag_13 = True flag_12 = True
self.room.countdown = Config.COUNTDOWM_TIME player.score.difficulty = self.command[33]
self.room.timestamp = round(time.time() * 1000)
self.room.state = 4
if self.room.round_switch == 1:
# 将换房主时间提前到此刻
self.room.make_round()
logging.info(f'Room `{self.room.room_code}` starts playing') if player.score.cleartype != self.command[34] and player.player_state != 7 and player.player_state != 8:
flag_12 = True
player.score.cleartype = self.command[34]
if self.room.state in (4, 5, 6): if player.download_percent != self.command[35]:
timestamp = round(time.time() * 1000) flag_12 = True
self.room.countdown -= timestamp - self.room.timestamp player.download_percent = self.command[35]
self.room.timestamp = timestamp
if self.room.state == 4 and self.room.countdown <= 0:
# 此处不清楚
self.room.state = 5
self.room.countdown = 5999
flag_11 = True
flag_13 = True
if self.room.state == 5 and self.room.is_ready(5, 6): if player.character_id != self.command[36]:
self.room.state = 6 flag_12 = True
flag_13 = True player.character_id = self.command[36]
if self.room.state == 5 and self.room.is_ready(5, 7): if player.is_uncapped != self.command[37]:
self.room.state = 7 flag_12 = True
self.room.countdown = 0xffffffff player.is_uncapped = self.command[37]
flag_13 = True
if self.room.state == 5 and self.room.countdown <= 0: if self.room.state == 3 and player.score.score != bi(self.command[24:28]):
print('我怎么知道这是啥') flag_12 = True
player.score.score = bi(self.command[24:28])
if self.room.state == 6 and self.room.countdown <= 0: if self.room.is_ready(3, 4) or (self.room.state == 3 and self.room.should_next_state):
# 此处不清楚 flag_13 = True
self.room.state = 7 flag_0c = True
self.room.countdown = 0xffffffff self.room.state = 4
flag_13 = True
self.room.countdown = self.room.countdown if self.room.countdown > 0 else 0 if self.room.round_mode == 2:
# 将换房主时间提前到此刻
self.room.make_round()
logging.info(f'Room `{self.room.room_code}` starts playing')
if self.room.state in (7, 8): if self.room.state == 4:
if player.timer < bi(self.command[28:32]) or bi(self.command[28:32]) == 0 and player.timer != 0: if player.download_percent != 0xff:
player.last_timer = player.timer # 有人没下载完把他踢了!
player.last_score = player.score self.room.delete_player(self.player_index)
player.timer = bi(self.command[28:32])
player.score = bi(self.command[24:28])
if player.timer != 0 or self.room.state != 8:
for i in self.room.players:
i.extra_command_queue.append(
self.s.command_0e(self.player_index))
if self.room.is_ready(8, 1):
flag_13 = True
self.room.state = 1
self.room.song_idx = 0xffff
for i in self.room.players:
i.timer = 0
i.score = 0
if self.room.is_finish():
# 有人退房导致的结算
self.room.make_finish()
flag_13 = True
if flag_11:
self.room.command_queue.append(self.s.command_11())
if flag_12:
self.room.command_queue.append( self.room.command_queue.append(
self.s.command_12(self.player_index)) self.s.command_12(self.player_index))
if flag_13: self.room.command_queue.append(self.s.command_14())
self.room.command_queue.append(self.s.command_13())
return re if self.room.should_next_state:
self.room.state = 5
flag_11 = True
flag_13 = True
if self.room.state == 5:
flag_13 = True
if self.room.is_ready(5, 6):
self.room.state = 6
if self.room.is_ready(5, 7):
self.room.state = 7
if self.room.state in (5, 6) and self.room.should_next_state:
# 此处不清楚
self.room.state = 7
flag_13 = True
if self.room.state in (7, 8):
player_now_timer = bi(self.command[28:32])
if player.score.timer < player_now_timer or player_now_timer == 0 and player.score.timer != 0:
player.last_score.timer = player.score.timer
player.last_score.score = player.score.score
player.score.timer = player_now_timer
player.score.score = bi(self.command[24:28])
if player.score.timer != 0 or self.room.state != 8:
for i in self.room.players:
i.extra_command_queue.append(
self.s.command_0e(self.player_index))
if self.room.is_ready(8, 1):
flag_13 = True
self.room.state = 1
self.room.song_idx = 0xffff
if self.room.state == 8 and self.room.should_next_state:
flag_0c = True
flag_13 = True
self.room.state = 1
self.room.song_idx = 0xffff
if self.room.state in (1, 2) and player.player_state == 8:
# 还在结算给踢了
self.room.delete_player(self.player_index)
self.room.command_queue.append(
self.s.command_12(self.player_index))
self.room.command_queue.append(self.s.command_14())
if self.room.is_finish():
# 有人退房导致的结算
self.room.make_finish()
flag_13 = True
if flag_11:
self.room.command_queue.append(self.s.command_11())
if flag_12:
self.room.command_queue.append(
self.s.command_12(self.player_index))
if flag_13:
self.room.command_queue.append(self.s.command_13())
if flag_0c:
return [self.s.command_0c()]
def command_0a(self): def command_0a(self):
# 退出房间 # 退出房间
@@ -267,9 +325,6 @@ class CommandParser:
self.room.command_queue.append(self.s.command_12(self.player_index)) self.room.command_queue.append(self.s.command_12(self.player_index))
if self.room.state in (2, 3):
self.room.state = 1
self.room.song_idx = 0xffff
# self.room.command_queue.append(self.s.command_11()) # self.room.command_queue.append(self.s.command_11())
self.room.command_queue.append(self.s.command_13()) self.room.command_queue.append(self.s.command_13())
self.room.command_queue.append(self.s.command_14()) self.room.command_queue.append(self.s.command_14())
@@ -281,3 +336,45 @@ class CommandParser:
if self.player_index != i and self.room.players[i].online == 1: if self.player_index != i and self.room.players[i].online == 1:
self.room.players[i].extra_command_queue.append( self.room.players[i].extra_command_queue.append(
self.s.command_0f(self.player_index, song_idx)) self.s.command_0f(self.player_index, song_idx))
def command_20(self):
# 表情
sticker_id = bi(self.command[16:18])
for i in range(4):
if self.player_index != i and self.room.players[i].online == 1:
self.room.players[i].extra_command_queue.append(
self.s.command_21(self.player_index, sticker_id))
def command_22(self):
# 房间设置,懒得判断房主
self.s.random_code = self.command[16:24]
self.room.is_public = self.command[25]
if self.room.is_public == 0:
self.room.round_mode = self.command[24]
self.room.timed_mode = self.command[26]
else:
self.room.round_mode = 3
self.room.timed_mode = 1
self.room.state = 1
self.room.command_queue.append(self.s.command_11())
self.room.command_queue.append(self.s.command_13())
return [self.s.command_0d(1)]
def command_23(self):
# 歌曲投票
self.s.random_code = self.command[16:24]
if self.room.player_num < 2:
return [self.s.command_0d(6)]
if self.room.state != 2:
return [self.s.command_0d(5)]
player = self.room.players[self.player_index]
player.voting = bi(self.command[24:26])
logging.info(
f'Player `{player.name}` votes for song `{player.voting}`')
self.room.command_queue.append(self.s.command_12(self.player_index))
if self.room.is_all_player_voted:
self.room.make_voting()
self.room.command_queue.append(self.s.command_13())
return [self.s.command_0d(1)]

View File

@@ -1,28 +1,43 @@
from os import urandom
from time import time from time import time
from .udp_class import Room, b from .udp_class import Room, b
PADDING = [b(i) * i for i in range(16)] + [b'']
class CommandSender: class CommandSender:
PROTOCOL_NAME = b'\x06\x16' PROTOCOL_NAME = b'\x06\x16'
PROTOCOL_VERSION = b'\x09' PROTOCOL_VERSION = b'\x0D'
def __init__(self, room: Room = None) -> None: def __init__(self, room: Room = None) -> None:
self.room = room self.room = room
self.timestamp = round(time() * 1000000) self.timestamp = round(time() * 1000000)
self.room.timestamp = self.timestamp + 1
self.random_code = b'\x11\x11\x11\x11\x00\x00\x00\x00' self._random_code = None
@property
def random_code(self):
if self._random_code is None:
self._random_code = urandom(4) + b'\x00\x00\x00\x00'
return self._random_code
@random_code.setter
def random_code(self, value):
self._random_code = value
@staticmethod @staticmethod
def command_encode(t: tuple): def command_encode(t: tuple):
r = b''.join(t) r = b''.join(t)
x = 16 - len(r) % 16 x = 16 - len(r) % 16
return r + b(x) * x return r + PADDING[x]
def command_prefix(self, command: bytes): def command_prefix(self, command: bytes):
length = self.room.command_queue_length length = self.room.command_queue_length
if command >= b'\x10': if b'\x10' <= command <= b'\x1f':
length += 1 length += 1
return (self.PROTOCOL_NAME, command, self.PROTOCOL_VERSION, b(self.room.room_id, 8), b(length, 4)) return (self.PROTOCOL_NAME, command, self.PROTOCOL_VERSION, b(self.room.room_id, 8), b(length, 4))
@@ -31,12 +46,18 @@ class CommandSender:
return self.command_encode((*self.command_prefix(b'\x0c'), self.random_code, b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8))) return self.command_encode((*self.command_prefix(b'\x0c'), self.random_code, b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8)))
def command_0d(self, code: int): def command_0d(self, code: int):
# 3 你不是房主
# 5 有玩家目前无法开始
# 6 需要更多玩家以开始
# 7 有玩家无法游玩这首歌
return self.command_encode((*self.command_prefix(b'\x0d'), self.random_code, b(code))) return self.command_encode((*self.command_prefix(b'\x0d'), self.random_code, b(code)))
def command_0e(self, player_index: int): def command_0e(self, player_index: int):
# 分数广播 # 分数广播
# 我猜616 写错了,首先 4 个 00 大概是分数使用了 8 bytes 转换,其次上一个分数根本就不需要哈哈哈哈哈哈!
player = self.room.players[player_index] player = self.room.players[player_index]
return self.command_encode((*self.command_prefix(b'\x0e'), b(player.player_id, 8), b(player.character_id), b(player.is_uncapped), b(player.difficulty), b(player.score, 4), b(player.timer, 4), b(player.cleartype), b(player.player_state), b(player.download_percent), b'\x01', b(player.last_score, 4), b(player.last_timer, 4), b(player.online))) return self.command_encode((*self.command_prefix(b'\x0e'), player.info, b(player.last_score.score, 4), b'\x00' * 4, b(player.last_score.timer, 4), b'\x00' * 4))
def command_0f(self, player_index: int, song_idx: int): def command_0f(self, player_index: int, song_idx: int):
# 歌曲推荐 # 歌曲推荐
@@ -52,13 +73,17 @@ class CommandSender:
def command_12(self, player_index: int): def command_12(self, player_index: int):
player = self.room.players[player_index] player = self.room.players[player_index]
return self.command_encode((*self.command_prefix(b'\x12'), self.random_code, b(player_index), b(player.player_id, 8), b(player.character_id), b(player.is_uncapped), b(player.difficulty), b(player.score, 4), b(player.timer, 4), b(player.cleartype), b(player.player_state), b(player.download_percent), b(player.online))) return self.command_encode((*self.command_prefix(b'\x12'), self.random_code, b(player_index), player.info))
def command_13(self): def command_13(self):
return self.command_encode((*self.command_prefix(b'\x13'), self.random_code, b(self.room.host_id, 8), b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8), b(self.room.song_idx, 2), b(self.room.interval, 2), b(self.room.times, 7), self.room.get_player_last_score(), b(self.room.last_song_idx, 2), b(self.room.round_switch, 1))) return self.command_encode((*self.command_prefix(b'\x13'), self.random_code, self.room.room_info))
def command_14(self): def command_14(self):
return self.command_encode((*self.command_prefix(b'\x14'), self.random_code, self.room.song_unlock)) return self.command_encode((*self.command_prefix(b'\x14'), self.random_code, self.room.song_unlock))
def command_15(self): def command_15(self):
return self.command_encode((*self.command_prefix(b'\x15'), self.room.get_players_info(), self.room.song_unlock, b(self.room.host_id, 8), b(self.room.state), b(self.room.countdown, 4), b(self.timestamp, 8), b(self.room.song_idx, 2), b(self.room.interval, 2), b(self.room.times, 7), self.room.get_player_last_score(), b(self.room.last_song_idx, 2), b(self.room.round_switch, 1))) return self.command_encode((*self.command_prefix(b'\x15'), self.room.get_players_info(), self.room.song_unlock, self.room.room_info))
def command_21(self, player_index: int, sticker_id: int):
player = self.room.players[player_index]
return self.command_encode((*self.command_prefix(b'\x21'), b(player.player_id, 8), b(sticker_id, 2)))

View File

@@ -2,7 +2,8 @@ from flask import Blueprint, request
from core.config_manager import Config from core.config_manager import Config
from core.error import ArcError from core.error import ArcError
from core.linkplay import Player, RemoteMultiPlayer, Room from core.linkplay import MatchStore, Player, RemoteMultiPlayer, Room
from core.notification import RoomInviteNotification
from core.sql import Connect from core.sql import Connect
from .auth import auth_required from .auth import auth_required
@@ -68,3 +69,104 @@ def multiplayer_update(user_id):
':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST ':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST
r['port'] = int(Config.LINKPLAY_UDP_PORT) r['port'] = int(Config.LINKPLAY_UDP_PORT)
return success_return(r) return success_return(r)
@bp.route('/me/room/<room_code>/invite', methods=['POST']) # 邀请
@auth_required(request)
@arc_try
def room_invite(user_id, room_code):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
other_user_id = request.form.get('to', type=int)
x = RemoteMultiPlayer()
share_token = x.select_room(room_code=room_code)['share_token']
with Connect(in_memory=True) as c_m:
with Connect() as c:
sender = Player(c, user_id)
sender.select_user_about_link_play()
n = RoomInviteNotification.from_sender(
sender, Player(c, other_user_id), share_token, c_m)
n.insert()
return success_return({}) # 无返回
@bp.route('/me/room/status', methods=['POST']) # 房间号码获取
@auth_required(request)
@arc_try
def room_status(user_id):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
share_token = request.form.get('shareToken', type=str)
x = RemoteMultiPlayer()
room_code = x.select_room(share_token=share_token)['room_code']
return success_return({
'roomId': room_code,
})
@bp.route('/me/matchmaking/join/', methods=['POST']) # 匹配
@auth_required(request)
@arc_try
def matchmaking_join(user_id):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
with Connect() as c:
user = Player(None, user_id)
user.get_song_unlock(request.json['clientSongMap'])
x = MatchStore(c)
x.init_player(user)
r = x.match(user_id)
if r is None:
return success_return({
'userId': user_id,
'status': 2,
})
r['endPoint'] = request.host.split(
':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST
r['port'] = int(Config.LINKPLAY_UDP_PORT)
return success_return(r)
@bp.route('/me/matchmaking/status/', methods=['POST']) # 匹配状态5s 一次
@auth_required(request)
@arc_try
def matchmaking_status(user_id):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
with Connect() as c:
r = MatchStore(c).match(user_id)
if r is None:
return success_return({
'userId': user_id,
'status': 0,
})
r['endPoint'] = request.host.split(
':')[0] if Config.LINKPLAY_DISPLAY_HOST == '' else Config.LINKPLAY_DISPLAY_HOST
r['port'] = int(Config.LINKPLAY_UDP_PORT)
return success_return(r)
@bp.route('/me/matchmaking/leave/', methods=['POST']) # 退出匹配
@auth_required(request)
@arc_try
def matchmaking_leave(user_id):
if not Config.LINKPLAY_HOST:
raise ArcError('The link play server is unavailable.', 151, status=404)
MatchStore().clear_player(user_id)
return success_return({})

View File

@@ -8,6 +8,7 @@ from core.bundle import BundleDownload
from core.download import DownloadList from core.download import DownloadList
from core.error import RateLimit from core.error import RateLimit
from core.item import ItemCharacter from core.item import ItemCharacter
from core.notification import NotificationFactory
from core.sql import Connect from core.sql import Connect
from core.system import GameInfo from core.system import GameInfo
from core.user import UserOnline from core.user import UserOnline
@@ -28,6 +29,15 @@ def game_info():
return success_return(GameInfo().to_dict()) return success_return(GameInfo().to_dict())
@bp.route('/notification/me', methods=['GET']) # 通知
@auth_required(request)
@arc_try
def notification_me(user_id):
with Connect(in_memory=True) as c_m:
x = NotificationFactory(c_m, UserOnline(c_m, user_id))
return success_return([i.to_dict() for i in x.get_notification()])
@bp.route('/game/content_bundle', methods=['GET']) # 热更新 @bp.route('/game/content_bundle', methods=['GET']) # 热更新
@arc_try @arc_try
def game_content_bundle(): def game_content_bundle():

View File

@@ -443,7 +443,7 @@ def all_character():
def change_character(): def change_character():
# 修改角色数据 # 修改角色数据
skill_ids = ['No_skill', 'gauge_easy', 'note_mirror', 'gauge_hard', 'frag_plus_10_pack_stellights', 'gauge_easy|frag_plus_15_pst&prs', 'gauge_hard|fail_frag_minus_100', 'frag_plus_5_side_light', 'visual_hide_hp', 'frag_plus_5_side_conflict', 'challenge_fullcombo_0gauge', 'gauge_overflow', 'gauge_easy|note_mirror', 'note_mirror', 'visual_tomato_pack_tonesphere', skill_ids = ['No_skill', 'gauge_easy', 'note_mirror', 'gauge_hard', 'frag_plus_10_pack_stellights', 'gauge_easy|frag_plus_15_pst&prs', 'gauge_hard|fail_frag_minus_100', 'frag_plus_5_side_light', 'visual_hide_hp', 'frag_plus_5_side_conflict', 'challenge_fullcombo_0gauge', 'gauge_overflow', 'gauge_easy|note_mirror', 'note_mirror', 'visual_tomato_pack_tonesphere',
'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', 'frags_kou', 'visual_ink', 'shirabe_entry_fee', 'frags_yume', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', 'eto_uncap', 'luna_uncap', 'frags_preferred_song', 'visual_ghost_skynotes', 'ayu_uncap', 'skill_vita', 'skill_fatalis', 'skill_reunion', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'ilith_awakened_skill', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_luin', 'skill_luin_uncap', 'skill_kanae_uncap', 'skill_doroc_uncap', 'skill_saya_uncap'] 'frag_rng_ayu', 'gaugestart_30|gaugegain_70', 'combo_100-frag_1', 'audio_gcemptyhit_pack_groovecoaster', 'gauge_saya', 'gauge_chuni', 'kantandeshou', 'gauge_haruna', 'frags_nono', 'gauge_pandora', 'gauge_regulus', 'omatsuri_daynight', 'sometimes(note_mirror|frag_plus_5)', 'scoreclear_aa|visual_scoregauge', 'gauge_tempest', 'gauge_hard', 'gauge_ilith_summer', 'frags_kou', 'visual_ink', 'shirabe_entry_fee', 'frags_yume', 'note_mirror|visual_hide_far', 'frags_ongeki', 'gauge_areus', 'gauge_seele', 'gauge_isabelle', 'gauge_exhaustion', 'skill_lagrange', 'gauge_safe_10', 'frags_nami', 'skill_elizabeth', 'skill_lily', 'skill_kanae_midsummer', 'eto_uncap', 'luna_uncap', 'frags_preferred_song', 'visual_ghost_skynotes', 'ayu_uncap', 'skill_vita', 'skill_fatalis', 'skill_reunion', 'frags_ongeki_slash', 'frags_ongeki_hard', 'skill_amane', 'skill_kou_winter', 'gauge_hard|note_mirror', 'skill_shama', 'skill_milk', 'skill_shikoku', 'skill_mika', 'ilith_awakened_skill', 'skill_mithra', 'skill_toa', 'skill_nami_twilight', 'skill_ilith_ivy', 'skill_hikari_vanessa', 'skill_maya', 'skill_luin', 'skill_luin_uncap', 'skill_kanae_uncap', 'skill_doroc_uncap', 'skill_saya_uncap', 'skill_luna_ilot', 'skill_eto_hoppe', 'skill_aichan']
return render_template('web/changechar.html', skill_ids=skill_ids) return render_template('web/changechar.html', skill_ids=skill_ids)