88import math
99import json
1010import base64
11+ import hashlib
12+ import subprocess
13+ from pathlib import Path
1114from datetime import datetime
1215
13- from PyQt5 .QtWidgets import QWidget
16+ from PyQt5 .QtWidgets import QWidget , QMessageBox
1417
1518from Crypto .Cipher import AES
1619from Crypto .Random import get_random_bytes
1720
21+ import ms_toollib as ms
22+
1823from plugin_sdk import BasePlugin , PluginInfo , make_plugin_icon , WindowMode
1924from shared_types .events import CloseEvent , GameFinishedEvent
2025
2631_ENCRYPT_KEY = b"f[{gr!%$%^65sr60"
2732
2833
34+
35+
2936def _encrypt (data : bytes ) -> bytes :
3037 nonce = get_random_bytes (12 )
3138 cipher = AES .new (_ENCRYPT_KEY , AES .MODE_GCM , nonce = nonce )
@@ -72,6 +79,7 @@ def _setup_subscriptions(self) -> None:
7279 def _create_widget (self ) -> QWidget :
7380 self ._ui = XianNiUpgradeUI ()
7481 self ._ui .set_image_dir (self .data_dir / "asserts" )
82+ self ._ui .set_absorb_callbacks (self .validate_replays , self .absorb_replays )
7583 return self ._ui
7684
7785 def on_initialized (self ) -> None :
@@ -89,6 +97,10 @@ def _calc_xp(self, event: GameFinishedEvent) -> int:
8997 """每局获得的经验值"""
9098 return 8000
9199
100+ def _calc_xp2 (self , video : ms .EvfVideo ) -> int :
101+ """通过录像计算经验值"""
102+ return 1
103+
92104 # ═══════════════════════════════════════════════════════════
93105 # 数据管理
94106 # ═══════════════════════════════════════════════════════════
@@ -98,55 +110,237 @@ def _load_data(self):
98110 if path .exists ():
99111 try :
100112 raw = _decrypt (path .read_bytes ())
101- self ._player_data = json .loads (raw )
102- self .logger .info (f"已加载存档: Lv.{ self ._player_data ['level' ]} " )
103- return
113+ data = json .loads (raw )
114+ if "identifiers" in data and "players" in data :
115+ self ._identifiers = data ["identifiers" ]
116+ self ._players = data ["players" ]
117+ self ._history = data .get ("history" , [])
118+ self ._current_pid = data .get ("current_pid" , 0 )
119+ self ._imported = set (tuple (v ) for v in data .get ("imported_videos" , []))
120+ self .logger .info (f"已加载存档,{ len (self ._identifiers )} 个玩家" )
121+ return
122+ self .logger .info ("旧存档格式,忽略" )
104123 except Exception as e :
105124 self .logger .warning (f"读取存档失败: { e } " )
106125
107- self ._player_data = {
108- "level" : 0 ,
109- "xp" : 0 ,
110- "history" : [],
111- }
126+ self ._identifiers = []
127+ self . _players = []
128+ self . _history = []
129+ self . _current_pid = 0
130+ self . _imported : set [ tuple [ str , str ]] = set ()
112131
113132 def _save_data (self ):
114133 path = self .data_dir / "player_data.dat"
134+ data = {
135+ "identifiers" : self ._identifiers ,
136+ "players" : self ._players ,
137+ "history" : self ._history ,
138+ "current_pid" : self ._current_pid ,
139+ "imported_videos" : [list (v ) for v in self ._imported ],
140+ }
141+ raw = json .dumps (data , ensure_ascii = False ).encode ("utf-8" )
142+ path .write_bytes (_encrypt (raw ))
143+
144+ def _get_or_create_pid (self , identifier : str ) -> int :
145+ identifier = identifier .strip ()
146+ if not identifier :
147+ identifier = "匿名玩家"
148+ try :
149+ return self ._identifiers .index (identifier )
150+ except ValueError :
151+ pid = len (self ._identifiers )
152+ self ._identifiers .append (identifier )
153+ self ._players .append ({"level" : 0 , "xp" : 0 })
154+ return pid
155+
156+ # ═══════════════════════════════════════════════════════════
157+ # 吸收灵气(导入其他版本录像获得经验)
158+ # ═══════════════════════════════════════════════════════════
159+
160+ def validate_replays (self , exe_path : str , replay_path : str ) -> dict | None :
161+ """校验录像并返回预览数据,失败返回 None"""
162+ try :
163+ exe = Path (exe_path )
164+ if not exe .exists ():
165+ self .logger .error (f"校验程序不存在: { exe_path } " )
166+ return None
167+
168+ actual_md5 = hashlib .md5 (exe .read_bytes ()).hexdigest ()
169+
170+ match actual_md5 :
171+ case "d5fd61ae1372297aa7008d7b7cd8a13b" :
172+ return self ._validate_metasweeper322 (exe , replay_path )
173+ case _:
174+ self .logger .error (f"未知法器 MD5: { actual_md5 } " )
175+ return None
176+ except Exception as e :
177+ self .logger .error (f"校验失败: { e } " )
178+ return None
179+
180+ def _validate_metasweeper322 (self , exe : Path , replay_path : str ) -> dict | None :
181+ """元扫雷 3.2.2 的录像校验与解析"""
115182 try :
116- raw = json .dumps (self ._player_data , ensure_ascii = False ).encode ("utf-8" )
117- path .write_bytes (_encrypt (raw ))
183+ cmd = [str (exe ), "-c" , replay_path ]
184+ self .logger .info (f"执行: { ' ' .join (cmd )} " )
185+ result = subprocess .run (cmd , capture_output = True , timeout = 120 )
186+ if result .returncode != 0 :
187+ self .logger .error (f"校验程序返回非零: { result .returncode } " )
188+ return None
189+
190+ out_path = exe .parent / "_internal" / "out.json"
191+ if not out_path .exists ():
192+ self .logger .error (f"未找到结果文件: { out_path } " )
193+ return None
194+
195+ report = json .loads (out_path .read_bytes ())
196+ if report .get ("error" ):
197+ self .logger .error (f"校验报告错误: { report ['error' ]} " )
198+ return None
199+
200+ new_files = []
201+ dup_files = []
202+ for d in report .get ("data" , []):
203+ if d .get ("status" ) != 0 :
204+ continue
205+ fp = d ["file" ]
206+ try :
207+ v = ms .EvfVideo (fp )
208+ v .parse ()
209+ key = (str (v .start_time ), v .player_identifier )
210+ entry = {
211+ "file" : fp ,
212+ "player" : v .player_identifier ,
213+ "start_time" : v .start_time ,
214+ "level" : getattr (v , "level" , 3 ),
215+ "mode" : getattr (v , "mode" , 0 ),
216+ "rtime" : getattr (v , "rtime" , 0.0 ),
217+ "bbbv" : getattr (v , "bbbv" , 0 ),
218+ "xp" : self ._calc_xp2 (v ),
219+ }
220+ if key in self ._imported :
221+ dup_files .append (entry )
222+ elif entry ["xp" ] > 0 :
223+ new_files .append (entry )
224+ except Exception as e :
225+ self .logger .warning (f"解析录像失败 { fp } : { e } " )
226+
227+ return {
228+ "md5" : "d5fd61ae1372297aa7008d7b7cd8a13b" ,
229+ "new_files" : new_files ,
230+ "duplicates" : dup_files ,
231+ "total_new_xp" : sum (n ["xp" ] for n in new_files ),
232+ }
118233 except Exception as e :
119- self .logger .error (f"保存存档失败: { e } " )
234+ self .logger .error (f"元扫雷 3.2.2 校验失败: { e } " )
235+ return None
236+
237+ def absorb_replays (self , preview : dict ) -> int :
238+ """根据预览数据实际吸收经验,返回获得的总经验"""
239+ gained_total = 0
240+
241+ for entry in preview ["new_files" ]:
242+ xp_per = entry .get ("xp" , 0 )
243+ key = (str (entry ["start_time" ]), entry ["player" ])
244+ self ._imported .add (key )
245+
246+ pid = self ._get_or_create_pid (entry ["player" ])
247+ self ._current_pid = pid
248+ player = self ._players [pid ]
249+ player ["xp" ] += xp_per
250+ gained_total += xp_per
251+
252+ while player ["level" ] < 100 :
253+ need = _total_xp (player ["level" ] + 1 )
254+ if player ["xp" ] < need :
255+ break
256+ player ["level" ] += 1
257+
258+ top = _total_xp (100 )
259+ if player ["xp" ] > top :
260+ player ["xp" ] = top
261+
262+ self ._history .append ({
263+ "pid" : pid ,
264+ "time" : int (datetime .now ().timestamp ()),
265+ "level" : entry ["level" ],
266+ "mode" : entry ["mode" ],
267+ "rtime" : round (entry ["rtime" ], 2 ),
268+ "bbbv" : entry ["bbbv" ],
269+ "xp" : xp_per ,
270+ })
271+
272+ if len (self ._history ) > 1000 :
273+ self ._history = self ._history [- 1000 :]
274+
275+ self ._save_data ()
276+ self ._push_ui_update ()
277+ return gained_total
278+
279+ def _build_update_data (self ) -> dict :
280+ if not self ._players :
281+ return {
282+ "player_name" : "" ,
283+ "level" : 0 ,
284+ "total_xp" : 0 ,
285+ "xp_curr" : 0 ,
286+ "xp_need" : _total_xp (1 ),
287+ "rank" : LEVEL_NAMES .get (0 , "" ),
288+ "image_index" : get_image_index (0 ),
289+ "history" : [],
290+ }
291+ pid = self ._current_pid
292+ if pid >= len (self ._players ):
293+ pid = 0
294+ self ._current_pid = 0
295+ player = self ._players [pid ]
296+ level = player ["level" ]
297+ xp = player ["xp" ]
298+ xp_base = _total_xp (level )
299+ xp_next = _total_xp (level + 1 ) if level < 100 else _total_xp (100 )
300+ return {
301+ "player_name" : self ._identifiers [pid ] if self ._identifiers else "" ,
302+ "level" : level ,
303+ "total_xp" : xp ,
304+ "xp_curr" : xp - xp_base ,
305+ "xp_need" : xp_next - xp_base ,
306+ "rank" : LEVEL_NAMES .get (level , "" ),
307+ "image_index" : get_image_index (level ),
308+ "history" : self ._history [::- 1 ][:100 ],
309+ }
120310
121311 def _on_game_finished (self , event : GameFinishedEvent ):
122312 if event .game_state != 6 :
123313 return
124314
315+ pid = self ._get_or_create_pid (event .player_identifier )
316+ self ._current_pid = pid
317+ player = self ._players [pid ]
318+
125319 xp_gained = self ._calc_xp (event )
126- self . _player_data ["xp" ] += xp_gained
320+ player ["xp" ] += xp_gained
127321
128- while self . _player_data ["level" ] < 100 :
129- need = _total_xp (self . _player_data ["level" ] + 1 )
130- if self . _player_data ["xp" ] < need :
322+ while player ["level" ] < 100 :
323+ need = _total_xp (player ["level" ] + 1 )
324+ if player ["xp" ] < need :
131325 break
132- self . _player_data ["level" ] += 1
326+ player ["level" ] += 1
133327
134- # 封顶
135328 top = _total_xp (100 )
136- if self . _player_data ["xp" ] > top :
137- self . _player_data ["xp" ] = top
329+ if player ["xp" ] > top :
330+ player ["xp" ] = top
138331
139- self ._player_data ["history" ].append ({
332+ self ._history .append ({
333+ "pid" : pid ,
140334 "time" : int (datetime .now ().timestamp ()),
141- "level" : LEVEL_LABELS . get ( event .level , str ( event . level )) ,
142- "mode" : MODE_LABELS . get ( event .mode , str ( event . mode )) ,
335+ "level" : event .level ,
336+ "mode" : event .mode ,
143337 "rtime" : round (event .rtime , 2 ),
144338 "bbbv" : event .bbbv ,
145339 "xp" : xp_gained ,
146340 })
147341
148- if len (self ._player_data [ "history" ] ) > 1000 :
149- self ._player_data [ "history" ] = self ._player_data [ "history" ] [- 1000 :]
342+ if len (self ._history ) > 1000 :
343+ self ._history = self ._history [- 1000 :]
150344
151345 self ._save_data ()
152346 self ._push_ui_update ()
@@ -155,18 +349,4 @@ def _on_close(self, event: CloseEvent):
155349 self ._save_data ()
156350
157351 def _push_ui_update (self ):
158- level = self ._player_data ["level" ]
159- xp = self ._player_data ["xp" ]
160- xp_base = _total_xp (level )
161- xp_next = _total_xp (level + 1 ) if level < 100 else _total_xp (100 )
162-
163- data = {
164- "level" : level ,
165- "total_xp" : xp ,
166- "xp_curr" : xp - xp_base ,
167- "xp_need" : xp_next - xp_base ,
168- "rank" : LEVEL_NAMES .get (level , "" ),
169- "image_index" : get_image_index (level ),
170- "history" : self ._player_data ["history" ][::- 1 ][:100 ],
171- }
172- self ._ui ._signal_update .emit (data )
352+ self ._ui ._signal_update .emit (self ._build_update_data ())
0 commit comments