hearthstone-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2267 @@
1
+ """hs board — 解析炉石客户端日志 Power.log, 输出当前对局面板 (供 AI 军师读取)
2
+
3
+ 用法:
4
+ hs board 读默认日志 (%LOCALAPPDATA%\\Blizzard\\Hearthstone\\Logs\\Power.log)
5
+ hs board --log=D:\\path\\Power.log 指定日志路径
6
+ hs board --stdin 从 stdin 读日志 (方便测试)
7
+ hs board --player=鸵鸟居士 手动指定我方玩家名 (默认自动判定)
8
+ hs board --events=N 行动回顾带最近 N 个回合 (默认 3, N=0 完全不输出)
9
+
10
+ 只认 GameState.DebugPrintPower() 行 (PowerTaskList 是重复历史, 忽略)。
11
+ 从最后一个 CREATE_GAME 起全量重放 packet, tag 原子覆盖, 输出最终状态面板;
12
+ 重放时同步收集行动事件 (出牌/攻击/技能/抽弃牌), 并单独还原开局语义:
13
+ 真实起手 -> 双方换牌 (我方带卡名/对方报张数) -> 幸运币 -> START_OF_GAME 开局触发汇总, 供"行动回顾"输出。
14
+ """
15
+ import os
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ DEFAULT_LOG = (Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
21
+ / "Blizzard" / "Hearthstone" / "Logs" / "Power.log")
22
+ # 日志根目录候选: 本机炉石把日志写在安装目录下(每次启动生成 Hearthstone_<时间戳> 子目录),
23
+ # 标准位置 %LOCALAPPDATA%\Blizzard\Hearthstone\Logs 作为兜底
24
+ DEFAULT_LOG_DIRS = [
25
+ Path(r"E:\Hearthstone\Logs"),
26
+ (Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
27
+ / "Blizzard" / "Hearthstone" / "Logs"),
28
+ ]
29
+
30
+
31
+ def find_latest_power(base_dir=None):
32
+ """自动发现最新 Power.log: 目录下直接存在, 或 Hearthstone_<时间戳> 子目录中, 取最新。
33
+
34
+ base_dir=None 时扫描 DEFAULT_LOG_DIRS 全部候选, 否则只扫指定目录。返回 Path 或 None。
35
+ """
36
+ bases = [Path(base_dir)] if base_dir else DEFAULT_LOG_DIRS
37
+ best, best_key = None, None
38
+ for b in bases:
39
+ if not b.is_dir():
40
+ continue
41
+ cands = []
42
+ direct = b / "Power.log"
43
+ if direct.is_file():
44
+ cands.append(direct)
45
+ try:
46
+ for d in b.iterdir():
47
+ if d.is_dir() and d.name.startswith("Hearthstone_"):
48
+ f = d / "Power.log"
49
+ if f.is_file():
50
+ cands.append(f)
51
+ except OSError:
52
+ pass
53
+ for f in cands:
54
+ key = (f.parent.name, f.stat().st_mtime) # 子目录名时间戳字典序=时间序, mtime 兜底
55
+ if best_key is None or key > best_key:
56
+ best, best_key = f, key
57
+ return best
58
+
59
+ # 行前缀: 时间戳 D 21:47:52.3528941 GameState.DebugPrintPower() - <payload>
60
+ PREFIX_RE = re.compile(r"^D [\d:.]+ GameState\.DebugPrintPower\(\) - (.*)$")
61
+ # 玩家名行(新版日志): D ... GameState.DebugPrintGame() - PlayerID=2, PlayerName=鸵鸟居士#5869
62
+ PLAYER_NAME_RE = re.compile(r"^D [\d:.]+ GameState\.DebugPrintGame\(\) - PlayerID=(\d+), PlayerName=(.+?)\s*$")
63
+ TAG_LINE_RE = re.compile(r"^tag=(\S+) value=(.+?)\s*$")
64
+ CREATE_GAME_RE = re.compile(r"^CREATE_GAME$")
65
+ # 新版: FULL_ENTITY - Creating ID=4 CardID= (实体名/zone 不再内联, 由后续 tag 行填充)
66
+ FULL_ENTITY_CREATE_RE = re.compile(r"^FULL_ENTITY - Creating ID=(\d+) CardID=(\S*)\s*$")
67
+ # 旧版: FULL_ENTITY - Updating [entityName=.. ID=4 Zone=..] CardID=..
68
+ FULL_ENTITY_RE = re.compile(r"^FULL_ENTITY - Updating (.+) CardID=(\S*)\s*$")
69
+ # 新版: GameEntity EntityID=1 / Player EntityID=2 PlayerID=1 (名字只在 DebugPrintGame 行)
70
+ GAME_ENTITY_RE = re.compile(r"^GameEntity EntityID=(\d+)\s*$")
71
+ PLAYER_ENTITY_RE = re.compile(r"^Player EntityID=(\d+) PlayerID=(\d+)")
72
+ SHOW_ENTITY_RE = re.compile(r"^SHOW_ENTITY - Updating (?:Entity=)?(.+) CardID=(\S*)\s*$")
73
+ HIDE_ENTITY_RE = re.compile(r"^HIDE_ENTITY - Entity=(.+?)(?: tag=(\S+) value=(.+?)\s*)?$")
74
+ TAG_CHANGE_RE = re.compile(r"^TAG_CHANGE Entity=(.+) tag=(\S+) value=(.*?)\s*$") # value 允许为空 (新版日志有 value= 空值行)
75
+ CHANGE_ENTITY_RE = re.compile(r"^CHANGE_ENTITY - Updating (?:Entity=)?(.+) CardID=(\S*)\s*$")
76
+ META_BURN_RE = re.compile(r"^META_DATA - Meta=BURNED_CARD\b")
77
+ # Info 行: 方括号实体引用 (内含嵌套 [..], 贪婪取到最后一个 ]) 或裸实体 id
78
+ INFO_LINE_RE = re.compile(r"^Info\[\d+\] = (?:\[(.+)\]|(\S+))\s*$")
79
+ BRACKET_ID_RE = re.compile(r"\b[Ii][Dd]=(\d+)") # 方括号引用大小写 id= 都有
80
+ ENTITY_NAME_RE = re.compile(r"entityName=(.*?) [Ii][Dd]=")
81
+ BLOCK_TYPE_RE = re.compile(r"^BLOCK_START BlockType=(\S+)")
82
+ BLOCK_ENTITY_RE = re.compile(r" Entity=(.+?) EffectCardId=")
83
+ # Target 值: 方括号实体引用 (内含空格) 或裸词 (后面还跟 SubOption= 等字段, 不能吞到行尾)
84
+ BLOCK_TARGET_RE = re.compile(r" Target=(\[.*?\]|\S+)")
85
+ TRIGGER_KW_RE = re.compile(r" TriggerKeyword=(\S+)")
86
+ # 换牌保留选择行 (非 Power 行): D ... GameState.DebugPrintEntitiesChosen() - Entities[0]=[... id=52 ... player=2]
87
+ # 语义 = 该玩家确定保留的牌 (与实际换掉不一一对应), 换掉 = 起手 - 保留
88
+ CHOSEN_ENT_RE = re.compile(r"^D [\d:.]+ GameState\.DebugPrintEntitiesChosen\(\) -\s+Entities\[\d+\]=\[(.+)\]\s*$")
89
+ CHOSEN_PLAYER_RE = re.compile(r"\bplayer=(\d+)")
90
+ # 对局中 GENERAL 选择 (发现/灾变类, 换牌 MULLIGAN 是另一类型不受影响):
91
+ # DebugPrintEntityChoices 列选项 (id/玩家/Source), DebugPrintEntitiesChosen 确认所选 (双方都走这里, 对方无 SendChoices)
92
+ EC_HEAD_RE = re.compile(
93
+ r"^D [\d:.]+ GameState\.DebugPrintEntityChoices\(\) - id=(\d+) Player=(\S+).*\bChoiceType=(\S+)")
94
+ EC_CHOSEN_HEAD_RE = re.compile(
95
+ r"^D [\d:.]+ GameState\.DebugPrintEntitiesChosen\(\) - id=(\d+) Player=(\S+) EntitiesCount=(\d+)")
96
+ EC_BRACKET_RE = re.compile(r"(\[.+\])\s*$")
97
+ # 对局元信息行 (非 Power 行): D ... GameState.DebugPrintGame() - GameType=GT_CASUAL / FormatType=FT_STANDARD / BuildNumber=253216
98
+ GAME_META_RE = re.compile(r"^D [\d:.]+ GameState\.DebugPrintGame\(\) - (BuildNumber|GameType|FormatType)=(\S+)\s*$")
99
+ # 日志超限截断标记 (裸行): Truncating log, which has reached the size limit of 10000KB
100
+ TRUNCATE_RE = re.compile(r"Truncating log, which has reached the size limit of (\d+)KB")
101
+ GAME_TYPE_ZH = {"GT_RANKED": "排名", "GT_CASUAL": "休闲", "GT_ARENA": "竞技场", "GT_FRIENDLY": "好友对战",
102
+ "GT_VS_AI": "人机对战", "GT_TUTORIAL": "教学", "GT_MERCENARIES": "佣兵战纪",
103
+ "GT_BATTLEGROUNDS": "酒馆战棋", "GT_BATTLEGROUNDS_FRIENDLY": "酒馆好友"}
104
+ FORMAT_TYPE_ZH = {"FT_STANDARD": "标准", "FT_WILD": "狂野", "FT_CLASSIC": "经典", "FT_TWIST": "异画"}
105
+
106
+ # 日志里 tag value 可能是数字枚举也可能是字符串, 两种都归一化
107
+ ZONE_BY_NUM = {1: "PLAY", 2: "DECK", 3: "HAND", 4: "GRAVEYARD", 5: "REMOVEDFROMGAME", 6: "SETASIDE", 7: "SECRET"}
108
+ CTYPE_BY_NUM = {1: "GAME", 2: "PLAYER", 3: "HERO", 4: "MINION", 5: "SPELL", 6: "ENCHANTMENT",
109
+ 7: "WEAPON", 9: "TOKEN", 10: "HERO_POWER", 39: "LOCATION"}
110
+ MULLIGAN_BY_NUM = {1: "INPUT", 2: "READY", 3: "DONE"}
111
+ PLAYSTATE_BY_NUM = {4: "WON", 5: "LOST", 6: "TIED", 7: "PLAYING", 8: "CONCEDED"}
112
+
113
+ # cards_zh.json 是 collectible 库, 英雄技能查不到, 内置经典技能兜底 (其余显示 cardId 原文)
114
+ HERO_POWER_NAMES = {
115
+ "CS2_034": "火焰冲击", "CS2_056": "生命分流", "CS2_084": "全副武装",
116
+ "CS2_049": "图腾召唤", "CS2_101": "援军", "DS1h_292": "稳固射击",
117
+ "CS2_017": "变形", "CS1h_001": "次级治疗术", "CS2_083b": "匕首精通",
118
+ }
119
+ # 经典技能描述兜底 (HearthstoneJSON 全量库缺基础技能条目, CS2_084 还被同名法术占用)
120
+ HERO_POWER_TEXTS = {
121
+ "CS2_034": "造成1点伤害。", "CS2_056": "抽一张牌,你的英雄受到2点伤害。",
122
+ "CS2_084": "获得2点护甲值。", "CS2_049": "召唤一个随机基础图腾。",
123
+ "CS2_101": "召唤两个1/1的白银之手新兵。", "DS1h_292": "对敌方英雄造成2点伤害。",
124
+ "CS2_017": "你的英雄本回合+1攻击力,并获得1点护甲值。", "CS1h_001": "恢复2点生命值。",
125
+ "CS2_083b": "你的英雄本回合+1攻击力。",
126
+ }
127
+ # 按技能名的描述兜底: 经典技能的皮肤变体在 full 库里文本脏 (稳固射击变体自带重复段), 不可依赖
128
+ HERO_POWER_DESC_BY_NAME = {
129
+ "全副武装": "获得2点护甲值。", "全副武装!": "获得2点护甲值。",
130
+ "稳固射击": "对敌方英雄造成2点伤害。", "次级治疗术": "恢复2点生命值。",
131
+ "火焰冲击": "造成1点伤害。", "生命分流": "抽一张牌,你的英雄失去2点生命值。",
132
+ "图腾召唤": "召唤一个随机基础图腾。", "援军": "召唤两个1/1的白银之手新兵。",
133
+ "变形": "你的英雄本回合+1攻击力,并获得1点护甲值。", "匕首精通": "你的英雄本回合+1攻击力。",
134
+ }
135
+
136
+ # 随从状态标签 (按此顺序输出, 冻结附注最后; DORMANT 单独处理: 值为剩余唤醒回合数, 显示 [休眠N])
137
+ MINION_TAGS = [
138
+ ("TAUNT", "嘲讽"), ("DIVINE_SHIELD", "圣盾"), ("WINDFURY", "风怒"), ("MEGA_WINDFURY", "巨型风怒"),
139
+ ("STEALTH", "潜行"), ("LIFESTEAL", "吸血"), ("POISONOUS", "剧毒"), ("REBORN", "复生"),
140
+ ("CHARGE", "冲锋"), ("RUSH", "突袭"), ("CANT_ATTACK", "不可攻击"),
141
+ ("CANT_BE_ATTACKED", "无法被攻击"), ("SILENCED", "被沉默"), ("IMMUNE", "免疫"),
142
+ ]
143
+ TYPE_ZH = {"MINION": "随从", "SPELL": "法术", "WEAPON": "武器", "HERO": "英雄", "LOCATION": "地标", "HERO_POWER": "技能"}
144
+ # 可主动打出的牌型 (行动回顾 "打出" 事件)
145
+ PLAYABLE_TYPES = {"MINION", "SPELL", "WEAPON", "LOCATION", "HERO"}
146
+
147
+
148
+ def _num(v):
149
+ """字符串 -> int, 非数字返回 None"""
150
+ try:
151
+ return int(v)
152
+ except (TypeError, ValueError):
153
+ return None
154
+
155
+
156
+ def _norm(v, table):
157
+ """归一化枚举值: 数字枚举查表转字符串, 原本就是字符串的原样返回"""
158
+ n = _num(v)
159
+ return table.get(n, v) if n is not None else v
160
+
161
+
162
+ def _new_game():
163
+ return {"entities": {}, "names": {}, "player_names": {}, "cur": None,
164
+ # 行动回顾重放上下文: turn=当前回合值, actor=当前行动方 controller, gate=换牌结束后开闸记事件,
165
+ # grace=开闸宽限期 (换牌重抽归入开局事件, 首个正式回合开始时关闭), opening_seen=开局发牌去重
166
+ "turn": 0, "actor": None, "gate": False, "grace": False, "events": [], "blocks": [],
167
+ "pending_stats": [], "opening_seen": set(),
168
+ # 效果层事件: dead_seen=已死亡实体(亡语块判定), death_evs=死亡事件(复生回填),
169
+ # summon_acc=同回合同来源召唤合并, blk_seq=块序号, burn_pending=BURNED_CARD 等待 Info 行
170
+ "dead_seen": set(), "death_evs": {}, "summon_acc": {}, "blk_seq": 0, "burn_pending": False,
171
+ "pending_summon": [],
172
+ # 技能自带护甲内联: hp_armor_pend=技能实体id->[累计护甲, 英雄实体id] 等技能事件消费;
173
+ # heal_watch=待定治疗(同块 HEALTH 下调则改判光环回调), aura_drops=块内 HEALTH 下调记录
174
+ "hp_armor_pend": {}, "heal_watch": [], "aura_drops": [],
175
+ # 对局中 GENERAL 选择: ec_choice=已列选项待确认, ec_confirm=已确认待出事件
176
+ "ec_choice": None, "ec_confirm": None,
177
+ # 亡语/触发块内 SHOW_ENTITY 揭示的实体 -> 亮牌观察 (CARDTYPE=SPELL 出事件, 被藏回/收尾清理)
178
+ "reveal_watch": {},
179
+ # 已在触发时刻出过事件的奥秘 (SHOW_ENTITY 揭示即触发, 坟场迁移时去重不再补发)
180
+ "secret_trig": set(),
181
+ # 开局/换牌语义: dealt=起手发牌, mull_in=换入, kept=该方确定保留的实体 (换掉=dealt-kept)
182
+ "opening": {"dealt": {}, "mull_in": {}, "kept": {}},
183
+ # 对局元信息 (DebugPrintGame 行) 与日志截断标记; first_hero=双方初始英雄实体 id (职业显示锚点)
184
+ "meta": {}, "truncated": False, "trunc_kb": None, "first_hero": {}}
185
+
186
+
187
+ def _create_entity_by_id(game, eid, card_id, name="", zone=""):
188
+ game["entities"][eid] = {
189
+ "id": eid, "cardId": card_id, "zone": zone, "zone_pos": 0,
190
+ "controller": None, "tags": {}, "name": name, "peak_hp": 0, "fresh": True,
191
+ }
192
+ if name:
193
+ game["names"][name] = eid
194
+ return eid
195
+
196
+
197
+ def _create_entity(game, ref, card_id):
198
+ """按 FULL_ENTITY 引用创建实体, 返回 entityID (无 ID 的引用返回 None)"""
199
+ eid = _ref_id(ref)
200
+ if eid is None:
201
+ return None
202
+ name = ""
203
+ nm = ENTITY_NAME_RE.search(ref)
204
+ if nm:
205
+ name = nm.group(1)
206
+ zone = ""
207
+ zm = re.search(r"\b[Zz]one=(\S+)", ref)
208
+ if zm:
209
+ zone = _norm(zm.group(1), ZONE_BY_NUM)
210
+ return _create_entity_by_id(game, eid, card_id, name, zone)
211
+
212
+
213
+ def _ref_id(ref):
214
+ """实体引用 -> entityID: 纯数字或 [.. ID=n ..]; 名字形式返回 None"""
215
+ s = ref.strip()
216
+ if s.isdigit():
217
+ return int(s)
218
+ if s.startswith("["):
219
+ m = BRACKET_ID_RE.search(s)
220
+ if m:
221
+ return int(m.group(1))
222
+ return None
223
+
224
+
225
+ def _resolve(game, ref):
226
+ """TAG_CHANGE/SHOW_ENTITY 的实体引用 -> entityID; 未知名字建占位实体避免丢 tag。
227
+
228
+ 新版日志玩家名 (如 鸵鸟居士#5869) 引用要先对上真正的 Player 实体:
229
+ DebugPrintGame 行建立 PlayerID->名字映射, 此处按名字(含去 #后缀)反查绑定。
230
+ 方括号引用内联的本地化卡名 (entityName=) 顺手挂到实体, 供渲染兜底。"""
231
+ eid = _ref_id(ref)
232
+ if eid is not None:
233
+ ent = game["entities"].get(eid)
234
+ nm = ENTITY_NAME_RE.search(ref)
235
+ if nm and not nm.group(1).startswith("UNKNOWN") and (not ent["name"] or ent["name"].startswith("UNKNOWN")):
236
+ # 内联名迟到: 真名覆盖空名/UNKNOWN 占位 (对方任务打出时引用才带真名)
237
+ ent["name"] = nm.group(1)
238
+ return eid
239
+ name = ref.strip()
240
+ if name in game["names"]:
241
+ return game["names"][name]
242
+ for pid, pname in game["player_names"].items():
243
+ if name == pname or name == pname.split("#")[0] or name.split("#")[0] == pname:
244
+ for p in _players(game):
245
+ if _tag_int(p, "PLAYER_ID") == pid:
246
+ game["names"][name] = p["id"]
247
+ if not p["name"]:
248
+ p["name"] = pname
249
+ return p["id"]
250
+ eid = -len(game["entities"]) - 1
251
+ game["entities"][eid] = {
252
+ "id": eid, "cardId": "", "zone": "", "zone_pos": 0,
253
+ "controller": None, "tags": {}, "name": name, "peak_hp": 0,
254
+ }
255
+ game["names"][name] = eid
256
+ return eid
257
+
258
+
259
+ def _apply_tag(game, eid, tag, value, via_change=False):
260
+ """via_change=True 表示这条变化来自 TAG_CHANGE/HIDE_ENTITY packet (真实动作),
261
+ 而非 FULL_ENTITY/SHOW_ENTITY 的初始 tag 快照 (休眠/预备类事件只认前者)"""
262
+ ent = game["entities"].get(eid)
263
+ if not ent:
264
+ return
265
+ old_zone, old_exh = ent["zone"], _num(ent["tags"].get("EXHAUSTED"))
266
+ old_val = _num(ent["tags"].get(tag))
267
+ ent["tags"][tag] = value
268
+ if tag == "ZONE":
269
+ ent["zone"] = _norm(value, ZONE_BY_NUM)
270
+ elif tag == "ZONE_POSITION":
271
+ n = _num(value)
272
+ if n is not None:
273
+ ent["zone_pos"] = n
274
+ elif tag == "CONTROLLER":
275
+ n = _num(value)
276
+ if n is not None:
277
+ ent["controller"] = n
278
+ if ent["id"] > 0 and _ctype(ent) == "HERO": # 首个拿到归属的英雄实体 = 初始英雄 (变形不影响职业)
279
+ game["first_hero"].setdefault(n, ent["id"])
280
+ elif tag == "HEALTH":
281
+ n = _num(value)
282
+ if n is not None:
283
+ ent["peak_hp"] = max(ent["peak_hp"], n) # 记录见过的最大生命, 推算满血
284
+ elif tag == "CARDTYPE":
285
+ if ent["controller"] is not None and ent["id"] > 0 and _norm(value, CTYPE_BY_NUM) == "HERO":
286
+ game["first_hero"].setdefault(ent["controller"], ent["id"])
287
+ _note_reveal_cardtype(game, ent, value)
288
+ if tag == "PLAYSTATE" and _norm(value, PLAYSTATE_BY_NUM) == "CONCEDED":
289
+ # 引擎发 CONCEDED 后必跟 LOST 覆盖, 最终态查不到投降; 重放时单独留痕供终局行判定
290
+ ent["conceded"] = True
291
+ _watch_tag(game, ent, tag, value, old_zone, old_exh)
292
+ # 战吼目标: PLAY 块内对其他实体的影响类 tag -> 追加到打出事件
293
+ if tag in BC_TARGET_TAGS and game["gate"] and _ctype(ent) in ("MINION", "HERO", "WEAPON", "LOCATION"):
294
+ _note_bc_target(game, ent)
295
+ # 正式对局中的回复类变化: DAMAGE 回落=治疗, ARMOR 上升=英雄加甲 (开局灌 tag/换牌宽限期不算;
296
+ # zone 限定 PLAY 挡掉手牌区卡牌自带 ARMOR 数据假象; ARMOR 旧值缺省按 0, 首次加甲不漏报)
297
+ if game["gate"] and not game["grace"] and ent["zone"] == "PLAY":
298
+ new_val = _num(value)
299
+ depth = len(game["blocks"])
300
+ if tag == "DAMAGE" and None not in (old_val, new_val) and new_val < old_val:
301
+ drop = next((d for d in game["aura_drops"] if d["eid"] == ent["id"]), None)
302
+ if drop is not None: # 同块 HEALTH 已同步下调 = 光环回调, 有效生命没变, 不报治疗
303
+ game["aura_drops"].remove(drop)
304
+ _record(game, "aura_cb", eid=ent["id"], vals=drop["vals"])
305
+ else:
306
+ base = _tag_int(ent, "HEALTH") or ent["peak_hp"]
307
+ ev = _record(game, "heal", eid=ent["id"], hp=(max(base - old_val, 0), max(base - new_val, 0)))
308
+ s_kind, s_id = _heal_src(game)
309
+ if s_kind:
310
+ ev["src_kind"], ev["src_id"] = s_kind, s_id
311
+ game["heal_watch"].append({"ev": ev, "eid": ent["id"], "depth": depth})
312
+ elif tag == "HEALTH" and new_val is not None and old_val is not None and new_val < old_val:
313
+ _note_aura_drop(game, ent, old_val, new_val, depth)
314
+ elif tag == "ARMOR" and _ctype(ent) == "HERO" and new_val is not None and new_val > (old_val or 0):
315
+ gain = new_val - (old_val or 0)
316
+ pid = _power_blk_id(game)
317
+ if pid is not None: # 技能块自带护甲: 内联进技能行, 不再单独发护甲事件
318
+ _attach_power_armor(game, pid, gain, ent["id"])
319
+ else:
320
+ _record(game, "armor", eid=ent["id"], gain=gain)
321
+ # 效果层伤害/攻击目标反解: 块内 DAMAGE 上升 = 引擎自动结算的伤害
322
+ # (旧值缺省按 0: 皮肤英雄等初始 FULL_ENTITY 不带 DAMAGE tag)
323
+ new_val = _num(value)
324
+ # 攻击块内英雄先破甲后扣血: 护甲损耗也累计进该攻击的实际伤害 (光环增幅/破甲可见)
325
+ top = game["blocks"][-1] if game["blocks"] else None
326
+ if (tag == "ARMOR" and top is not None and top["type"] == "ATTACK"
327
+ and None not in (old_val, new_val) and new_val < old_val):
328
+ aev = top.get("atk_ev")
329
+ if (aev is not None and eid != _ref_id(top.get("entity") or "")
330
+ and top.get("dmg_tgt") in (None, eid)):
331
+ top["dmg_tgt"] = eid
332
+ aev["dmg_amt"] = (aev.get("dmg_amt") or 0) + (old_val - new_val)
333
+ if (tag == "DAMAGE" and game["gate"] and not game["grace"]
334
+ and new_val is not None and new_val > (old_val or 0)):
335
+ dmg_old = old_val or 0
336
+ top = game["blocks"][-1] if game["blocks"] else None
337
+ if top is not None and top["type"] == "ATTACK":
338
+ if eid != _ref_id(top.get("entity") or "") and top.get("atk_ev") is not None:
339
+ if top.get("dmg_tgt") in (None, eid):
340
+ top["dmg_tgt"] = eid # 攻击块 Target=0 时受击实体 = 反解目标
341
+ aev = top["atk_ev"]
342
+ aev["dmg_amt"] = (aev.get("dmg_amt") or 0) + (new_val - dmg_old) # 实际伤害 (光环增幅可见)
343
+ elif ent["zone"] == "PLAY" and _ctype(ent) in ("HERO", "MINION"):
344
+ has_atk = any(b["type"] == "ATTACK" for b in game["blocks"])
345
+ trig, trig_i = None, -1
346
+ for bi in range(len(game["blocks"]) - 1, -1, -1): # 内层优先: 嵌套时伤害执行者是最内层触发块
347
+ b = game["blocks"][bi]
348
+ if b["type"] == "TRIGGER":
349
+ s = game["entities"].get(_ref_id(b.get("entity") or ""))
350
+ if s is not None and _ctype(s) != "PLAYER": # 疲劳/阶段触发(玩家实体)不算来源
351
+ trig, trig_i = b, bi
352
+ break
353
+ # 归属看直接所属块: 触发块之内再无 PLAY 块即归触发结算 —— 清场法术 PLAY 块里
354
+ # 嵌套的亡语 TRIGGER 子块不再被外层 PLAY 误杀; PLAY/POWER 直接层的法术伤害
355
+ # 找不到触发块 (或触发块内又嵌 PLAY), 天然不会走到这里, 不重复报
356
+ if trig is not None and not has_atk and not any(
357
+ b["type"] == "PLAY" for b in game["blocks"][trig_i + 1:]):
358
+ # 亡语/回合结束触发的伤害: 斩杀链回溯 (主动攻击/主动法术伤害已有事件, 不重复)
359
+ sid = _ref_id(trig.get("entity") or "")
360
+ kind = "deathrattle" if (trig.get("kw") == "DEATHRATTLE" or sid in game["dead_seen"]) else "trigger"
361
+ sent = game["entities"].get(sid)
362
+ base = _tag_int(ent, "HEALTH") or ent["peak_hp"]
363
+ _record(game, "eff_damage",
364
+ actor=(sent["controller"] if sent is not None and sent["controller"] is not None
365
+ else ent["controller"]),
366
+ src_kind=kind, src_id=sid, tgt_id=eid, amount=new_val - dmg_old,
367
+ lethal=bool(base and new_val >= base))
368
+ # 脚本攻击 Target=0 反解: 块内 PROPOSED_DEFENDER 即真实攻击目标
369
+ if (tag == "PROPOSED_DEFENDER" and game["blocks"] and game["blocks"][-1]["type"] == "ATTACK"
370
+ and (ent["id"] == 1 or _ctype(ent) == "GAME")):
371
+ v = _num(value)
372
+ aev = game["blocks"][-1].get("atk_ev")
373
+ if v and aev is not None and aev.get("tgt_ref") in (None, "0", "-1") and game["entities"].get(v) is not None:
374
+ aev["tgt_ref"] = str(v)
375
+ if (tag == "FATIGUE" and _ctype(ent) == "PLAYER" and game["gate"] and not game["grace"]):
376
+ new_val = _num(value)
377
+ if None not in (old_val, new_val) and new_val > old_val: # 第 N 次疲劳=抽空牌库, 该玩家英雄扣 N 血
378
+ _record(game, "fatigue", eid=ent["id"], count=new_val)
379
+ # 休眠/唤醒 (囚禁类效果): DORMANT 0→1 记囚禁 (来源取块上下文, 战吼块=战吼随从), 1→0 记苏醒;
380
+ # 只认真实 TAG_CHANGE, 实体初始快照自带 DORMANT 不重复报 (召唤事件已覆盖)
381
+ if (tag == "DORMANT" and via_change and game["gate"] and not game["grace"]
382
+ and ent["zone"] == "PLAY" and _ctype(ent) == "MINION"):
383
+ dor_new = _num(value)
384
+ dor_old = old_val if old_val is not None else 0
385
+ if dor_new == 1 and dor_old == 0:
386
+ kind, sid = _summon_ctx(game)
387
+ _record(game, "dormant", eid=ent["id"], src_kind=kind, src_id=sid)
388
+ elif dor_new == 0 and dor_old == 1:
389
+ _record(game, "awaken", eid=ent["id"])
390
+ # DECK_ACTION 块内 (手牌预备动作): 块实体的动作类 tag 变化留摘要供块结束汇总
391
+ if via_change and game["blocks"]:
392
+ for _b in reversed(game["blocks"]):
393
+ if "deck_act" in _b:
394
+ da = _b["deck_act"]
395
+ if ent["id"] == da["eid"]:
396
+ if tag.startswith("PREPARE") or tag == "DECK_ACTION_COST":
397
+ da["prepare"] = True
398
+ elif (not tag.isdigit() and tag not in ("ZONE", "ZONE_POSITION", "EXHAUSTED")
399
+ and all(t != tag for t, _, _ in da["tagsum"]) and len(da["tagsum"]) < 4):
400
+ da["tagsum"].append((tag, old_val if old_val is not None else 0, value))
401
+ break
402
+ # FULL_ENTITY 直接建在场面上的英雄技能 = 技能被替换/升级 (灌注/英雄牌/形态切换), 无 zone 迁移可监听;
403
+ # 开局/换牌期创建的实体只清标记不记事件, 避免自带技能误报
404
+ if ent.get("fresh"):
405
+ if not game["gate"] or game["grace"]:
406
+ ent["fresh"] = False
407
+ elif ent["zone"] == "PLAY" and _ctype(ent) == "HERO_POWER":
408
+ ent["fresh"] = False
409
+ pev = _record(game, "power_change", eid=ent["id"])
410
+ pend = game["hp_armor_pend"].pop(ent["id"], None)
411
+ if pend:
412
+ pev["armor"] = pend[0]
413
+ elif ent["zone"] == "PLAY" and _ctype(ent) == "MINION":
414
+ # 亡语/触发/战吼块内直接建在场面的召唤 (否则对方场面凭空多随从)。
415
+ # 延迟到实体 tag 段结束 (下一 packet) 再定性: CREATOR/HAS_BEEN_REBORN 在 ZONE 之后才到
416
+ ent["fresh"] = False
417
+ game["pending_summon"].append(ent["id"])
418
+ elif ent["zone"] == "HAND":
419
+ if ent["controller"] is None:
420
+ pass # CONTROLLER tag 在 ZONE 之后才到, 保持 fresh 等后续 tag 再定性
421
+ else:
422
+ ent["fresh"] = False
423
+ _record_gain_src(game, ent) # 触发效果直接送入手牌 (逐月幼龙类)
424
+ elif ent["zone"] == "DECK":
425
+ if ent["controller"] is None:
426
+ pass # CONTROLLER tag 在 ZONE 之后才到, 保持 fresh 等后续 tag 再定性
427
+ else:
428
+ if game["gate"] and not game["grace"] and game["blocks"]:
429
+ # 效果生成直接进牌库的实体 (洗入牌库): 挂到所属块, 块结束出汇总事件
430
+ game["blocks"][-1].setdefault("deck_new", []).append(ent["id"])
431
+ ent["fresh"] = False
432
+ elif ent["zone"] and ent["zone"] != "PLAY":
433
+ ent["fresh"] = False # 迁移入场/库中实体不走技能变更事件
434
+
435
+
436
+ def _record(game, etype, turn=None, actor=None, **kw):
437
+ """记一条事件, 快照当时的回合值与行动方 (turn/actor 显式传入时覆盖); 返回事件对象供后续回填"""
438
+ ev = {"type": etype,
439
+ "turn": game["turn"] if turn is None else turn,
440
+ "actor": game["actor"] if actor is None else actor, **kw}
441
+ game["events"].append(ev)
442
+ return ev
443
+
444
+
445
+ def _record_opening(game, ent):
446
+ """开局发牌/换牌重抽归类 (同实体去重): 该方保留选择已知后的入手 = 换入, 否则 = 起手发牌"""
447
+ if ent["id"] in game["opening_seen"]:
448
+ return
449
+ game["opening_seen"].add(ent["id"])
450
+ ctl = ent["controller"]
451
+ if ctl is None:
452
+ return
453
+ op = game["opening"]
454
+ key = "mull_in" if ctl in op["kept"] else "dealt"
455
+ op[key].setdefault(ctl, []).append(ent["id"])
456
+
457
+
458
+ def _note_created(game, eid):
459
+ """FULL_ENTITY 建实体 -> 归入最内层开局触发块 (块结束时生成开局触发汇总事件)"""
460
+ for b in reversed(game["blocks"]):
461
+ if "sog" in b:
462
+ b["sog"]["created"].append(eid)
463
+ break
464
+
465
+
466
+ def _note_deck_create(game):
467
+ """FULL_ENTITY 建实体 -> 归入最内层 DECK_ACTION 块 (块结束时出实体变化摘要)"""
468
+ for b in reversed(game["blocks"]):
469
+ if "deck_act" in b:
470
+ b["deck_act"]["created"] += 1
471
+ break
472
+
473
+
474
+ def _finish_deck_action(game, blk):
475
+ """DECK_ACTION 块结束 -> 手牌动作事件 (预备/囚禁类手牌操作, 通用按块内变化汇总):
476
+ 花费 = 行动方 RESOURCES_USED 块内增量; 减费数值渲染时按 ATTACHED 附魔推断, 判不出带块内摘要"""
477
+ da = blk["deck_act"]
478
+ pel = game["entities"].get(da["pel_id"]) if da["pel_id"] is not None else None
479
+ if pel is not None and da["ctl"] is None:
480
+ da["ctl"] = pel["controller"]
481
+ if da["eid"] is None:
482
+ return # 块实体引用不到 id (罕见引用形态), 无法命名, 不生成噪音事件
483
+ spent = None
484
+ if pel is not None and da["pre_res"] >= 0:
485
+ spent = max(_tag_int(pel, "RESOURCES_USED") - da["pre_res"], 0)
486
+ summary = ";".join(f"{t} {o}→{v}" for t, o, v in da["tagsum"])
487
+ if da["created"]:
488
+ summary = (summary + ";" if summary else "") + f"创建实体 {da['created']} 个"
489
+ _record(game, "deck_action", actor=da["ctl"], eid=da["eid"], spent=spent,
490
+ prepare=da["prepare"], summary=summary)
491
+
492
+
493
+ def _deck_cost_cut(lookup, game, eid):
494
+ """DECK_ACTION 减费推断: ATTACHED 到该实体的附魔, 卡文本含减费且带 TAG_SCRIPT_DATA_NUM_1
495
+ 数值 -> 累计减费; 原费取卡库 cost, 兜底 TAG_LAST_KNOWN_COST_IN_HAND; 判不出返回 None"""
496
+ ent = game["entities"].get(eid)
497
+ if not ent:
498
+ return None
499
+ cut = 0
500
+ for e in game["entities"].values():
501
+ if _ctype(e) != "ENCHANTMENT" or _tag_int(e, "ATTACHED") != eid:
502
+ continue
503
+ cid = e["cardId"] or e.get("seen_cardid") or "" # 附魔用完即藏回, 曾见名兜底
504
+ if not cid:
505
+ continue
506
+ text = (lookup.get(cid) or {}).get("text") or ""
507
+ if "消耗减少" in text or "费用减少" in text or "费用降低" in text:
508
+ n = _tag_int(e, "TAG_SCRIPT_DATA_NUM_1")
509
+ if n > 0:
510
+ cut += n
511
+ if not cut:
512
+ return None
513
+ c = lookup.get(ent["cardId"]) if ent["cardId"] else None
514
+ base = c.get("cost") if c else None
515
+ if base is None:
516
+ base = _tag_int(ent, "TAG_LAST_KNOWN_COST_IN_HAND") or None
517
+ if base is None:
518
+ return None
519
+ now = max(base - cut, 0)
520
+ return (base, now) if now < base else None
521
+
522
+
523
+ def _finish_sog(game, blk):
524
+ """开局触发块结束 -> 汇总事件: 触发源 + 复制洗入牌库的张数/卡名 + 替换的英雄技能"""
525
+ info = blk["sog"]
526
+ src_id = _ref_id(info["src"] or "")
527
+ src = game["entities"].get(src_id) if src_id is not None else None
528
+ copies, power = [], None
529
+ for eid in info["created"]:
530
+ ent = game["entities"].get(eid)
531
+ if not ent:
532
+ continue
533
+ if _ctype(ent) == "HERO_POWER":
534
+ power = eid # 开局替换技能 (穆格·兹伊类)
535
+ elif ent["zone"] == "DECK":
536
+ copies.append(eid) # 复制洗入牌库 (霍格类)
537
+ if src is None and not copies and power is None:
538
+ return
539
+ _record(game, "sog", turn=0,
540
+ actor=src["controller"] if src and src["controller"] is not None else None,
541
+ src=src_id, copies=copies, power=power)
542
+
543
+
544
+ def _handle_entity_choices(game, line):
545
+ """对局中 GENERAL 选择列选项 (发现/灾变类): 记 id/玩家/Source/选项实体, 等 EntitiesChosen 确认。
546
+ 只认 ChoiceType=GENERAL, 换牌 MULLIGAN/战吼 TARGET 不进此状态, 不与开局处理重复"""
547
+ m = EC_HEAD_RE.match(line)
548
+ if m:
549
+ game["ec_choice"] = ({"id": int(m.group(1)), "player": m.group(2).strip(),
550
+ "src": None, "options": []}
551
+ if m.group(3) == "GENERAL" else None)
552
+ return
553
+ ch = game.get("ec_choice")
554
+ if ch is None:
555
+ return
556
+ body = line.split("GameState.DebugPrintEntityChoices() - ", 1)
557
+ body = body[1] if len(body) == 2 else ""
558
+ if body.startswith("Source="):
559
+ ch["src"] = _ref_id(body[7:].strip())
560
+ return
561
+ bm = EC_BRACKET_RE.search(body)
562
+ if bm:
563
+ eid = _ref_id(bm.group(1))
564
+ if eid is not None:
565
+ ch["options"].append(eid)
566
+
567
+
568
+ def _handle_entities_chosen(game, line):
569
+ """EntitiesChosen 双语义: 换牌期 = 保留选择 (kept, 原逻辑不动);
570
+ 对局中 = GENERAL 选择确认 -> 「选择:选项名」事件 (对方选择也走这里, 无 SendChoices 行)"""
571
+ if not game["gate"] or game["grace"]:
572
+ ce = CHOSEN_ENT_RE.match(line)
573
+ if ce:
574
+ pm = CHOSEN_PLAYER_RE.search(ce.group(1))
575
+ idm = BRACKET_ID_RE.search(ce.group(1))
576
+ if pm and idm:
577
+ ctl, eid = int(pm.group(1)), int(idm.group(1))
578
+ lst = game["opening"]["kept"].setdefault(ctl, [])
579
+ if eid not in lst:
580
+ lst.append(eid)
581
+ return
582
+ cm = EC_CHOSEN_HEAD_RE.match(line)
583
+ if cm:
584
+ ch = game.get("ec_choice")
585
+ game["ec_confirm"] = ({"player": cm.group(2).strip(), "total": _num(cm.group(3)) or 1,
586
+ "chosen": []}
587
+ if ch is not None and ch["id"] == int(cm.group(1)) else None)
588
+ return
589
+ cf = game.get("ec_confirm")
590
+ if cf is None:
591
+ return
592
+ ce = CHOSEN_ENT_RE.match(line)
593
+ if ce:
594
+ idm = BRACKET_ID_RE.search(ce.group(1))
595
+ eid = int(idm.group(1)) if idm else None
596
+ if eid is not None and eid not in cf["chosen"]:
597
+ cf["chosen"].append(eid)
598
+ if len(cf["chosen"]) >= cf["total"]:
599
+ _emit_choose(game, cf)
600
+ game["ec_confirm"], game["ec_choice"] = None, None
601
+
602
+
603
+ def _emit_choose(game, cf):
604
+ """GENERAL 选择确认 -> 选择事件 (选项实体留 id, 渲染时解名: 对方后来打出的发现牌也能对上名字)"""
605
+ if not cf["chosen"]:
606
+ return
607
+ ctl = None
608
+ peid = game["names"].get(cf["player"])
609
+ pent = game["entities"].get(peid) if peid is not None else None
610
+ if pent is not None and _ctype(pent) == "PLAYER":
611
+ ctl = pent["controller"]
612
+ _record(game, "choose", actor=ctl, eids=list(cf["chosen"]))
613
+
614
+
615
+ def _finish_deck_new(game, blk, ctx):
616
+ """块结束 -> 块内生成直接进牌库的实体汇总事件 (洗入牌库 N 张(来源效果), 卡名可见才带):
617
+ 灾变类选择/触发效果洗牌; 开局霍格式复制在 gate 开闸前, 仍由 sog 事件覆盖, 不重复"""
618
+ if not game["gate"] or game["grace"]:
619
+ return
620
+ by_ctl = {}
621
+ for eid in blk["deck_new"]:
622
+ ent = game["entities"].get(eid)
623
+ if ent is not None and ent["controller"] is not None:
624
+ by_ctl.setdefault(ent["controller"], []).append(eid)
625
+ for ctl, eids in by_ctl.items():
626
+ _record(game, "shuffle", actor=ctl, eids=eids, count=len(eids),
627
+ src_kind=ctx[0] if ctx else "effect", src_id=ctx[1] if ctx else None)
628
+
629
+
630
+ def _reveal_kind(game, info):
631
+ """亮牌来源类别: 触发块_keyword 是亡语 (或来源实体已死亡) 判亡语, 否则普通触发"""
632
+ sid = info.get("src_id")
633
+ if info.get("kw") == "DEATHRATTLE" or (sid is not None and sid in game["dead_seen"]):
634
+ return "deathrattle"
635
+ return "trigger"
636
+
637
+
638
+ def _note_reveal(game, eid):
639
+ """亡语块内 SHOW_ENTITY 揭示隐藏实体 (雷鸣流云亡语亮出吸收的法术): 挂观察等 CARDTYPE 定性
640
+ (只有法术报亮牌), 块内 POWER/PLAY 块以它为源结算 = 已施放; 被藏回 (REVEALED 后 HIDE) =
641
+ 过场动画, 撤回。非亡语触发 (兆示自亮/阶段触发) 不挂, 避免噪音"""
642
+ inner = next((b for b in reversed(game["blocks"]) if b["type"] == "TRIGGER"), None)
643
+ if inner is None:
644
+ return
645
+ info = {"depth": len(game["blocks"]), "src_id": _ref_id(inner.get("entity") or ""),
646
+ "kw": inner.get("kw"), "hidden": False, "ev": None}
647
+ if _reveal_kind(game, info) != "deathrattle":
648
+ return
649
+ game["reveal_watch"][eid] = info
650
+
651
+
652
+ def _note_reveal_cardtype(game, ent, value):
653
+ """揭示实体定性为法术 -> 立即出亮牌事件 (占位: 事件顺序紧跟死亡/触发, 施放标记后补)"""
654
+ info = game["reveal_watch"].get(ent["id"])
655
+ if (info is None or info.get("ev") is not None or info.get("hidden")
656
+ or _norm(value, CTYPE_BY_NUM) != "SPELL" or ent["controller"] is None
657
+ or not game["gate"] or game["grace"]):
658
+ return
659
+ info["ev"] = _record(game, "reveal", actor=ent["controller"], eid=ent["id"],
660
+ src_kind=_reveal_kind(game, info), src_id=info.get("src_id"), cast=False)
661
+
662
+
663
+ def _finalize_reveal(game, eid):
664
+ """亮牌观察收尾 (所属块关闭): 被藏回的过场揭示撤回事件, 其余保留 (cast 标记已定)"""
665
+ info = game["reveal_watch"].pop(eid)
666
+ ev = info.get("ev")
667
+ if info.get("hidden"):
668
+ if ev is not None and ev in game["events"]:
669
+ game["events"].remove(ev)
670
+ return
671
+ if ev is not None:
672
+ return
673
+ ent = game["entities"].get(eid)
674
+ if (ent is None or _ctype(ent) != "SPELL" or ent["controller"] is None
675
+ or not game["gate"] or game["grace"]):
676
+ return
677
+ _record(game, "reveal", actor=ent["controller"], eid=eid,
678
+ src_kind=_reveal_kind(game, info), src_id=info.get("src_id"), cast=False)
679
+
680
+
681
+ def _stat_snap(game, eid):
682
+ """随从当前攻血快照 (当前血=HEALTH-DAMAGE, 炉石受伤走 DAMAGE 累积); 非随从/取不到返回 None"""
683
+ ent = game["entities"].get(eid) if eid is not None else None
684
+ if not ent or _ctype(ent) != "MINION":
685
+ return None
686
+ atk = _tag_int(ent, "ATK")
687
+ hp = (_tag_int(ent, "HEALTH") or ent["peak_hp"]) - _tag_int(ent, "DAMAGE")
688
+ return atk, max(hp, 0)
689
+
690
+
691
+ def _track_settle(game, ev):
692
+ """出牌/召唤事件的攻血快照等结算后回填: 记下事件与所处块深度, BLOCK_END 时刷新为稳定值"""
693
+ if ev.get("stat") is not None:
694
+ game["pending_stats"].append({"ev": ev, "depth": len(game["blocks"])})
695
+
696
+
697
+ def _settle_stats(game):
698
+ """块结束 -> 把本块(含子块)内记录的出牌/召唤攻血刷新为当前实体属性。
699
+
700
+ 战吼在出牌块的嵌套 POWER 子块里结算 (如暮光幼龙 +血), 记录时刻的快照是打出前旧值;
701
+ 块关闭时回填即"结算稳定后的面板值", 块外后续回合的 buff 不受影响。
702
+ key/eid 可覆盖回填字段与实体 (复生回填死亡事件、反解攻击目标回填 tgt_stat)。"""
703
+ depth = len(game["blocks"])
704
+ keep = []
705
+ for it in game["pending_stats"]:
706
+ if it["depth"] >= depth:
707
+ it["ev"][it.get("key", "stat")] = _stat_snap(game, it.get("eid") or it["ev"].get("eid"))
708
+ else:
709
+ keep.append(it)
710
+ game["pending_stats"] = keep
711
+ # 已关闭块内的治疗/光环下调记录过期, 防跨块误配
712
+ game["heal_watch"] = [x for x in game["heal_watch"] if x["depth"] < depth]
713
+ game["aura_drops"] = [x for x in game["aura_drops"] if x["depth"] < depth]
714
+
715
+
716
+ def _summon_ctx(game):
717
+ """块栈 -> (来源类别, 来源实体id): 内层优先。TRIGGER 块按 TriggerKeyword/死者判亡语, 否则触发效果;
718
+ 无触发块时找 PLAY 块 (块实体是随从=战吼)。类别: deathrattle/battlecry/trigger/effect"""
719
+ dead = game["dead_seen"]
720
+ for b in reversed(game["blocks"]):
721
+ if b["type"] == "TRIGGER":
722
+ sid = _ref_id(b.get("entity") or "")
723
+ kind = "deathrattle" if (b.get("kw") == "DEATHRATTLE" or (sid is not None and sid in dead)) else "trigger"
724
+ return kind, sid
725
+ for b in reversed(game["blocks"]):
726
+ if b["type"] == "PLAY":
727
+ sid = _ref_id(b.get("entity") or "")
728
+ sent = game["entities"].get(sid)
729
+ return ("battlecry" if sent is not None and _ctype(sent) == "MINION" else "effect"), sid
730
+ if game["blocks"]:
731
+ b = game["blocks"][-1]
732
+ return "effect", _ref_id(b.get("entity") or "")
733
+ return "effect", None
734
+
735
+
736
+ def _power_blk_id(game):
737
+ """块栈内层往外第一个 POWER 块且块实体是英雄技能 -> 技能实体id; 中途遇 TRIGGER 或非技能块返回 None
738
+ (技能块内触发的效果不算技能自带, 护甲照常单独发事件)"""
739
+ for b in reversed(game["blocks"]):
740
+ if b["type"] == "TRIGGER":
741
+ return None
742
+ if b["type"] == "POWER":
743
+ pid = _ref_id(b.get("entity") or "")
744
+ p = game["entities"].get(pid)
745
+ return pid if p is not None and _ctype(p) == "HERO_POWER" else None
746
+ return None
747
+
748
+
749
+ def _attach_power_armor(game, pid, gain, hero_id):
750
+ """英雄技能块内的护甲变化不单独发事件: 优先回填本块刚生成的技能事件, 否则挂起等技能事件生成时内联"""
751
+ for ev in reversed(game["events"][-4:]):
752
+ if ev["type"] in ("power", "power_change") and ev.get("eid") == pid:
753
+ ev["armor"] = (ev.get("armor") or 0) + gain
754
+ return
755
+ p = game["hp_armor_pend"].get(pid)
756
+ game["hp_armor_pend"][pid] = [gain, hero_id] if not p else [p[0] + gain, hero_id]
757
+
758
+
759
+ def _heal_src(game):
760
+ """治疗来源块上下文: 内层优先 触发块=触发源, 英雄技能块=技能, 其他 POWER 块=块实体;
761
+ 判不出返回 (None, None)"""
762
+ for b in reversed(game["blocks"]):
763
+ if b["type"] == "TRIGGER":
764
+ sid = _ref_id(b.get("entity") or "")
765
+ s = game["entities"].get(sid)
766
+ if s is not None and _ctype(s) != "PLAYER": # 疲劳/阶段触发(玩家实体)不算来源
767
+ return "trigger", sid
768
+ continue
769
+ if b["type"] == "POWER":
770
+ pid = _ref_id(b.get("entity") or "")
771
+ if pid is None:
772
+ return None, None
773
+ p = game["entities"].get(pid)
774
+ return ("heropower" if p is not None and _ctype(p) == "HERO_POWER" else "power"), pid
775
+ return None, None
776
+
777
+
778
+ def _note_aura_drop(game, ent, old_v, new_v, depth):
779
+ """块内 HEALTH 下调 = 疑似光环移除: 同块刚记的治疗事件改判为光环回调, 否则留档供紧随的
780
+ DAMAGE 回调识别 (有效生命没变, 不应显示为治疗)"""
781
+ for it in reversed(game["heal_watch"]):
782
+ if it["eid"] == ent["id"] and it["depth"] >= depth:
783
+ game["heal_watch"].remove(it)
784
+ it["ev"]["type"] = "aura_cb"
785
+ it["ev"]["vals"] = (old_v, new_v)
786
+ for k in ("hp", "src_kind", "src_id"):
787
+ it["ev"].pop(k, None)
788
+ return
789
+ game["aura_drops"].append({"eid": ent["id"], "vals": (old_v, new_v), "depth": depth})
790
+
791
+
792
+ def _note_bc_target(game, ent):
793
+ """PLAY 块(战吼)内对其他实体的影响类 tag (DAMAGE/SILENCED/控制类) -> 记为打出目标,
794
+ 事件行渲染时追加 「→ 目标」。触发块内的变化 (亡语/吸血等) 与块内新创建的实体
795
+ (复生/附属物自带 DAMAGE 数据) 不算战吼指向"""
796
+ for b in reversed(game["blocks"]):
797
+ if b["type"] == "TRIGGER":
798
+ return
799
+ if b["type"] == "PLAY":
800
+ pid = _ref_id(b.get("entity") or "")
801
+ pev = b.get("play_ev")
802
+ if pev is None or pid is None or pid == ent["id"] or ent["id"] not in b.get("pre_ids", ()):
803
+ return # 内层 PLAY 块不匹配 (嵌套出牌) 不再外溯
804
+ p = game["entities"].get(pid)
805
+ if p is not None and _ctype(p) == "MINION" and pev.get("ctype") == "MINION":
806
+ lst = pev.setdefault("bc_tgts", [])
807
+ if ent["id"] not in lst:
808
+ lst.append(ent["id"])
809
+ return
810
+
811
+
812
+ # 战吼目标标注认的影响类 tag: 伤害/沉默/冻结/消灭/控制
813
+ BC_TARGET_TAGS = {"DAMAGE", "SILENCED", "FROZEN", "DESTROY", "CONTROLLER"}
814
+
815
+
816
+ def _src_label(lookup, game, ev):
817
+ """事件来源类别+实体 -> 来源描述 (亡语/战吼/复生/<名>效果); 判不出返回 None"""
818
+ kind, sid = ev.get("src_kind"), ev.get("src_id")
819
+ if kind == "reborn":
820
+ return "复生"
821
+ if kind == "deathrattle" and sid is None:
822
+ return "亡语"
823
+ nm = _ev_name(lookup, game, sid, "") if sid is not None else ""
824
+ if kind == "deathrattle":
825
+ return f"{nm}亡语" if nm else "亡语"
826
+ if kind == "battlecry":
827
+ return f"{nm}战吼" if nm else "战吼"
828
+ return f"{nm}效果" if nm else None
829
+
830
+
831
+ def _reborn_backfill(game, dev, eid):
832
+ """复生回填: 死亡事件追加复生标记, 块结束时回填复活实体的攻血快照"""
833
+ if dev is None or dev.get("reborn"):
834
+ return
835
+ dev["reborn"] = True
836
+ game["pending_stats"].append({"ev": dev, "depth": len(game["blocks"]), "key": "reborn_stat", "eid": eid})
837
+
838
+
839
+ def _flush_summon(game):
840
+ """实体 tag 段结束 (下一 packet 到来) -> 给延迟定性的效果召唤出事件"""
841
+ ids = game["pending_summon"]
842
+ if not ids:
843
+ return
844
+ game["pending_summon"] = []
845
+ for eid in ids:
846
+ ent = game["entities"].get(eid)
847
+ if ent is not None:
848
+ _record_effect_summon(game, ent)
849
+
850
+
851
+ def _record_effect_summon(game, ent):
852
+ """块内 FULL_ENTITY 直接建在场面的召唤 -> 效果召唤事件 (亡语/战吼/触发/复生来源)。
853
+
854
+ 同回合同来源的召唤合并为一条 (×N) 防食尸鬼潮刷屏; 复生召唤优先回填死者死亡事件。"""
855
+ ctl = ent["controller"]
856
+ if ctl is None:
857
+ return
858
+ ent["entered_turn"] = game["turn"]
859
+ if _tag_int(ent, "HAS_BEEN_REBORN") == 1:
860
+ kind, sid = "reborn", None
861
+ elif game["blocks"]:
862
+ kind, sid = _summon_ctx(game)
863
+ else:
864
+ return # 块外无上下文的直接铺场判不出来源, 不生成 (降级安全)
865
+ if kind == "reborn":
866
+ dev = game["death_evs"].get(_tag_int(ent, "CREATOR") or -1)
867
+ if dev is not None:
868
+ _reborn_backfill(game, dev, ent["id"])
869
+ return
870
+ cid = ent["cardId"] or ent["name"]
871
+ if _tag_int(ent, "COLOSSAL_LIMB") == 1: # 巨型附属物 t1/t2/t3 按本体归并, 渲染时用实际卡名 (如 黑血黏质 ×3)
872
+ cid = re.sub(r"t\d+$", "", cid)
873
+ key = (game["turn"], ctl, kind, sid, cid) # 仅同名召唤合并
874
+ acc = game["summon_acc"].get(key)
875
+ if acc is not None:
876
+ acc["count"] = (acc.get("count") or 1) + 1
877
+ game["pending_stats"].append({"ev": acc, "depth": len(game["blocks"]), "key": "stat", "eid": ent["id"]})
878
+ return
879
+ ev = _record(game, "summon", actor=ctl, eid=ent["id"], stat=_stat_snap(game, ent["id"]),
880
+ src_kind=kind, src_id=sid, count=1)
881
+ game["pending_stats"].append({"ev": ev, "depth": len(game["blocks"]), "key": "stat", "eid": ent["id"]})
882
+ game["summon_acc"][key] = ev
883
+
884
+
885
+ def _record_gain_src(game, ent):
886
+ """块内直接建在手牌的获得 -> 带来源获得事件:
887
+ TRIGGER 块 = 触发效果 (逐月幼龙类回合结束送牌);
888
+ PLAY 块且块实体是随从 = 战吼生成进手牌 (对方手牌否则无中生有)"""
889
+ if ent["controller"] is None or not game["blocks"]:
890
+ return
891
+ for b in reversed(game["blocks"]):
892
+ if b["type"] != "TRIGGER":
893
+ continue
894
+ sid = _ref_id(b.get("entity") or "")
895
+ sent = game["entities"].get(sid)
896
+ if sent is not None and _ctype(sent) != "PLAYER": # 疲劳/阶段类触发(玩家实体)不算来源
897
+ _record(game, "gain_src", actor=ent["controller"], eid=ent["id"], src_kind="trigger", src_id=sid)
898
+ return
899
+ for b in reversed(game["blocks"]): # 战吼: 内层 PLAY 块实体是随从 -> 归该随从战吼 (法术块不归)
900
+ if b["type"] != "PLAY":
901
+ continue
902
+ sid = _ref_id(b.get("entity") or "")
903
+ sent = game["entities"].get(sid)
904
+ if sent is not None and _ctype(sent) == "MINION":
905
+ _record(game, "gain_src", actor=ent["controller"], eid=ent["id"], src_kind="battlecry", src_id=sid)
906
+ return
907
+
908
+
909
+ def _watch_tag(game, ent, tag, value, old_zone, old_exh):
910
+ """重放时同步收集行动事件; 换牌结束 (MULLIGAN_STATE=DONE) 前的初始铺场/换牌一律不记"""
911
+ if tag == "TURN" and (_ctype(ent) == "GAME" or ent["id"] == 1):
912
+ # 回合切换前兜底: 技能块护甲没等到技能事件 (日志顺序异常) 就补发独立护甲事件, 不丢数值
913
+ for gain, hero_id in game["hp_armor_pend"].values():
914
+ if gain and hero_id:
915
+ _record(game, "armor", eid=hero_id, gain=gain)
916
+ game["hp_armor_pend"].clear()
917
+ n = _num(value)
918
+ if n is not None:
919
+ game["turn"] = n
920
+ game["grace"] = False # TURN 变化兜底结束抽牌宽限期
921
+ game["summon_acc"].clear() # 召唤合并按回合分桶, 跨回合不再合并
922
+ return
923
+ if tag == "STEP" and _ctype(ent) == "GAME" and str(value) == "MAIN_READY":
924
+ # 首个正式回合开始 (MAIN_READY), 宽限期结束, 之后的 DECK->HAND 都是正式抽牌
925
+ game["grace"] = False
926
+ return
927
+ if tag == "MULLIGAN_STATE" and _norm(value, MULLIGAN_BY_NUM) == "DONE":
928
+ # 唯一开闸点: 任一方换牌 DONE = 换牌流程结束, 之后才是正式对局行动 (只开闸一次)
929
+ if not game["gate"]:
930
+ game["gate"] = True
931
+ game["grace"] = True # 开闸宽限: 引擎才落盘的换牌塞回/新抽移动不算正式抽牌
932
+ # 行动方 = 先手方 (FIRST_PLAYER tag 的真实回合归属): 第 1 回合起手期间日志不再发
933
+ # CURRENT_PLAYER=1, 若按 DONE 主体归属会被后完成换牌的后手方错误覆盖
934
+ fp = next((p for p in _players(game) if _tag_int(p, "FIRST_PLAYER") == 1), None)
935
+ if fp is not None:
936
+ game["actor"] = fp["controller"] if fp["controller"] is not None else (_tag_int(fp, "PLAYER_ID") or None)
937
+ if game["actor"] is None: # 兜底: 日志无 FIRST_PLAYER 时按首个 DONE (先手先完成换牌)
938
+ game["actor"] = ent["controller"] if ent["controller"] is not None else (_tag_int(ent, "PLAYER_ID") or None)
939
+ return
940
+ if tag == "CURRENT_PLAYER" and _ctype(ent) == "PLAYER":
941
+ if _num(value) == 1:
942
+ # 每次回合切换都刷新行动方 (否则行动回顾全部归到换牌 DONE 时的先手头上)
943
+ game["actor"] = ent["controller"] if ent["controller"] is not None else (_tag_int(ent, "PLAYER_ID") or None)
944
+ return
945
+ if not game["gate"]:
946
+ if tag == "ZONE":
947
+ _zone_event(game, ent, old_zone) # 开闸前只可能命中 DECK->HAND 白名单 (起手发牌)
948
+ return
949
+ if tag == "ZONE":
950
+ _zone_event(game, ent, old_zone)
951
+ elif (tag == "EXHAUSTED" and _num(value) == 1 and old_exh != 1
952
+ and _ctype(ent) == "HERO_POWER" and ent["zone"] == "PLAY"):
953
+ # 块起点已出过技能事件 (power_ev) 则复用, 防止重复; 无块上下文 (EXHAUSTED 迟到) 才补发
954
+ pev = next((b["power_ev"] for b in reversed(game["blocks"])
955
+ if "power_ev" in b and b["power_ev"].get("eid") == ent["id"]), None)
956
+ if pev is None:
957
+ pev = _record(game, "power", eid=ent["id"])
958
+ pend = game["hp_armor_pend"].pop(ent["id"], None)
959
+ if pend: # 技能块内攒下的护甲内联到技能行 (如 全副武装!(+2 护甲))
960
+ pev["armor"] = pend[0]
961
+
962
+
963
+ def _zone_event(game, ent, old):
964
+ """区域迁移 -> 事件 (只认白名单迁移, SETASIDE/REMOVEDFROMGAME 间挪动等噪音天然过滤)"""
965
+ new, ct = ent["zone"], _ctype(ent)
966
+ if old == "DECK" and new == "HAND":
967
+ if not game["gate"] or game["grace"]:
968
+ _record_opening(game, ent) # 起手发牌/换牌重抽 -> 开局事件 (换牌塞回不在此白名单)
969
+ else:
970
+ _record(game, "draw", eid=ent["id"])
971
+ return
972
+ if not game["gate"]:
973
+ return
974
+ if old == "HAND" and new == "PLAY" and ct in PLAYABLE_TYPES:
975
+ target = None
976
+ top = game["blocks"][-1] if game["blocks"] else None
977
+ match_blk = top if (top and top["type"] == "PLAY"
978
+ and _ref_id(top["entity"] or "") == ent["id"]) else None
979
+ if match_blk:
980
+ target = match_blk["target"] # 出牌块的 Target 即法术/武器指向
981
+ ev = _record(game, "play", eid=ent["id"], ctype=ct, target_ref=target,
982
+ stat=_stat_snap(game, ent["id"]),
983
+ forge_pid=_tag_int(ent, "DISPLAY_ENTITY_ON_MOUSEOVER") or None)
984
+ # 兆示 (Forge) 预览指针打牌瞬间快照: 引擎随后立刻把它清 0, 最终态查不到
985
+ if match_blk:
986
+ match_blk["play_ev"] = ev # 战吼对其他实体的影响 tag 回填到此事件 (→ 目标)
987
+ match_blk["pre_ids"] = {e["id"] for e in game["entities"].values() if e["zone"] == "PLAY"}
988
+ ent["entered_turn"] = game["turn"] # 上场回合号, 供 [本回合上场] 与当前回合对账
989
+ _track_settle(game, ev)
990
+ elif old == "PLAY" and new == "HAND" and ct in PLAYABLE_TYPES:
991
+ _record(game, "bounce", eid=ent["id"]) # 被移回手牌 (对方亡语/法术效果), 不记则场面凭空少人
992
+ elif old == "HAND" and new == "SECRET":
993
+ # 奥秘/任务打出: 任务名进度公开(方括号引用带内联名), 真奥秘隐藏(UNKNOWN) 触发才揭示;
994
+ # 打出时刻快照可见性与名字 (任务链变形会重建实体, 最终态查不到打出的名字)
995
+ nm = ent.get("name") or ""
996
+ known = bool(ent["cardId"]) or bool(nm and not nm.startswith("UNKNOWN"))
997
+ _record(game, "secret_play", eid=ent["id"], known=known,
998
+ name=nm if nm and not nm.startswith("UNKNOWN") else "", card_id=ent["cardId"] or "")
999
+ elif old == "SETASIDE" and new == "PLAY":
1000
+ if ct == "MINION":
1001
+ ev = _record(game, "summon", eid=ent["id"], stat=_stat_snap(game, ent["id"]))
1002
+ ent["entered_turn"] = game["turn"]
1003
+ _track_settle(game, ev)
1004
+ elif ct == "WEAPON":
1005
+ _record(game, "equip", eid=ent["id"])
1006
+ elif old == "GRAVEYARD" and new == "PLAY" and ct == "MINION":
1007
+ # 同实体复活 (少见形态): 回填死亡事件复生标记
1008
+ _reborn_backfill(game, game["death_evs"].get(ent["id"]), ent["id"])
1009
+ elif new == "GRAVEYARD":
1010
+ if old == "SECRET":
1011
+ # 触发时刻已在 SHOW_ENTITY 揭示时出过事件 (secret_trig), 这里只兜底没走揭示路径的
1012
+ # (被效果直接清掉进坟场等), 保持原口径
1013
+ if ent["id"] not in game["secret_trig"]:
1014
+ _record(game, "secret_trigger", eid=ent["id"]) # 奥秘触发后进坟场, cardId 此时已揭示
1015
+ elif old == "PLAY" and ct in ("MINION", "HERO", "WEAPON", "LOCATION"):
1016
+ ev = _record(game, "death", eid=ent["id"], stat=_stat_snap(game, ent["id"])) # 法术/技能结算进坟场不算死亡
1017
+ game["dead_seen"].add(ent["id"]) # 亡语块判定 + 复生回填锚点
1018
+ game["death_evs"][ent["id"]] = ev
1019
+ elif old == "HAND":
1020
+ _record(game, "discard", eid=ent["id"])
1021
+ elif old == "SETASIDE" and new == "HAND":
1022
+ if game["gate"] and not game["grace"] and any(b["type"] == "TRIGGER" for b in game["blocks"]):
1023
+ _record_gain_src(game, ent) # 触发效果送入手牌 -> 带来源获得
1024
+ else:
1025
+ _record(game, "gain", eid=ent["id"])
1026
+
1027
+
1028
+ # 只有 Player 实体才有的 tag (新版日志对手真名经 TAG_CHANGE Entity=<名字> 揭晓, 用于归并)
1029
+ PLAYER_ONLY_TAGS = {"PLAYSTATE", "CURRENT_PLAYER", "MULLIGAN_STATE", "HERO_ENTITY", "TIMEOUT",
1030
+ "PLAYER_ID", "MAXHANDSIZE", "STARTHANDSIZE", "TEAM_ID", "MAXRESOURCES",
1031
+ "FIRST_PLAYER", "FATIGUE", "LAST_MSG_PLAYED"}
1032
+
1033
+
1034
+ def _handle_packet(game, payload):
1035
+ """处理一条 packet 行 (CREATE_GAME 由外层处理); 顶格与块内缩进共用"""
1036
+ _flush_summon(game) # 新 packet = 上一个 FULL_ENTITY 的 tag 段已结束
1037
+ if game.get("burn_pending"):
1038
+ # 手牌满烧牌: META_DATA Meta=BURNED_CARD 的下一行 Info[0] 指向被烧实体 (卡名已公开)
1039
+ game["burn_pending"] = False
1040
+ m = INFO_LINE_RE.match(payload)
1041
+ if m:
1042
+ ref = f"[{m.group(1)}]" if m.group(1) else (m.group(2) or "")
1043
+ eid = _ref_id(ref)
1044
+ ent = game["entities"].get(eid)
1045
+ if ent is not None and game["gate"] and not game["grace"]:
1046
+ _record(game, "burn", actor=ent["controller"], eid=eid)
1047
+ return
1048
+ m = META_BURN_RE.match(payload)
1049
+ if m:
1050
+ game["burn_pending"] = True
1051
+ return
1052
+ m = GAME_ENTITY_RE.match(payload)
1053
+ if m:
1054
+ game["cur"] = _create_entity_by_id(game, int(m.group(1)), "", name="GameEntity")
1055
+ return
1056
+ m = PLAYER_ENTITY_RE.match(payload)
1057
+ if m:
1058
+ eid = _create_entity_by_id(game, int(m.group(1)), "")
1059
+ game["entities"][eid]["tags"]["PLAYER_ID"] = m.group(2)
1060
+ pname = game["player_names"].get(int(m.group(2)))
1061
+ if pname and not pname.startswith("UNKNOWN HUMAN PLAYER"): # 匿名占位不占名字位, 等真名归并
1062
+ game["entities"][eid]["name"] = pname
1063
+ game["names"][pname] = eid
1064
+ game["cur"] = eid
1065
+ return
1066
+ m = FULL_ENTITY_CREATE_RE.match(payload)
1067
+ if m:
1068
+ eid = int(m.group(1))
1069
+ game["cur"] = _create_entity_by_id(game, eid, m.group(2))
1070
+ _note_created(game, eid)
1071
+ _note_deck_create(game)
1072
+ return
1073
+ m = FULL_ENTITY_RE.match(payload)
1074
+ if m:
1075
+ eid = _create_entity(game, m.group(1), m.group(2))
1076
+ if eid is not None:
1077
+ _note_created(game, eid)
1078
+ _note_deck_create(game)
1079
+ game["cur"] = eid
1080
+ return
1081
+ m = SHOW_ENTITY_RE.match(payload)
1082
+ if m:
1083
+ eid = _resolve(game, m.group(1))
1084
+ ent = game["entities"].get(eid)
1085
+ if ent:
1086
+ revealed_new = not ent["cardId"] and m.group(2)
1087
+ ent["cardId"] = m.group(2)
1088
+ # 隐藏奥秘被揭示 = 奥秘触发时刻 (揭示紧跟触发块/触发效果之前)。原先等 SECRET->GRAVEYARD
1089
+ # 迁移才补发, 但迁移在触发块收尾, 事件会排到触发效果 (如冰冻陷阱回手) 之后, 次序颠倒
1090
+ if (revealed_new and game["gate"] and not game["grace"]
1091
+ and ent["zone"] == "SECRET" and eid not in game["secret_trig"]):
1092
+ game["secret_trig"].add(eid)
1093
+ _record(game, "secret_trigger", eid=eid)
1094
+ # 亡语/触发块内首次揭示隐藏实体 (雷鸣流云亡语亮出吸收的法术): 挂观察等定性
1095
+ if (revealed_new and game["gate"] and not game["grace"]
1096
+ and any(b["type"] == "TRIGGER" for b in game["blocks"])):
1097
+ _note_reveal(game, eid)
1098
+ # 起手直接建在手牌区的卡 (后手整手/幸运币) 没有区域迁移, 靠揭示时机记开局发牌
1099
+ if (not game["gate"] or game["grace"]) and ent["zone"] == "HAND" and ent["cardId"]:
1100
+ _record_opening(game, ent)
1101
+ game["cur"] = eid
1102
+ return
1103
+ m = HIDE_ENTITY_RE.match(payload)
1104
+ if m:
1105
+ eid = _resolve(game, m.group(1))
1106
+ ent = game["entities"].get(eid)
1107
+ if ent:
1108
+ if ent["cardId"]:
1109
+ ent["seen_cardid"] = ent["cardId"] # 曾揭示后又被藏回 (开局触发源换回牌库), 留名兜底
1110
+ ent["cardId"] = ""
1111
+ info = game["reveal_watch"].get(eid)
1112
+ if info is not None:
1113
+ info["hidden"] = True # 亮出随即藏回 = 过场动画 (野兽绊索洗牌), 收尾时撤回
1114
+ if m.group(2):
1115
+ _apply_tag(game, eid, m.group(2), m.group(3) or "", via_change=True)
1116
+ game["cur"] = eid
1117
+ return
1118
+ m = TAG_CHANGE_RE.match(payload)
1119
+ if m:
1120
+ ref, tag, val = m.group(1), m.group(2), m.group(3)
1121
+ eid = _resolve(game, ref)
1122
+ ent = game["entities"].get(eid)
1123
+ if ent and eid < 0 and tag in PLAYER_ONLY_TAGS:
1124
+ # 匿名占位 (UNKNOWN HUMAN PLAYER) 或无名 Player 都算待归并, 真名到来时覆盖
1125
+ unnamed = [p for p in _players(game)
1126
+ if not p["name"] or p["name"] == "UNKNOWN HUMAN PLAYER"]
1127
+ if len(unnamed) == 1: # 唯一待归并 Player -> 真名归并
1128
+ p = unnamed[0]
1129
+ game["names"][ent["name"]] = p["id"]
1130
+ p["name"] = ent["name"]
1131
+ del game["entities"][eid]
1132
+ eid = p["id"]
1133
+ _apply_tag(game, eid, tag, val, via_change=True)
1134
+ return
1135
+ m = CHANGE_ENTITY_RE.match(payload)
1136
+ if m:
1137
+ eid = _resolve(game, m.group(1))
1138
+ ent = game["entities"].get(eid)
1139
+ if ent:
1140
+ ent["cardId"] = m.group(2)
1141
+ game["cur"] = eid
1142
+ return
1143
+ if payload[:11] == "BLOCK_START":
1144
+ # 块结构只用于事件归属: 压栈记录; cur 置空, 块参数 tag (PROPOSED_*/SCRIPT_DATA 等) 不误挂实体
1145
+ game["cur"] = None
1146
+ bt = BLOCK_TYPE_RE.match(payload)
1147
+ btype = bt.group(1) if bt else ""
1148
+ ent_m = BLOCK_ENTITY_RE.search(payload)
1149
+ tgt_m = BLOCK_TARGET_RE.search(payload)
1150
+ kw_m = TRIGGER_KW_RE.search(payload)
1151
+ blk = {"type": btype, "entity": ent_m.group(1) if ent_m else None,
1152
+ "target": tgt_m.group(1) if tgt_m else None,
1153
+ "kw": kw_m.group(1) if kw_m else None}
1154
+ game["blk_seq"] += 1
1155
+ blk["seq"] = game["blk_seq"]
1156
+ rid = _ref_id(blk["entity"] or "") if blk["entity"] else None
1157
+ if rid is not None and btype in ("POWER", "PLAY", "TRIGGER") and rid in game["reveal_watch"]:
1158
+ info = game["reveal_watch"][rid]
1159
+ if info.get("ev") is not None:
1160
+ # 亮出的牌有以它为源的结算块 = 已施放 (POWER=直接结算, TRIGGER=施放时施法 CASTS_WHEN_DRAWN)
1161
+ info["ev"]["cast"] = True
1162
+ if btype in ("PLAY", "POWER") and rid is not None and not any("power_ev" in b for b in game["blocks"]):
1163
+ # 英雄技能启用: 块起点即按键时刻, 立即出事件。原先等块尾 EXHAUSTED=1 才补发,
1164
+ # 技能块内触发的效果 (甲龙攻击/灌注获得) 会排到技能前面, 次序颠倒
1165
+ pent = game["entities"].get(rid)
1166
+ if pent is not None and _ctype(pent) == "HERO_POWER" and pent["zone"] == "PLAY":
1167
+ ev = _record(game, "power", eid=rid)
1168
+ blk["power_ev"] = ev
1169
+ pend = game["hp_armor_pend"].pop(rid, None)
1170
+ if pend:
1171
+ ev["armor"] = pend[0]
1172
+ if btype == "TRIGGER" and blk["kw"] == "START_OF_GAME_KEYWORD":
1173
+ blk["sog"] = {"src": blk["entity"], "created": []} # 开局触发: 块结束出汇总事件
1174
+ if btype == "DECK_ACTION" and game["gate"] and not game["grace"]:
1175
+ # 手牌预备动作块 (预备/囚禁类手牌操作): 记块实体与行动方, 块结束出事件
1176
+ bid = _ref_id(blk["entity"] or "")
1177
+ bent = game["entities"].get(bid) if bid is not None else None
1178
+ ctl = bent["controller"] if bent is not None else None
1179
+ pel = next((p for p in _players(game) if p["controller"] == ctl), None) if ctl is not None else None
1180
+ blk["deck_act"] = {"eid": bid, "ctl": ctl,
1181
+ "pre_res": _tag_int(pel, "RESOURCES_USED", -1) if pel is not None else -1,
1182
+ "pel_id": pel["id"] if pel is not None else None,
1183
+ "prepare": False, "created": 0, "tagsum": []}
1184
+ game["blocks"].append(blk)
1185
+ if btype == "ATTACK" and game["gate"]:
1186
+ atk_ref = ent_m.group(1) if ent_m else ""
1187
+ tgt_ref = tgt_m.group(1) if tgt_m else ""
1188
+ ev = _record(game, "attack", atk_ref=atk_ref, tgt_ref=tgt_ref,
1189
+ atk_stat=_stat_snap(game, _ref_id(atk_ref)),
1190
+ tgt_stat=_stat_snap(game, _ref_id(tgt_ref)))
1191
+ blk["atk_ev"] = ev # Target=0 的脚本攻击: 块内 PROPOSED_DEFENDER/受击伤害反解目标
1192
+ return
1193
+ if payload[:9] == "BLOCK_END" and game["blocks"]:
1194
+ dn_ctx = _summon_ctx(game) if "deck_new" in game["blocks"][-1] else None
1195
+ blk = game["blocks"].pop()
1196
+ game["cur"] = None
1197
+ if blk["type"] == "ATTACK":
1198
+ ev = blk.get("atk_ev")
1199
+ dt = blk.get("dmg_tgt")
1200
+ if ev is not None and dt is not None and ev.get("tgt_ref") in (None, "0", "-1"):
1201
+ ev["tgt_ref"] = str(dt)
1202
+ game["pending_stats"].append({"ev": ev, "depth": len(game["blocks"]), "key": "tgt_stat", "eid": dt})
1203
+ if "deck_act" in blk:
1204
+ _finish_deck_action(game, blk)
1205
+ _settle_stats(game)
1206
+ if "sog" in blk:
1207
+ _finish_sog(game, blk)
1208
+ if blk.get("deck_new"):
1209
+ _finish_deck_new(game, blk, dn_ctx)
1210
+ for k in [e for e, v in game["reveal_watch"].items() if v["depth"] > len(game["blocks"])]:
1211
+ _finalize_reveal(game, k) # 归属块已关闭: 藏回的撤回, 存活的亮牌事件定稿
1212
+ return
1213
+ # META_DATA 等: 最终状态重放不需要, 忽略
1214
+
1215
+
1216
+ def parse_power_log(lines):
1217
+ """从最后一个 CREATE_GAME 起重放 packet 流 -> (game|None, 起始行, 总行数)"""
1218
+ game, start_line, total = None, 0, 0
1219
+ for lineno, raw in enumerate(lines, 1):
1220
+ total = lineno
1221
+ # 首行 \ufeff 前缀 (带 BOM 文件/PowerShell 管道喂 stdin) 会让 ^D 锚点的
1222
+ # CREATE_GAME/元信息正则失配, 致 --log 与 --stdin 解析结果不一致, 统一剥掉
1223
+ line = raw.lstrip("\ufeff").rstrip("\r\n")
1224
+ tm = TRUNCATE_RE.search(line) # 日志超限截断标记 (裸行): 出现在最后一个 CREATE_GAME 之后才算本局截断
1225
+ if tm:
1226
+ if game is not None:
1227
+ game["truncated"], game["trunc_kb"] = True, _num(tm.group(1))
1228
+ continue
1229
+ nm = PLAYER_NAME_RE.match(line) # DebugPrintGame 玩家名行 (无 Power 前缀)
1230
+ if nm:
1231
+ if game is not None:
1232
+ pid, pname = int(nm.group(1)), nm.group(2).strip()
1233
+ game["player_names"][pid] = pname
1234
+ for p in _players(game): # 名字迟到时补绑: 已占位的名字引用重定向到真身
1235
+ if _tag_int(p, "PLAYER_ID") == pid:
1236
+ p["name"] = p["name"] or pname
1237
+ game["names"][pname] = p["id"]
1238
+ break
1239
+ continue
1240
+ mm = GAME_META_RE.match(line) # DebugPrintGame 元信息行: 对局类型/赛制/构建号, 每局一份
1241
+ if mm:
1242
+ if game is not None:
1243
+ game["meta"][mm.group(1)] = mm.group(2)
1244
+ continue
1245
+ if "GameState.DebugPrintEntityChoices() -" in line:
1246
+ # 对局中 GENERAL 选择列选项 (发现/灾变类): 记选项, 确认行到来出事件
1247
+ if game is not None:
1248
+ _handle_entity_choices(game, line)
1249
+ continue
1250
+ if "GameState.DebugPrintEntitiesChosen() -" in line:
1251
+ # 换牌期 = 保留选择 (后手的选择可能晚于先手 DONE 开闸, 故宽限到 grace 结束);
1252
+ # 对局中 = GENERAL 选择确认 -> 「选择:选项名」事件
1253
+ if game is not None:
1254
+ _handle_entities_chosen(game, line)
1255
+ continue
1256
+ m = PREFIX_RE.match(line)
1257
+ if not m:
1258
+ continue # 非 GameState.DebugPrintPower 行 (含 PowerTaskList 重复历史) 直接忽略
1259
+ payload = m.group(1)
1260
+ if payload[:1] in (" ", "\t"):
1261
+ stripped = payload.strip()
1262
+ t = TAG_LINE_RE.match(stripped)
1263
+ if t:
1264
+ if game and game["cur"] is not None:
1265
+ _apply_tag(game, game["cur"], t.group(1), t.group(2))
1266
+ continue
1267
+ # 新版日志块内 packet 也带缩进 (TAG_CHANGE/SHOW_ENTITY/GameEntity/Player...)
1268
+ if game is None:
1269
+ continue
1270
+ game["cur"] = None
1271
+ _handle_packet(game, stripped)
1272
+ continue
1273
+ if game:
1274
+ game["cur"] = None
1275
+ if CREATE_GAME_RE.match(payload):
1276
+ game, start_line = _new_game(), lineno
1277
+ continue
1278
+ if game is None:
1279
+ continue
1280
+ game["cur"] = None
1281
+ _handle_packet(game, payload)
1282
+ if game is not None:
1283
+ _flush_summon(game) # 日志截尾时兜底冲刷
1284
+ return game, start_line, total
1285
+
1286
+
1287
+ def _tag_int(ent, tag, default=0):
1288
+ n = _num(ent["tags"].get(tag, ""))
1289
+ return n if n is not None else default
1290
+
1291
+
1292
+ def _ctype(ent):
1293
+ return _norm(ent["tags"].get("CARDTYPE", ""), CTYPE_BY_NUM)
1294
+
1295
+
1296
+ def _players(game):
1297
+ ps = [e for e in game["entities"].values() if _ctype(e) == "PLAYER"]
1298
+ ps.sort(key=lambda e: _tag_int(e, "PLAYER_ID"))
1299
+ return ps
1300
+
1301
+
1302
+ def _game_entity(game):
1303
+ return next((e for e in game["entities"].values() if _ctype(e) == "GAME"), None)
1304
+
1305
+
1306
+ def _of_side(game, controller, zone, ctype):
1307
+ out = [e for e in game["entities"].values()
1308
+ if e["controller"] == controller and e["zone"] == zone and _ctype(e) == ctype]
1309
+ out.sort(key=lambda e: (e["zone_pos"], e["id"]))
1310
+ return out
1311
+
1312
+
1313
+ def _in_zone(game, controller, zone):
1314
+ return [e for e in game["entities"].values() if e["controller"] == controller and e["zone"] == zone]
1315
+
1316
+
1317
+ def _playstate(ent):
1318
+ return _norm(ent["tags"].get("PLAYSTATE", ""), PLAYSTATE_BY_NUM)
1319
+
1320
+
1321
+ def is_mulligan(game):
1322
+ """换牌阶段判定: 只看真实 Player 实体 (占位/匿名实体的残留 INPUT 不算)"""
1323
+ return any(_norm(p["tags"].get("MULLIGAN_STATE", ""), MULLIGAN_BY_NUM) == "INPUT"
1324
+ for p in _players(game))
1325
+
1326
+
1327
+ def is_game_over(game):
1328
+ return any(_playstate(p) in ("WON", "LOST", "TIED") for p in _players(game))
1329
+
1330
+
1331
+ def _is_quest_ent(ent):
1332
+ """任务类实体: SECRET 区里带 QUEST/SIDEQUEST tag 的 (任务链二段组件同样带 QUEST tag)"""
1333
+ return _tag_int(ent, "QUEST") == 1 or _tag_int(ent, "SIDEQUEST") == 1
1334
+
1335
+
1336
+ def _hero_dead(game, ctl):
1337
+ """该方英雄是否阵亡: 在场英雄伤害>=30 或血量归零; 或已无在场英雄且有英雄实体进坟场
1338
+ (英雄牌替换时旧英雄也会离开场面, 但会有新英雄顶上, 不能只看坟场)"""
1339
+ heroes = [e for e in game["entities"].values() if e["controller"] == ctl and _ctype(e) == "HERO"]
1340
+ if not heroes:
1341
+ return False
1342
+ live = [h for h in heroes if h["zone"] == "PLAY"]
1343
+ for h in live:
1344
+ base = _tag_int(h, "HEALTH") or h["peak_hp"]
1345
+ if _tag_int(h, "DAMAGE") >= 30 or (base > 0 and base - _tag_int(h, "DAMAGE") <= 0):
1346
+ return True
1347
+ return not live and any(h["zone"] == "GRAVEYARD" for h in heroes)
1348
+
1349
+
1350
+ def _endgame_line(game, me, opp):
1351
+ """终局结果行: 我方/对方视角的胜负 + 结束方式 (投降 > 斩杀 > 疲劳), 判不出方式只报胜负"""
1352
+ players = _players(game)
1353
+ states = {p["id"]: _playstate(p) for p in players}
1354
+ if any(s == "TIED" for s in states.values()):
1355
+ return "对局结束:平局"
1356
+ winner = next((p for p in players if states.get(p["id"]) == "WON"), None)
1357
+ my_state = states.get(me["id"], "") if me else ""
1358
+ opp_state = states.get(opp["id"], "") if opp else ""
1359
+ res = None
1360
+ if me:
1361
+ if my_state == "WON" or (my_state not in ("LOST", "CONCEDED") and opp_state in ("LOST", "CONCEDED")):
1362
+ res = "我方胜利"
1363
+ elif my_state in ("LOST", "CONCEDED") or opp_state == "WON":
1364
+ res = "我方失败"
1365
+ conceded = next((p for p in players if p.get("conceded")), None)
1366
+ reason = ""
1367
+ if conceded is not None:
1368
+ if me and conceded is me:
1369
+ reason = "我方投降"
1370
+ elif me and conceded is opp:
1371
+ reason = "对方投降"
1372
+ else:
1373
+ reason = f"{_player_name(conceded)} 投降"
1374
+ else:
1375
+ opp_dead = opp is not None and _hero_dead(game, opp["controller"])
1376
+ me_dead = me is not None and _hero_dead(game, me["controller"])
1377
+ if opp_dead and not me_dead:
1378
+ reason = "斩杀,对方英雄阵亡"
1379
+ elif me_dead and not opp_dead:
1380
+ reason = "我方英雄阵亡"
1381
+ elif me_dead and opp_dead:
1382
+ reason = "双方英雄阵亡"
1383
+ elif any(_tag_int(p, "FATIGUE") > 0 for p in players):
1384
+ reason = "疲劳"
1385
+ head = res or (f"{_player_name(winner)} 获胜" if winner else "")
1386
+ return ("对局结束:" + head if head else "对局结束") + (f"({reason})" if reason else "")
1387
+
1388
+
1389
+ def _order_labels(players):
1390
+ """先手/后手标注: FIRST_PLAYER=1 先手, 其余一方为后手(带硬币); 日志缺 tag 则不标"""
1391
+ first = next((p for p in players if _tag_int(p, "FIRST_PLAYER") == 1), None)
1392
+ out = {}
1393
+ if first is not None:
1394
+ out[first["id"]] = "[先手]"
1395
+ for p in players:
1396
+ if p is not first:
1397
+ out[p["id"]] = "[后手+硬币]"
1398
+ return out
1399
+
1400
+
1401
+ def _secret_count(game, ctl):
1402
+ """真奥秘数: SECRET 区实体去掉任务类 (任务会被引擎放进 SECRET 区, 计入会误导)"""
1403
+ return len([e for e in _in_zone(game, ctl, "SECRET") if not _is_quest_ent(e)])
1404
+
1405
+
1406
+ def _quest_lines(lookup, game, ctl):
1407
+ """任务槽: 该方 SECRET 区任务类实体逐行显示名称与进度 (QUEST_PROGRESS/QUEST_PROGRESS_TOTAL),
1408
+ 完成判定 = QUEST_COMPLETED tag 或 progress>=total; 无任务返回空列表"""
1409
+ rows = []
1410
+ quests = sorted((e for e in _in_zone(game, ctl, "SECRET") if _is_quest_ent(e)),
1411
+ key=lambda e: (e["zone_pos"], e["id"]))
1412
+ for q in quests:
1413
+ name = _card_name(lookup, q["cardId"], q)
1414
+ prog, total = _tag_int(q, "QUEST_PROGRESS"), _tag_int(q, "QUEST_PROGRESS_TOTAL")
1415
+ done = _tag_int(q, "QUEST_COMPLETED") == 1 or (total > 0 and prog >= total)
1416
+ prog_txt = f" {prog}/{total}" if total else (f" {prog}" if prog else "")
1417
+ rows.append(f"任务 {name}{prog_txt}(已完成)" if done else f"任务 {name}{prog_txt}")
1418
+ return rows
1419
+
1420
+
1421
+ def _quest_reward_hint(lookup, game, ev):
1422
+ """任务完成事件后追加奖励入手提示: 奖励实体通常是完成块内新建并进 HAND 的卡,
1423
+ 带 CREATOR/DISPLAYED_CREATOR=任务实体 tag, 按 creator 反查手牌; 兜底看紧邻的己方获得事件"""
1424
+ qid = ev.get("eid")
1425
+ if qid is None:
1426
+ return ""
1427
+ cands = [e for e in game["entities"].values()
1428
+ if e["zone"] == "HAND" and qid in (_tag_int(e, "CREATOR"), _tag_int(e, "DISPLAYED_CREATOR"))]
1429
+ if cands:
1430
+ nm = _ev_name(lookup, game, min(cands, key=lambda e: e["id"])["id"])
1431
+ if nm:
1432
+ return f"(奖励 {nm} 已入手)"
1433
+ return "(奖励已入手)"
1434
+ evs = game["events"]
1435
+ idx = next((i for i, e in enumerate(evs) if e is ev), None)
1436
+ if idx is not None:
1437
+ for j in range(idx - 1, max(idx - 4, -1), -1):
1438
+ prev = evs[j]
1439
+ if (prev["type"] == "gain" and prev.get("actor") == ev.get("actor")
1440
+ and prev.get("turn") == ev.get("turn")):
1441
+ return "(奖励已入手)"
1442
+ return ""
1443
+
1444
+
1445
+ def detect_me(game, player_arg=None):
1446
+ """我方判定: HAND 区卡牌 ID 持续可见 (cardId 非空) 的那一方。
1447
+ 返回 (我方 player entity|None, 对方 player entity|None, 是否无法判定)"""
1448
+ players = _players(game)
1449
+ if player_arg:
1450
+ for p in players:
1451
+ if p["name"] == player_arg or (p["name"] or "").split("#")[0] == player_arg:
1452
+ other = [x for x in players if x is not p]
1453
+ return p, (other[0] if other else None), False
1454
+ sys.exit(f"找不到玩家: {player_arg} (现有: {'、'.join(_player_name(p) for p in players)})")
1455
+ counts = {}
1456
+ for e in game["entities"].values():
1457
+ if e["zone"] == "HAND" and e["cardId"] and e["controller"]:
1458
+ counts[e["controller"]] = counts.get(e["controller"], 0) + 1
1459
+ if counts:
1460
+ best = max(counts, key=counts.get)
1461
+ if list(counts.values()).count(counts[best]) == 1:
1462
+ for p in players:
1463
+ if _tag_int(p, "PLAYER_ID") == best:
1464
+ other = [x for x in players if x is not p]
1465
+ return p, (other[0] if other else None), False
1466
+ return (None, None, True)
1467
+
1468
+
1469
+ _lower_idx_cache = None # (lookup 对象, 小写 cardId 索引) 惰性缓存, 变体兜底用
1470
+
1471
+
1472
+ def _db_card(lookup, card_id):
1473
+ """cardId -> 卡库条目 多级兜底: 精确 -> 全表小写 (大小写漂移) -> 皮肤/钻石/异画变体键
1474
+ 去后缀逐级回退 (END_030t2 -> END_030t -> END_030); 全部落空返回 None"""
1475
+ if not card_id or "UNKNOWN ENTITY" in card_id:
1476
+ return None
1477
+ c = lookup.get(card_id)
1478
+ if c is not None:
1479
+ return c
1480
+ global _lower_idx_cache
1481
+ if _lower_idx_cache is None or _lower_idx_cache[0] is not lookup:
1482
+ _lower_idx_cache = (lookup, {str(k).lower(): v for k, v in lookup.items() if k})
1483
+ low = card_id.lower()
1484
+ idx = _lower_idx_cache[1]
1485
+ c = idx.get(low)
1486
+ if c is not None:
1487
+ return c
1488
+ cid = low
1489
+ while len(cid) > 5: # 变体后缀 (t2/e/h 等) 逐级剥掉重试, 命中最近的基础卡; 留长度下限防误配短键
1490
+ cid = cid[:-1]
1491
+ c = lookup.get(cid) or idx.get(cid)
1492
+ if c is not None:
1493
+ return c
1494
+ return None
1495
+
1496
+
1497
+ def _card_name(lookup, card_id, ent=None):
1498
+ if not card_id:
1499
+ return "未知卡牌" if not (ent and ent.get("name")) else ent["name"]
1500
+ if "UNKNOWN ENTITY" in card_id: # 新版日志 token 占位文本, 不是真实 cardId
1501
+ return "未知随从"
1502
+ c = _db_card(lookup, card_id)
1503
+ if c and c.get("name"):
1504
+ return c["name"]
1505
+ if card_id in HERO_POWER_NAMES:
1506
+ return HERO_POWER_NAMES[card_id]
1507
+ if ent and ent.get("name"): # 日志方括号引用里的本地化名 (新卡/token 不在卡牌库时的兜底)
1508
+ return ent["name"]
1509
+ return card_id
1510
+
1511
+
1512
+ def _plain_text(html):
1513
+ """卡牌 HTML 描述 -> 纯文本 (去标签 + 取 @ 升级段第一段 + 占位符转X + 压缩空白)"""
1514
+ text = (html or "").replace("<b>@</b>", "X") # 段内动态数值占位 (@ 包在标签里, 区别于段分隔符)
1515
+ if "兆示" in text: # 兆示卡多级 text 用 "</i>1" 裸数字拼接各等级 (无 @ 分隔), 只取基础级避免重复句堆叠
1516
+ text = re.split(r"</i>1(?![0-9])", text)[0]
1517
+ text = text.split("@")[0] # 多阶段升级卡 text 用裸 @ 拼接多份, 取第一段
1518
+ text = re.sub(r"\{\d+\}", "X", text) # {0} 等动态数值占位符
1519
+ text = re.sub(r"\$[a-zA-Z](?=\d)", "", text) # $d2 等变量标记 ($字母+数字)
1520
+ text = text.replace("$", "").replace("#", "") # $/# 数值高亮标记, 游戏内渲染不显示
1521
+ return " ".join(re.sub(r"<[^>]+>", "", text).split())
1522
+
1523
+
1524
+ def _ev_desc(lookup, game, eid, card_id=None):
1525
+ """事件实体的卡牌效果描述 (去 HTML 全文, 不截断——每张牌全对局只挂一次, 长一点换来决策质量);
1526
+ 查不到/无描述返回空; card_id 显式传入时优先 (实体可能被任务链变形重建)"""
1527
+ ent = game["entities"].get(eid) if eid is not None else None
1528
+ cid = card_id or (ent["cardId"] if ent and ent["cardId"] else "")
1529
+ c = _db_card(lookup, cid) if cid else None
1530
+ return _plain_text((c or {}).get("text") or "")
1531
+
1532
+
1533
+ def _hero_ent(game, controller):
1534
+ """该方当前英雄实体: 玩家实体 HERO_ENTITY 指向优先 (变形后指向新英雄), 退场面英雄,
1535
+ 再退该方任意离场英雄 (终局斩杀后英雄进坟场/英雄牌变形后旧英雄进暂存区, 面板仍要显示)。
1536
+ HAND/DECK 里的英雄牌只是卡牌, 不算英雄本体。找不到返回 None"""
1537
+ for p in _players(game):
1538
+ if p["controller"] == controller:
1539
+ hid = _tag_int(p, "HERO_ENTITY")
1540
+ if hid > 0:
1541
+ h = game["entities"].get(hid)
1542
+ if h is not None and _ctype(h) == "HERO":
1543
+ return h
1544
+ break
1545
+ live = _of_side(game, controller, "PLAY", "HERO")
1546
+ if live:
1547
+ return live[0]
1548
+ gone = [e for e in game["entities"].values()
1549
+ if e["controller"] == controller and e["id"] > 0 and _ctype(e) == "HERO"
1550
+ and e["zone"] not in ("HAND", "DECK")]
1551
+ return gone[0] if gone else None
1552
+
1553
+
1554
+ def _ent_class(lookup, class_names, ent):
1555
+ """英雄实体 -> 职业中文: 查卡库 cardClass, 缺失再退日志自带 CLASS tag; 判不出返回空"""
1556
+ if not ent:
1557
+ return ""
1558
+ c = lookup.get(ent["cardId"])
1559
+ cls = (c.get("cardClass") if c else "") or str(ent["tags"].get("CLASS") or "")
1560
+ return class_names.get(cls, "") if cls else ""
1561
+
1562
+
1563
+ def _side_class(lookup, class_names, game, controller):
1564
+ """职业显示 = 初始英雄卡的职业: 变形/打出英雄牌只换英雄名不改职业 (如死亡之翼仍是战士)。
1565
+ 初始英雄锚点缺失时退当前英雄, 再判不出报未知职业"""
1566
+ fh = (game.get("first_hero") or {}).get(controller)
1567
+ for ent in ((game["entities"].get(fh) if fh is not None else None), _hero_ent(game, controller)):
1568
+ zh = _ent_class(lookup, class_names, ent)
1569
+ if zh:
1570
+ return zh
1571
+ return "未知职业"
1572
+
1573
+
1574
+ def _hero_line(lookup, game, controller):
1575
+ hero = _hero_ent(game, controller)
1576
+ if not hero:
1577
+ return "英雄:无"
1578
+ hp = max((_tag_int(hero, "HEALTH") or hero["peak_hp"]) - _tag_int(hero, "DAMAGE"), 0) # 当前血=HEALTH-DAMAGE, 阵亡不为负
1579
+ max_hp = _tag_int(hero, "MAX_HEALTH") or hero["peak_hp"] or hp
1580
+ armor = _tag_int(hero, "ARMOR")
1581
+ weapon = next(iter(_of_side(game, controller, "PLAY", "WEAPON")), None)
1582
+ power = next(iter(_of_side(game, controller, "PLAY", "HERO_POWER")), None)
1583
+ wname = _card_name(lookup, weapon["cardId"], weapon) if weapon else "无"
1584
+ pname = _card_name(lookup, power["cardId"], power) if power else "无"
1585
+ pstate = "已用" if power and _tag_int(power, "EXHAUSTED") == 1 else "未用"
1586
+ return f"英雄:{_card_name(lookup, hero["cardId"], hero)} 血 {hp}/{max_hp} 护甲 {armor} 武器 {wname} 技能 {pname}({pstate})"
1587
+
1588
+
1589
+ def _minion_tags(ent, cur_turn=0):
1590
+ tags = []
1591
+ for key, zh in MINION_TAGS:
1592
+ if _tag_int(ent, key) == 1:
1593
+ tags.append(f"[{zh}]")
1594
+ dor = _tag_int(ent, "DORMANT") # 休眠: 值=剩余唤醒回合数, 1 (或只标休眠无倒计时) 显示 [休眠]
1595
+ if dor >= 1:
1596
+ tags.append(f"[休眠{dor}]" if dor > 1 else "[休眠]")
1597
+ # 本回合上场: 按日志 JUST_PLAYED tag 实际当前值渲染 (引擎回合结束清 0, 不会残留);
1598
+ # 上场回合号与当前回合对账兜底 (个别召唤不带 JUST_PLAYED tag)。
1599
+ # 不能用 EXHAUSTED 判定: 登场疲劳/攻击后的 EXHAUSTED=1 会跨回合残留, 标记过期
1600
+ entered = ent.get("entered_turn")
1601
+ if _tag_int(ent, "JUST_PLAYED") == 1 or (cur_turn and entered == cur_turn):
1602
+ tags.append("[本回合上场]")
1603
+ if _tag_int(ent, "FROZEN") == 1:
1604
+ tags.append("[冻结]")
1605
+ return "".join(tags)
1606
+
1607
+
1608
+ def _board_rows(lookup, game, controller):
1609
+ rows = []
1610
+ g_ent = _game_entity(game)
1611
+ cur_turn = _tag_int(g_ent, "TURN") if g_ent else 0
1612
+ ents = _of_side(game, controller, "PLAY", "MINION") + _of_side(game, controller, "PLAY", "LOCATION")
1613
+ ents.sort(key=lambda e: (e["zone_pos"], e["id"])) # 地标占场面格子, 与随从按场上位置合并排序
1614
+ for i, m in enumerate(ents, 1):
1615
+ gold = " [金]" if _tag_int(m, "PREMIUM") >= 1 else ""
1616
+ if _ctype(m) == "LOCATION":
1617
+ dur = _tag_int(m, "DURABILITY") # 剩余耐久: 有 DURABILITY tag 用之, 否则按血量-已伤推算
1618
+ if not dur:
1619
+ dur = max((_tag_int(m, "HEALTH") or m["peak_hp"]) - _tag_int(m, "DAMAGE"), 0)
1620
+ rows.append(f" {i}. {_card_name(lookup, m['cardId'], m)}{gold} [地标 耐久{dur}]")
1621
+ continue
1622
+ hp = (_tag_int(m, "HEALTH") or m["peak_hp"]) - _tag_int(m, "DAMAGE")
1623
+ rows.append(f" {i}. {_card_name(lookup, m["cardId"], m)} {_tag_int(m, 'ATK')}/{hp}{gold} {_minion_tags(m, cur_turn)}".rstrip())
1624
+ return rows
1625
+
1626
+
1627
+ def _forge_prev_name(lookup, game, ent):
1628
+ """兆示 (Forge) 预览实体卡名: DISPLAY_ENTITY_ON_MOUSEOVER 指向的暂存区预览实体
1629
+ (打出/抽到兆示牌时引擎建的 3 个 PREMIUM 变体之一)。预览对我方不可见时无 cardId
1630
+ -> 返回 None, 调用方省略后缀 (不显示'兆示:未知')"""
1631
+ pid = _tag_int(ent, "DISPLAY_ENTITY_ON_MOUSEOVER") if ent else 0
1632
+ prev = game["entities"].get(pid) if pid > 0 else None
1633
+ cid = (prev["cardId"] if prev else "") or (prev.get("seen_cardid") if prev else "") or ""
1634
+ return _card_name(lookup, cid, prev) if cid else None
1635
+
1636
+
1637
+ def _hand_rows(lookup, game, controller):
1638
+ rows = []
1639
+ hand = sorted((e for e in _in_zone(game, controller, "HAND")), key=lambda e: (e["zone_pos"], e["id"]))
1640
+ for i, h in enumerate(hand, 1):
1641
+ c = lookup.get(h["cardId"])
1642
+ cost = _num(h["tags"].get("COST"))
1643
+ if cost is None:
1644
+ cost = c.get("cost") if c and c.get("cost") is not None else 0
1645
+ ctype = "任务" if _is_quest_ent(h) else TYPE_ZH.get(_ctype(h), _ctype(h) or "未知")
1646
+ body = f"{_card_name(lookup, h["cardId"], h)} {cost}费"
1647
+ if _ctype(h) == "MINION":
1648
+ body += f" {_tag_int(h, 'ATK')}/{(_tag_int(h, 'HEALTH') or h['peak_hp']) - _tag_int(h, 'DAMAGE')}"
1649
+ marks = ""
1650
+ if _tag_int(h, "POWERED_UP") == 1:
1651
+ marks += " [已强化]" # 条件触发强化已生效 (如牌库达标减费)
1652
+ if _tag_int(h, "LITERALLY_UNPLAYABLE") == 1:
1653
+ marks += " [不可打出]" # 当前条件不满足, 本回合无法使用
1654
+ if _tag_int(h, "PREMIUM") >= 1:
1655
+ marks += " [金]"
1656
+ forge = _forge_prev_name(lookup, game, h)
1657
+ if forge:
1658
+ marks += f" <兆示:{forge}>"
1659
+ rows.append(f" {i}. {body} {ctype}{marks}")
1660
+ return rows
1661
+
1662
+
1663
+ def _player_name(p):
1664
+ name = p["name"] or f"玩家{_tag_int(p, 'PLAYER_ID')}"
1665
+ return name.split("#")[0] # 战网名 鸵鸟居士#5869 -> 鸵鸟居士
1666
+
1667
+
1668
+ # ---------- 行动回顾渲染 ----------
1669
+
1670
+ def _ev_name(lookup, game, eid, fallback=""):
1671
+ """事件实体 -> 显示名: 优先最终 cardId 查中文卡名 (对手出牌等事后揭示也能取到);
1672
+ cardId 被藏回清空时退曾见名 (开局触发源/换掉的牌被引擎重新隐藏)"""
1673
+ ent = game["entities"].get(eid) if eid is not None else None
1674
+ if ent:
1675
+ if ent["cardId"]:
1676
+ return _card_name(lookup, ent["cardId"], ent)
1677
+ if ent.get("seen_cardid"):
1678
+ return _card_name(lookup, ent["seen_cardid"], ent)
1679
+ if ent["name"] and not ent["name"].startswith("UNKNOWN"):
1680
+ return ent["name"]
1681
+ return fallback
1682
+
1683
+
1684
+ def _ev_target(lookup, game, me, opp, ref):
1685
+ """实体引用 -> 目标名; 是某方英雄则显示 我方英雄/对方英雄"""
1686
+ if not ref:
1687
+ return None
1688
+ ref = ref.strip()
1689
+ # 0/-1 = 无目标; System.Collections... = 新版日志把内部类型串塞进 Target, 都不算目标
1690
+ if ref in ("0", "-1") or ref.startswith("System.") or "`1[" in ref:
1691
+ return None
1692
+ eid = _ref_id(ref)
1693
+ ent = game["entities"].get(eid) if eid is not None else None
1694
+ if ent and _ctype(ent) == "HERO":
1695
+ if me and ent["controller"] == me["controller"]:
1696
+ return "我方英雄"
1697
+ if opp and ent["controller"] == opp["controller"]:
1698
+ return "对方英雄"
1699
+ return _ev_name(lookup, game, eid) or "英雄"
1700
+ nm = _ev_name(lookup, game, eid, fallback=ref if ref[:1].isalpha() else "")
1701
+ return nm or "未知目标"
1702
+
1703
+
1704
+ def _ev_side(game, me, opp, actor):
1705
+ """行动方 controller -> 我方/对方; 判不出我方时退回玩家名"""
1706
+ if me and actor == me["controller"]:
1707
+ return "我方"
1708
+ if opp and actor == opp["controller"]:
1709
+ return "对方"
1710
+ for p in _players(game):
1711
+ if p["controller"] == actor:
1712
+ return _player_name(p)
1713
+ return "未知"
1714
+
1715
+
1716
+ def _fmt_stat(stat):
1717
+ """攻血快照 -> " a/b" 后缀; 无快照返回空"""
1718
+ return f" {stat[0]}/{stat[1]}" if stat else ""
1719
+
1720
+
1721
+ def _power_desc(lookup, game, eid, name):
1722
+ """英雄技能描述: 经典技能按名/ID 内置, 其余查全量库; 去掉原文自带的'英雄技能'引导词"""
1723
+ ent = game["entities"].get(eid)
1724
+ desc = (HERO_POWER_DESC_BY_NAME.get(name) or HERO_POWER_TEXTS.get((ent.get("cardId") if ent else "") or "")
1725
+ or _ev_desc(lookup, game, eid))
1726
+ if desc.startswith("英雄技能"): # 技能原文自带的引导词与行前缀重复
1727
+ desc = desc[len("英雄技能"):].strip()
1728
+ return desc
1729
+
1730
+
1731
+ def _ent_disp_name(lookup, game, me, opp, eid):
1732
+ """事件实体显示名: 英雄按阵营称 我方英雄/对方英雄, 其余查卡名"""
1733
+ ent = game["entities"].get(eid) if eid is not None else None
1734
+ if ent and _ctype(ent) == "HERO":
1735
+ if me and ent["controller"] == me["controller"]:
1736
+ return "我方英雄"
1737
+ if opp and ent["controller"] == opp["controller"]:
1738
+ return "对方英雄"
1739
+ return _ev_name(lookup, game, eid) or "英雄"
1740
+ return _ev_name(lookup, game, eid, "未知目标")
1741
+
1742
+
1743
+ def _heal_src_label(lookup, game, ev):
1744
+ """治疗来源描述: 英雄技能块=英雄技能 X, 触发/法术效果块=X效果; 判不出返回 None"""
1745
+ if ev.get("src_kind") == "heropower":
1746
+ nm = _ev_name(lookup, game, ev.get("src_id"), "") if ev.get("src_id") is not None else ""
1747
+ return f"英雄技能 {nm}".rstrip()
1748
+ return _src_label(lookup, game, ev)
1749
+
1750
+
1751
+ def _gold_mark(lookup, game, eid):
1752
+ """实体 PREMIUM>=1 (金/签名/钻石卡) -> [金] 标注"""
1753
+ ent = game["entities"].get(eid) if eid is not None else None
1754
+ return " [金]" if ent is not None and _tag_int(ent, "PREMIUM") >= 1 else ""
1755
+
1756
+
1757
+ def _single_event_text(lookup, game, me, opp, ev):
1758
+ eid = ev.get("eid")
1759
+ if ev["type"] == "play":
1760
+ name = _ev_name(lookup, game, eid, "未知卡牌")
1761
+ ct = ev.get("ctype")
1762
+ if ct == "HERO":
1763
+ return f"打出英雄牌 {name}"
1764
+ body = f"打出 {TYPE_ZH.get(ct, ct or '卡牌')}「{name}」{_gold_mark(lookup, game, eid)}"
1765
+ if ct == "MINION":
1766
+ body += _fmt_stat(ev.get("stat")) # 打出时刻快照 (最终态可能已被 buff/打伤)
1767
+ # 打出行稳定带卡牌关键词/描述 (抽牌时给过描述的是抽到的牌; 获得/召唤入手与库外来源的牌
1768
+ # 从未展示过, 缺失会让决策线索不稳定——如 失控龙蛙 扰魔+嘲讽)
1769
+ desc = _ev_desc(lookup, game, eid)
1770
+ if desc:
1771
+ body += f"<{desc}>"
1772
+ # 块显式 Target = 打出指向 (法术/武器/战吼选定的目标, 随从战吼同样回填, 块内新建实体也可被指向);
1773
+ # 随从指向未揭示实体 (如对方从自己手牌选牌) 时目标名不可知, 不渲染"未知目标"噪音
1774
+ tref = ev.get("target_ref")
1775
+ tgt = _ev_target(lookup, game, me, opp, tref)
1776
+ if tgt and not (ct == "MINION" and tgt == "未知目标"):
1777
+ body += f"→ {tgt}"
1778
+ tid = _ref_id(tref) if tref else None # 主目标已在 → 中显示, 影响类追加目标去重防双写
1779
+ tgts = [x for x in (_ev_target(lookup, game, me, opp, str(t))
1780
+ for t in ev.get("bc_tgts") or []
1781
+ if not (tid and tid > 0 and str(t) == str(tid))) if x]
1782
+ if tgts: # 战吼对其他实体的影响 (沉默/伤害/控制) -> 追加目标
1783
+ body += f" → {' / '.join(tgts)}"
1784
+ if ev.get("forge_pid"):
1785
+ # 兆示预览: 用打出时刻快照的指针 (引擎出牌块内已把 DISPLAY 清 0, 最终态查不到指向);
1786
+ # 预览无 cardId (对方的兆示不可见) 时不显示, 不报'未知'
1787
+ pent = game["entities"].get(ev["forge_pid"])
1788
+ cid = (pent["cardId"] if pent else "") or (pent.get("seen_cardid") if pent else "") or ""
1789
+ if cid:
1790
+ body += f"(兆示 {_card_name(lookup, cid, pent)})"
1791
+ return body
1792
+ if ev["type"] == "attack":
1793
+ ref = ev.get("atk_ref") or ""
1794
+ atk_id = _ref_id(ref)
1795
+ name = _ev_name(lookup, game, atk_id, fallback=ref if ref[:1].isalpha() else "") or "未知随从"
1796
+ tgt = _ev_target(lookup, game, me, opp, ev.get("tgt_ref"))
1797
+ # Target=0/-1 = 引擎记录的取消/无目标攻击块, 只显示攻击方, 不编造"未知目标"
1798
+ body = f"攻击:{name}{_fmt_stat(ev.get('atk_stat'))}"
1799
+ if not tgt:
1800
+ return body
1801
+ body = f"{body} → {tgt}{_fmt_stat(ev.get('tgt_stat'))}"
1802
+ tid = _ref_id(ev.get("tgt_ref") or "")
1803
+ tent = game["entities"].get(tid) if tid is not None else None
1804
+ if tent is not None and _tag_int(tent, "DORMANT") >= 1:
1805
+ body += "(休眠中)" # 攻击目标仍处休眠 (囚禁类效果唤醒前的异常窗口)
1806
+ amt = ev.get("dmg_amt")
1807
+ if amt and not (ev.get("atk_stat") and amt == ev["atk_stat"][0]):
1808
+ body += f"(伤害 {amt})" # 实际伤害与攻击力不符 (光环/临时增幅) 时标注
1809
+ return body
1810
+ if ev["type"] == "power":
1811
+ name = _ev_name(lookup, game, eid, "未知技能")
1812
+ body = f"英雄技能 {name}"
1813
+ armor = ev.get("armor")
1814
+ if armor: # 技能自带的护甲变化内联 (如 全副武装!(+2 护甲)), 不再单发护甲事件
1815
+ return f"{body}(+{armor} 护甲)"
1816
+ desc = _power_desc(lookup, game, eid, name)
1817
+ if desc:
1818
+ body += f"<{desc}>"
1819
+ return body
1820
+ if ev["type"] == "power_change":
1821
+ name = _ev_name(lookup, game, eid, "未知技能")
1822
+ body = f"技能变更 {name}"
1823
+ desc = _power_desc(lookup, game, eid, name)
1824
+ if desc:
1825
+ body += f"<{desc}>"
1826
+ return body
1827
+ if ev["type"] == "heal":
1828
+ name = _ent_disp_name(lookup, game, me, opp, eid)
1829
+ hp = ev.get("hp")
1830
+ src = _heal_src_label(lookup, game, ev)
1831
+ if src:
1832
+ return f"{src} 治疗 {name} 血{hp[0]}→{hp[1]}" if hp else f"{src} 治疗 {name}"
1833
+ return f"治疗:{name} 血{hp[0]}→{hp[1]}" if hp else f"治疗:{name}"
1834
+ if ev["type"] == "aura_cb":
1835
+ name = _ent_disp_name(lookup, game, me, opp, eid)
1836
+ vals = ev.get("vals")
1837
+ return f"光环移除:{name} 属性回调 {vals[0]}→{vals[1]}" if vals else f"光环移除:{name}"
1838
+ if ev["type"] == "armor":
1839
+ ent = game["entities"].get(eid)
1840
+ if ent and me and ent["controller"] == me["controller"]:
1841
+ name = "我方英雄"
1842
+ elif ent and opp and ent["controller"] == opp["controller"]:
1843
+ name = "对方英雄"
1844
+ else:
1845
+ name = _ev_name(lookup, game, eid, "未知英雄")
1846
+ gain = ev.get("gain")
1847
+ return f"获得护甲 {name} +{gain}" if gain is not None else f"获得护甲 {name}"
1848
+ if ev["type"] == "summon":
1849
+ name = _ev_name(lookup, game, eid) or "未知随从"
1850
+ body = f"召唤 {name}{_gold_mark(lookup, game, eid)}{_fmt_stat(ev.get('stat'))}"
1851
+ n = ev.get("count") or 1
1852
+ if n > 1:
1853
+ body += f" ×{n}" # 同回合同来源同名召唤合并 (食尸鬼潮防刷屏)
1854
+ src = _src_label(lookup, game, ev)
1855
+ if src:
1856
+ body += f"({src})"
1857
+ else:
1858
+ desc = _ev_desc(lookup, game, eid)
1859
+ if desc:
1860
+ body += f"<{desc}>"
1861
+ return body
1862
+ if ev["type"] == "bounce":
1863
+ return f"回手 {_ev_name(lookup, game, eid, '未知卡牌')}"
1864
+ if ev["type"] == "equip":
1865
+ return f"装备武器 {_ev_name(lookup, game, eid) or '未知武器'}"
1866
+ if ev["type"] == "death":
1867
+ body = f"死亡:{_ev_name(lookup, game, eid, '未知卡牌')}{_fmt_stat(ev.get('stat'))}"
1868
+ if ev.get("reborn"):
1869
+ rs = ev.get("reborn_stat")
1870
+ body += f"(复生,复活为 {rs[0]}/{rs[1]})" if rs else "(复生)"
1871
+ return body
1872
+ if ev["type"] == "burn":
1873
+ return f"手牌已满,烧掉 {_ev_name(lookup, game, eid, '未知卡牌')}"
1874
+ if ev["type"] == "eff_damage":
1875
+ src = _src_label(lookup, game, ev) or "效果"
1876
+ tent = game["entities"].get(ev.get("tgt_id"))
1877
+ if tent is not None and _ctype(tent) == "HERO":
1878
+ tnm = ("我方英雄" if me and tent["controller"] == me["controller"]
1879
+ else "对方英雄" if opp and tent["controller"] == opp["controller"] else "英雄")
1880
+ else:
1881
+ tnm = _ev_name(lookup, game, ev.get("tgt_id"), "未知目标")
1882
+ amount = ev.get("amount")
1883
+ body = f"{src} 对 {tnm} 造成 {amount if amount is not None else '?'} 点伤害"
1884
+ if ev.get("lethal"):
1885
+ body += "(致命)" # 目标当场血量归零 (斩杀链终点)
1886
+ return body
1887
+ if ev["type"] == "gain_src":
1888
+ src = _src_label(lookup, game, ev)
1889
+ tail = f"({src}获得)" if src else ""
1890
+ if opp and ev.get("actor") == opp["controller"]:
1891
+ return f"获得 1 张牌{tail}" # 对方手牌内容匿名, 口径同抽牌
1892
+ return f"获得 {_ev_name(lookup, game, eid, '未知卡牌')}{tail}"
1893
+ if ev["type"] == "secret_play":
1894
+ ent = game["entities"].get(eid)
1895
+ kind = "任务" if ent and _is_quest_ent(ent) else "奥秘" # SECRET 区含奥秘与任务, QUEST/SIDEQUEST tag 区分
1896
+ name = ev.get("name") or "" # 打出时刻的名字/cardId 快照 (实体可能被任务链变形重建)
1897
+ if not name and ev.get("known"):
1898
+ name = _card_name(lookup, ev.get("card_id") or "", None)
1899
+ if not name:
1900
+ return f"打出 {kind}" # 对方真奥秘匿名, 无名字也无描述
1901
+ body = f"打出 {kind}「{name}」"
1902
+ if not (me and ev.get("actor") == me["controller"]): # 我方打出不带描述(抽牌时给过), 对方任务公开可见故带
1903
+ desc = _ev_desc(lookup, game, eid, card_id=ev.get("card_id"))
1904
+ if desc:
1905
+ body += f"<{desc}>"
1906
+ return body
1907
+ if ev["type"] == "secret_trigger":
1908
+ ent = game["entities"].get(eid)
1909
+ kind = "任务" if ent and _is_quest_ent(ent) else "奥秘"
1910
+ name = _ev_name(lookup, game, eid, f"未知{kind}")
1911
+ body = f"{kind}{'完成' if kind == '任务' else '触发'} {name}"
1912
+ desc = _ev_desc(lookup, game, eid)
1913
+ if desc:
1914
+ body += f"<{desc}>"
1915
+ if kind == "任务":
1916
+ body += _quest_reward_hint(lookup, game, ev)
1917
+ return body
1918
+ if ev["type"] == "sog":
1919
+ name = _ev_name(lookup, game, ev.get("src"), "未知卡牌")
1920
+ parts = []
1921
+ by_ctl = {}
1922
+ for eid in ev.get("copies") or []:
1923
+ ent = game["entities"].get(eid)
1924
+ if ent:
1925
+ by_ctl.setdefault(ent["controller"], []).append(eid)
1926
+ for ctl in sorted((c for c in by_ctl if c is not None), key=lambda c: (c != (me["controller"] if me else None), c)):
1927
+ eids = by_ctl[ctl]
1928
+ nms = [nm for nm in (_ev_name(lookup, game, eid) for eid in eids) if nm]
1929
+ tail = f"({' / '.join(nms)})" if len(nms) == len(eids) else ("(卡名未知)" if not nms else f"({' / '.join(nms)} 等)")
1930
+ parts.append(f"复制 {len(eids)} 张洗入{_ev_side(game, me, opp, ctl)}牌库{tail}")
1931
+ pw = ev.get("power")
1932
+ if pw is not None:
1933
+ parts.append(f"替换英雄技能为 {_ev_name(lookup, game, pw, '未知技能')}")
1934
+ if not parts:
1935
+ return f"{name} 触发"
1936
+ return f"{name} 触发:{','.join(parts)}"
1937
+ if ev["type"] == "choose":
1938
+ eids = ev.get("eids") or []
1939
+ nms = [nm for nm in (_ev_name(lookup, game, e) for e in eids) if nm]
1940
+ if nms:
1941
+ return "选择:" + " / ".join(nms) + ("" if len(nms) == len(eids) else " 等")
1942
+ return f"选择 {len(eids)} 项" # 选项全程未揭示 (对方发现), 只报动作
1943
+ if ev["type"] == "shuffle":
1944
+ eids = ev.get("eids") or []
1945
+ n = ev.get("count") or len(eids)
1946
+ body = f"洗入牌库 {n} 张"
1947
+ src = _src_label(lookup, game, ev)
1948
+ if src:
1949
+ body += f"({src})"
1950
+ nms = [nm for nm in (_ev_name(lookup, game, e) for e in eids) if nm]
1951
+ if nms:
1952
+ body += ":" + " / ".join(nms) + ("" if len(nms) == len(eids) else " 等")
1953
+ return body
1954
+ if ev["type"] == "reveal":
1955
+ name = _ev_name(lookup, game, eid, "未知卡牌")
1956
+ src = _src_label(lookup, game, ev) or "触发"
1957
+ body = f"{src} 亮出 「{name}」"
1958
+ return body + ("(已施放)" if ev.get("cast") else "(仅亮出,未施放)")
1959
+ if ev["type"] == "fatigue":
1960
+ n = ev.get("count")
1961
+ return f"疲劳 第 {n} 次(英雄扣 {n} 血)" if n is not None else "疲劳"
1962
+ if ev["type"] == "deck_action":
1963
+ name = _ev_name(lookup, game, eid, "未知卡牌")
1964
+ spent = ev.get("spent")
1965
+ cut = _deck_cost_cut(lookup, game, eid)
1966
+ if ev.get("prepare"):
1967
+ # 行动方已在行前缀 [第 N 回合·我方/对方] 标明, 正文不重复
1968
+ body = (f"花费 {spent} 费 给 手牌中的{name} 预备" if spent is not None
1969
+ else f"给 手牌中的{name} 预备")
1970
+ else:
1971
+ body = f"对 手牌中的{name} 发动手牌动作"
1972
+ if ev.get("summary"):
1973
+ body += f"({ev['summary']})"
1974
+ if cut:
1975
+ body += f"(费用 {cut[0]}→{cut[1]})"
1976
+ return body
1977
+ if ev["type"] == "dormant":
1978
+ name = _ev_name(lookup, game, eid, "未知随从")
1979
+ src = _src_label(lookup, game, ev)
1980
+ return f"{src} 将 {name} [休眠]" if src else f"{name} 进入休眠"
1981
+ if ev["type"] == "awaken":
1982
+ return f"{_ev_name(lookup, game, eid, '未知随从')} 苏醒"
1983
+
1984
+
1985
+ def _event_lines(lookup, game, me, opp):
1986
+ """事件列表 -> [(回合, 行动方, 文本)]; 连续抽牌/弃牌/获得按段合并计数"""
1987
+ lines, evs, i = [], game["events"], 0
1988
+ opp_ctl = opp["controller"] if opp else None
1989
+ n = len(evs)
1990
+ while i < n:
1991
+ ev = evs[i]
1992
+ t, actor, et = ev["turn"], ev["actor"], ev["type"]
1993
+ if et in ("draw", "discard", "gain"):
1994
+ j, items, unknown = i, [], 0
1995
+ while (j < n and evs[j]["type"] == et and evs[j]["turn"] == t and evs[j]["actor"] == actor):
1996
+ eid_j = evs[j].get("eid")
1997
+ nm = _ev_name(lookup, game, eid_j)
1998
+ if nm:
1999
+ items.append((nm, eid_j))
2000
+ else:
2001
+ unknown += 1
2002
+ j += 1
2003
+ if et == "draw":
2004
+ if opp_ctl is not None and actor == opp_ctl:
2005
+ # 对方抽牌一律匿名: 对方手牌内容本就不可知, 事后打出揭示的 cardId 不回填到抽牌事件
2006
+ lines.append((t, actor, f"抽牌 {len(items) + unknown} 张"))
2007
+ elif len(items) == 1 and not unknown:
2008
+ nm, eid_j = items[0]
2009
+ desc = _ev_desc(lookup, game, eid_j)
2010
+ lines.append((t, actor, f"抽牌 {nm}<{desc}>" if desc else f"抽牌 {nm}"))
2011
+ else:
2012
+ body = f"抽牌 {len(items) + unknown} 张"
2013
+ if items:
2014
+ parts = []
2015
+ for nm, eid_j in items:
2016
+ desc = _ev_desc(lookup, game, eid_j)
2017
+ parts.append(f"{nm}<{desc}>" if desc else nm)
2018
+ body += f"({' / '.join(parts)})"
2019
+ lines.append((t, actor, body))
2020
+ else:
2021
+ verb = "弃牌" if et == "discard" else "获得"
2022
+ for nm, _ in items:
2023
+ lines.append((t, actor, f"{verb}:{nm}" if et == "discard" else f"{verb} {nm}"))
2024
+ if unknown:
2025
+ lines.append((t, actor, f"{verb} {unknown} 张牌" if et == "gain" else f"{verb} {unknown} 张"))
2026
+ i = j
2027
+ continue
2028
+ lines.append((t, actor, _single_event_text(lookup, game, me, opp, ev)))
2029
+ i += 1
2030
+ return lines
2031
+
2032
+
2033
+ def _opening_lines(lookup, game, me, opp):
2034
+ """开局结构化行: 起手 N 张 -> 保留 X 换掉 A、B(换入 C、D)-> 对方保留 M 换 K 张 -> 后手获得幸运币。
2035
+ 我方带卡名, 对方只报张数; 无换牌动作不输出换行; 已是完整的 [开局·xx] 行"""
2036
+ op = game["opening"]
2037
+ out = []
2038
+
2039
+ def names_of(eids):
2040
+ nms = []
2041
+ for eid in eids:
2042
+ nm = _ev_name(lookup, game, eid)
2043
+ if not nm:
2044
+ return None
2045
+ nms.append(nm)
2046
+ return nms
2047
+
2048
+ for p, mine in ((me, True), (opp, False)):
2049
+ if not p:
2050
+ continue
2051
+ ctl = p["controller"]
2052
+ dealt = op["dealt"].get(ctl) or []
2053
+ if not dealt:
2054
+ continue
2055
+ if mine:
2056
+ nms = names_of(dealt)
2057
+ out.append(f"[开局·我方] 起手 {len(dealt)} 张" + (f"({' / '.join(nms)})" if nms else ""))
2058
+ kept_raw = op["kept"].get(ctl)
2059
+ if kept_raw is None:
2060
+ continue # 未提交保留选择 (秒投/异常局): 换掉无从谈起, 不输出换行
2061
+ out_e = [e for e in dealt if e not in set(kept_raw)] # 换掉 = 起手中未保留的
2062
+ in_e = op["mull_in"].get(ctl) or []
2063
+ if not out_e:
2064
+ continue # 全保留: 不输出换行
2065
+ if mine:
2066
+ out_n = names_of(out_e)
2067
+ body = f"[开局·我方] 保留 {len(dealt) - len(out_e)} 张换掉 "
2068
+ body += " / ".join(out_n) if out_n else f"{len(out_e)} 张"
2069
+ if in_e:
2070
+ in_n = names_of(in_e)
2071
+ body += f"(换入 {' / '.join(in_n)})" if in_n else f"(换入 {len(in_e)} 张)"
2072
+ out.append(body)
2073
+ else:
2074
+ out.append(f"[开局·对方] 保留 {len(dealt) - len(out_e)} 换 {len(out_e)} 张")
2075
+ # 后手获得幸运币: 按 FIRST_PLAYER 推 (硬币有换肤 variant, cardId 不可靠; 与 [后手+硬币] 标记同源)
2076
+ first = next((p for p in _players(game) if _tag_int(p, "FIRST_PLAYER") == 1), None)
2077
+ if first is not None and len(_players(game)) > 1:
2078
+ out.append("[开局·后手] 获得幸运币")
2079
+ return out
2080
+
2081
+
2082
+ def _events_section(lookup, game, me, opp, turns=3, mulligan=False):
2083
+ """行动回顾: 开局结构化行 (起手/换牌/幸运币) + 事件行 (开局触发等), 按回合边界自动带最近
2084
+ turns 个回合 (我方上回合全部+对方上回合全部+我方本回合已发生), 不按条数截断"""
2085
+ lines = _event_lines(lookup, game, me, opp)
2086
+ out = ["=== 行动回顾 ==="]
2087
+ rendered = [(0, s) for s in _opening_lines(lookup, game, me, opp)]
2088
+ rendered += [(t, f"[{'开局' if t == 0 else f'第 {t} 回合'}·{_ev_side(game, me, opp, actor)}] {txt}")
2089
+ for t, actor, txt in lines]
2090
+ if not rendered:
2091
+ out.append("(暂无行动记录)")
2092
+ return out
2093
+ cur_t = game["turn"]
2094
+ if turns and cur_t:
2095
+ floor = cur_t - (turns - 1)
2096
+ rendered = [x for x in rendered if floor <= x[0] <= cur_t]
2097
+ if not rendered:
2098
+ out.append("(近期无行动记录)")
2099
+ return out
2100
+ out += [s for _, s in rendered]
2101
+ cur_actor = game["actor"]
2102
+ if cur_actor is not None and not mulligan and not any(t == cur_t and a == cur_actor for t, a, _ in lines):
2103
+ out.append(f"[第 {cur_t} 回合·{_ev_side(game, me, opp, cur_actor)}] (尚未行动或无动作)")
2104
+ return out
2105
+
2106
+
2107
+ def _mana_text(ent, mulligan):
2108
+ """玩家实体的法力数值段: 可用/总(已用); 过载追加简洁标注
2109
+ (OVERLOAD_LOCKED=当前已被锁, OVERLOAD_OWED=下回合将锁, 都为 0 不显示)"""
2110
+ if mulligan:
2111
+ return "未开始"
2112
+ res = _tag_int(ent, "RESOURCES")
2113
+ used = _tag_int(ent, "RESOURCES_USED")
2114
+ temp = _tag_int(ent, "TEMP_RESOURCES")
2115
+ txt = f"{res - used + temp}/{res}(已用 {used})"
2116
+ locked, owed = _tag_int(ent, "OVERLOAD_LOCKED"), _tag_int(ent, "OVERLOAD_OWED")
2117
+ if locked or owed:
2118
+ txt += f" 过载{locked}" if locked else " 过载"
2119
+ if owed:
2120
+ txt += f"(下回合+{owed})"
2121
+ return txt
2122
+
2123
+
2124
+ def _mana_line(game, me, mulligan):
2125
+ if not me:
2126
+ return ""
2127
+ return f"我的法力 {_mana_text(me, mulligan)}"
2128
+
2129
+
2130
+ def render_panel(game, start_line, total, lookup, class_names, player_arg=None, events_turns=3):
2131
+ out = ["=== 炉石对局面板 ==="]
2132
+ if game is None or not game["entities"]:
2133
+ out.append("(尚未开始对局或日志为空)")
2134
+ out.append(f"# 实体总数 0 | 解析起始行 {start_line} | 日志总行 {total}")
2135
+ return "\n".join(out)
2136
+
2137
+ players = _players(game)
2138
+ me, opp, unknown = detect_me(game, player_arg)
2139
+ mulligan = is_mulligan(game)
2140
+ sides = [(opp, "对方"), (me, "我方")] if me else [(p, None) for p in players]
2141
+ sides = [(p, lab) for p, lab in sides if p]
2142
+
2143
+ # 对局元信息: 类型·赛制 | 构建号 (日志无元信息则整行省略)
2144
+ meta = game.get("meta") or {}
2145
+ gt, ft = meta.get("GameType", ""), meta.get("FormatType", "")
2146
+ meta_bits = []
2147
+ mode = GAME_TYPE_ZH.get(gt, gt)
2148
+ fmt = FORMAT_TYPE_ZH.get(ft, ft)
2149
+ if mode and fmt:
2150
+ meta_bits.append(f"{mode}·{fmt}")
2151
+ elif mode or fmt:
2152
+ meta_bits.append(mode or fmt)
2153
+ if meta.get("BuildNumber"):
2154
+ meta_bits.append(f"构建号 {meta['BuildNumber']}")
2155
+ if meta_bits:
2156
+ out.append(" | ".join(meta_bits))
2157
+
2158
+ # 日志超限截断: 终局 packet 可能缺失, 不再输出胜负判定行避免误判
2159
+ if game.get("truncated"):
2160
+ kb = game.get("trunc_kb")
2161
+ out.append("【日志已截断" + (f"({kb}KB 上限)" if kb else "") + ",终局信息可能缺失】")
2162
+ elif is_game_over(game):
2163
+ out.append("【对局已结束】")
2164
+ out.append(_endgame_line(game, me, opp))
2165
+ if mulligan:
2166
+ out.append("【换牌阶段】手牌已亮出,可给留牌建议")
2167
+ if unknown and not player_arg:
2168
+ out.append("[无法确定我方,可加 --player 参数]")
2169
+
2170
+ cur = next((p for p in players if _tag_int(p, "CURRENT_PLAYER") == 1), None)
2171
+ g_ent = _game_entity(game)
2172
+ turn = _tag_int(g_ent, "TURN") if g_ent else 0
2173
+ if mulligan:
2174
+ owner = "换牌阶段"
2175
+ elif me and cur:
2176
+ owner = "我的回合" if cur is me else "对方回合"
2177
+ elif cur:
2178
+ owner = f"{_player_name(cur)} 的回合"
2179
+ else:
2180
+ owner = "未知"
2181
+ mana = _mana_line(game, me, mulligan)
2182
+ # 新版日志 GameEntity 的 TURN 是双方合计手数, 玩家自己的 TURN tag 才是"第 N 回合"
2183
+ if turn and me and _tag_int(me, "TURN"):
2184
+ head = f"总第 {turn} 手 | 我方第 {_tag_int(me, 'TURN')} 回合 | {owner}"
2185
+ else:
2186
+ head = f"回合 {turn} | {owner}"
2187
+ if mana:
2188
+ head += f" | {mana}"
2189
+ out.append(head)
2190
+
2191
+ order = _order_labels(players)
2192
+ for p, label in sides:
2193
+ ctl = p["controller"]
2194
+ pname = _player_name(p)
2195
+ name = label or pname
2196
+ cls = _side_class(lookup, class_names, game, ctl)
2197
+ fatigue = _tag_int(p, "FATIGUE")
2198
+ corpses = _tag_int(p, "CORPSES") # 尸体数 (DK/亡灵体系): 挂在玩家实体上, 无此 tag 或 0 不显示
2199
+ secrets = _secret_count(game, ctl)
2200
+ deck = len(_in_zone(game, ctl, "DECK")) # 直接统计 DECK 区实体数, 复制牌导致的超编也如实反映
2201
+ order_tag = order.get(p["id"], "")
2202
+ hand_n = len(_in_zone(game, ctl, "HAND"))
2203
+ corpse_txt = f"尸体 {corpses} " if corpses > 0 else ""
2204
+ stat = (f"手牌 {hand_n} " if label != "我方" else "") + f"奥秘 {secrets} 牌库 {deck} "
2205
+ head_txt = f"{name}:{pname}({cls})" if label else f"{pname}({cls})"
2206
+ if order_tag:
2207
+ head_txt += f"{order_tag} "
2208
+ # 法力是公开信息: 对方 (及未知我方时的各方) 状态行补法力/过载段, 我方法力在面板头已有不重复
2209
+ mana_txt = "" if label == "我方" else f" 法力 {_mana_text(p, mulligan)}"
2210
+ out.append(f"{head_txt}{stat}{corpse_txt}疲劳 {fatigue}{mana_txt}" if label
2211
+ else f"{head_txt}手牌 {hand_n} 奥秘 {secrets} 牌库 {deck} {corpse_txt}疲劳 {fatigue}{mana_txt}")
2212
+ out.append(_hero_line(lookup, game, ctl))
2213
+ out.extend(_quest_lines(lookup, game, ctl))
2214
+ if not mulligan: # 换牌阶段双方场面输出为空
2215
+ board = _board_rows(lookup, game, ctl)
2216
+ out.append(f"{name}场面({len(board)}):")
2217
+ out.extend(board)
2218
+ if label == "我方":
2219
+ hand = _hand_rows(lookup, game, ctl)
2220
+ out.append(f"我方手牌({len(hand)}):")
2221
+ out.extend(hand)
2222
+
2223
+ if events_turns: # 行动回顾放在调试行前; 换牌阶段输出开局发牌供留牌建议
2224
+ out.extend(_events_section(lookup, game, me, opp, turns=events_turns, mulligan=mulligan))
2225
+
2226
+ out.append(f"# 实体总数 {len(game['entities'])} | 解析起始行 {start_line} | 日志总行 {total}")
2227
+ return "\n".join(out)
2228
+
2229
+
2230
+ def cmd_board(args):
2231
+ log_path, use_stdin, player, events_turns = None, False, None, 3
2232
+ for a in args:
2233
+ if a.startswith("--"):
2234
+ k, _, v = a[2:].partition("=")
2235
+ if k == "log":
2236
+ log_path = v
2237
+ elif k == "stdin":
2238
+ use_stdin = True
2239
+ elif k == "player":
2240
+ player = v
2241
+ elif k == "events":
2242
+ events_turns = _num(v)
2243
+ if events_turns is None or events_turns < 0:
2244
+ sys.exit("--events 需要非负整数 (默认 3 = 最近 3 个回合, N=0 完全不输出行动回顾)")
2245
+ else:
2246
+ sys.exit(f"未知选项: --{k}")
2247
+ else:
2248
+ sys.exit(f"board 不接受位置参数: {a}\n"
2249
+ "用法: board [--log=Power.log路径] [--stdin] [--player=玩家名] [--events=N]")
2250
+ if use_stdin:
2251
+ lines = sys.stdin.buffer.read().decode("utf-8", "replace").splitlines()
2252
+ else:
2253
+ p = Path(log_path) if log_path else find_latest_power()
2254
+ if not p or not p.is_file():
2255
+ sys.exit(f"未找到 Power.log (已扫描候选目录: {'; '.join(str(d) for d in DEFAULT_LOG_DIRS)})"
2256
+ f"\n可用 --log=路径 指定, 或 --stdin 从管道读入")
2257
+ lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
2258
+
2259
+ game, start_line, total = parse_power_log(lines)
2260
+ from hearthstone_cli.deck import CLASS_NAMES, load_db, load_full_db # 延迟导入避免循环依赖
2261
+ lookup = {c.get("id"): c for c in load_db()}
2262
+ for cid, c in load_full_db().items():
2263
+ # 全量库兜底 (英雄技能/token 等非 collectible): collectible 已有的条目不覆盖, 查卡顺序优先原库
2264
+ lookup.setdefault(cid, c)
2265
+ print(render_panel(game, start_line, total, lookup, CLASS_NAMES, player, events_turns=events_turns))
2266
+
2267
+