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.
- hearthstone_cli/__init__.py +3 -0
- hearthstone_cli/board.py +2267 -0
- hearthstone_cli/deck.py +964 -0
- hearthstone_cli/watch_worker.py +473 -0
- hearthstone_cli-0.1.0.dist-info/METADATA +234 -0
- hearthstone_cli-0.1.0.dist-info/RECORD +10 -0
- hearthstone_cli-0.1.0.dist-info/WHEEL +5 -0
- hearthstone_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hearthstone_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- hearthstone_cli-0.1.0.dist-info/top_level.txt +1 -0
hearthstone_cli/board.py
ADDED
|
@@ -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
|
+
|