test_pipeline_units.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. """流水线新增单元测试: SC52 等级解析。"""
  2. import json
  3. from pbot.codec.bitbuf import encode6
  4. from pbot.codec.frame import build_frame, parse_frame
  5. from pbot.world.mirror import WorldMirror
  6. from pbot.world.update import WorldUpdater
  7. def _sc52_frame(level: int, hp: int = 100) -> bytes:
  8. body = json.dumps({"job": 0, "level": level, "hp": hp,
  9. "maxhp": hp, "exp": 0, "maxexp": 100}).encode()
  10. return build_frame(52, 0, 0, 0, 0, encode6(body))
  11. def test_sc52_updates_level() -> None:
  12. w = WorldMirror()
  13. u = WorldUpdater()
  14. u.apply(w, parse_frame(_sc52_frame(35)))
  15. assert w.me.level == 35
  16. u.apply(w, parse_frame(_sc52_frame(36, hp=120)))
  17. assert w.me.level == 36
  18. assert w.me.hp == 120 # maxhp/hp 同步(若实现)或仅 level(见下)
  19. def test_sc52_level_zero_ignored() -> None:
  20. w = WorldMirror()
  21. u = WorldUpdater()
  22. u.apply(w, parse_frame(_sc52_frame(0)))
  23. assert w.me.level == 0 # 0 不产生误报
  24. def test_sc52_level_up_callback_fires_once() -> None:
  25. w = WorldMirror()
  26. u = WorldUpdater()
  27. calls: list[tuple[int, int]] = []
  28. w.on_level_up = lambda old, new: calls.append((old, new))
  29. u.apply(w, parse_frame(_sc52_frame(34)))
  30. u.apply(w, parse_frame(_sc52_frame(35))) # 升级 → 回调
  31. u.apply(w, parse_frame(_sc52_frame(35))) # 同级重推 → 不回调
  32. u.apply(w, parse_frame(_sc52_frame(34))) # 降级(异常帧) → 不回调
  33. assert calls == [(0, 34), (34, 35)]
  34. # ---- naming ----
  35. from pathlib import Path # noqa: E402
  36. from jiuyangfugu176.ctx import ChecklistWriter # noqa: E402
  37. from jiuyangfugu176.naming import random_cn_name, safe_filename # noqa: E402
  38. def test_random_cn_name_all_chinese() -> None:
  39. for _ in range(50):
  40. n = random_cn_name()
  41. assert 2 <= len(n) <= 8
  42. assert all("\u4e00" <= c <= "\u9fff" for c in n)
  43. def test_safe_filename() -> None:
  44. assert safe_filename("玖扬复古176.38区/新角色") == "玖扬复古176.38区_新角色"
  45. assert safe_filename('a\\b:*?"<>|') == "a_b"
  46. assert safe_filename(" ") == "unnamed"
  47. # ---- checklist ----
  48. def test_checklist_writer_marks(tmp_path: Path) -> None:
  49. w = ChecklistWriter(tmp_path / "进度.md", "玖扬复古176.38区", "凌风")
  50. w.mark("T1 喝药保护", True, "state=on")
  51. w.mark("T2 比奇杀怪", False, "超时")
  52. txt = (tmp_path / "进度.md").read_text(encoding="utf-8")
  53. assert "☑" in txt and "☒" in txt and "凌风" in txt
  54. # header 与 note 行在 mark 重写后仍保留
  55. w.note("登录成功")
  56. w.mark("T3 新增项", True)
  57. txt = (tmp_path / "进度.md").read_text(encoding="utf-8")
  58. assert "# 传奇流水线进度" in txt
  59. assert "> " in txt and "登录成功" in txt
  60. # 二次 mark 同名任务 = 更新而非重复追加
  61. w.mark("T2 比奇杀怪", True, "20/20")
  62. txt = (tmp_path / "进度.md").read_text(encoding="utf-8")
  63. assert txt.count("T2 比奇杀怪") == 1
  64. assert txt.count("凌风") >= 1
  65. def test_progress_checkpoint_roundtrip(tmp_path: Path) -> None:
  66. """断点打点: set_progress 写 md 注释, read_progress 取最新值(进程被杀续跑)。"""
  67. from jiuyangfugu176.ctx import read_progress
  68. f = tmp_path / "进度2.md"
  69. w = ChecklistWriter(f, "玖扬复古176.62区", "苍刻")
  70. assert read_progress(f, "kill") is None # 无打点 → None(首跑)
  71. w.set_progress("kill", 5)
  72. w.set_progress("kill", 18)
  73. assert read_progress(f, "kill") == 18 # 取最新
  74. w.set_progress("kill", 0) # 完成清理
  75. assert read_progress(f, "kill") == 0 # 0=已完成(调用方 or 0 → 从头)
  76. assert read_progress(f, "gold") is None # 其他 key 不受影响
  77. def test_checklist_resume_carries_marks(tmp_path: Path) -> None:
  78. """断线重跑续传: 新 writer 继承旧文件的勾选(get_mark 可查)。"""
  79. p = tmp_path / "进度.md"
  80. w1 = ChecklistWriter(p, "区A", "角色X")
  81. w1.mark("T1 开启喝药保护", True, "state=on")
  82. w1.mark("T3 比奇大城杀怪20", True, "比奇击杀 20/20")
  83. w2 = ChecklistWriter(p, "区A", "角色X") # 同文件重开 = 续跑
  84. assert w2.get_mark("T1 开启喝药保护") == (True, "state=on")
  85. assert w2.get_mark("T3 比奇大城杀怪20") == (True, "比奇击杀 20/20")
  86. assert w2.get_mark("T9 沃玛寺庙挂机") is None # 未做过的无记录
  87. txt = p.read_text(encoding="utf-8")
  88. assert "续跑" in txt and "2 项勾选" in txt
  89. # 新勾不丢旧勾
  90. w2.mark("T9 沃玛寺庙挂机", True, "100杀")
  91. txt = p.read_text(encoding="utf-8")
  92. assert "T3 比奇大城杀怪20" in txt and "T9 沃玛寺庙挂机" in txt
  93. def test_checklist_resume_ignores_failed_marks(tmp_path: Path) -> None:
  94. """☒(失败)任务不得被续跑跳过 — 51区 17:13 实证: T10 失败记录被
  95. 误当已勾跳过, 修复后 ☒ 不继承."""
  96. p = tmp_path / "进度.md"
  97. w1 = ChecklistWriter(p, "区A", "角色X")
  98. w1.mark("T8 开启五行之力", True, "面板已消费")
  99. w1.mark("T10 回盟重土城在线", False, "未落地盟重(当前 沃玛寺庙一层)")
  100. w2 = ChecklistWriter(p, "区A", "角色X")
  101. assert w2.get_mark("T8 开启五行之力") == (True, "面板已消费")
  102. assert w2.get_mark("T10 回盟重土城在线") is None, "失败记录不得继承"
  103. # ---- zone_ports 新网关字段(2026-10-02 38区抓包定案) ----
  104. def test_zone_entry_parses_gateway_fields() -> None:
  105. from pbot.login.zone_ports import _parse_zone_entry
  106. entry = _parse_zone_entry({"d": 14606326, "p": 8021, "s": 1,
  107. "n": "裁决38区", "i": "gfgame.dhsf.xqhuyu.com",
  108. "x": 12000})
  109. assert entry == {"port": 8021, "state": 1, "name": "裁决38区",
  110. "gw_host": "gfgame.dhsf.xqhuyu.com", "gw_port": 12000}
  111. def test_zone_entry_gateway_fields_default_empty() -> None:
  112. from pbot.login.zone_ports import _parse_zone_entry
  113. entry = _parse_zone_entry({"d": 14606325, "p": 8081, "s": 1, "n": "裁决37区"})
  114. assert entry["gw_host"] == ""
  115. assert entry["gw_port"] == 0
  116. # ---- gateway host 覆盖(38区新网关架构) ----
  117. def test_gateway_connection_host_override() -> None:
  118. import socket
  119. from threading import Thread
  120. from pbot.transport.gateway import GatewayConnection
  121. from pbot.types import ZoneId
  122. def _accept() -> None:
  123. srv.accept()[0].close()
  124. srv = socket.socket()
  125. srv.bind(("127.0.0.1", 0))
  126. srv.listen(1)
  127. port = srv.getsockname()[1]
  128. t = Thread(target=_accept, daemon=True)
  129. t.start()
  130. conn = GatewayConnection(ZoneId(14606326), port, host="127.0.0.1")
  131. assert conn.host == "127.0.0.1"
  132. conn.close()
  133. srv.close()
  134. t.join(timeout=2)
  135. # ---- protocol_map_name: 洞穴移动帧 body(2026-10-01 洞内 392 帧静默根因回归) ----
  136. from pbot.game.messages import protocol_map_name # noqa: E402
  137. def test_protocol_map_name_cave_display_passthrough() -> None:
  138. """天然洞穴: 服务器 MapId=显示名 → 移动帧 body 必须用中文名(d011 是
  139. 2026-09-26 未验证假设, 真机 21040 帧定案任务图 body=显示名;
  140. 2026-10-01 实跑 d011 全静默实证)。"""
  141. assert protocol_map_name("天然洞穴一层", "天然洞穴一层") == "天然洞穴一层"
  142. assert protocol_map_name("天然洞穴", "天然洞穴") == "天然洞穴"
  143. def test_protocol_map_name_known_numeric_ids() -> None:
  144. """数字文件 ID(比奇=0/盟重=3)仍直传 — 与洞穴中文透传互不干扰。"""
  145. assert protocol_map_name("0", "比奇大城") == "0"
  146. assert protocol_map_name("3", "盟重土城") == "3"
  147. assert protocol_map_name("", "比奇矿区") == "比奇矿区"
  148. # ---- bootstrap 纯函数 ----
  149. from jiuyangfugu176.bootstrap import _checklist_fname, _zone_label # noqa: E402
  150. def test_zone_label() -> None:
  151. assert _zone_label("38区") == "玖扬复古176·38区"
  152. def test_checklist_fname() -> None:
  153. assert _checklist_fname("38区", "凌风") == "进度_玖扬复古176_38区_凌风.md"
  154. assert _checklist_fname('38/区:*?"x', "凌\\风") == "进度_玖扬复古176_38_区_x_凌_风.md"
  155. # ---- 抢怪避让: SC31 attacker≠我 → contested_t 打标, 我打 → 清标 ----
  156. import json as _json # noqa: E402
  157. from pbot.codec.bitbuf import encode6 as _encode6 # noqa: E402
  158. from pbot.codec.frame import Frame as _Frame # noqa: E402
  159. from pbot.world.actor import ActorKey as _ActorKey # noqa: E402
  160. from pbot.world.actor import Monster as _Monster # noqa: E402
  161. from pbot.world.mirror import WorldMirror as _WorldMirror # noqa: E402
  162. def _sc31(victim: str, attacker: str) -> _Frame:
  163. body = _json.dumps({"Hp": 10, "MaxHp": 20, "Damage": 5,
  164. "UserID": victim, "AttackUserID": attacker}).encode()
  165. return _Frame(msg_id=31, recog=0, p1=0, p2=0, p3=0, pre=0, tail=0,
  166. body=_encode6(body))
  167. def test_sc31_other_attacker_marks_contended() -> None:
  168. w = _new_world_with_monster()
  169. WorldUpdater().apply(w, _sc31("44444", "9999000179080001"))
  170. assert w.monsters[44444].contested_t > 0.0
  171. def test_sc31_my_hit_clears_contended() -> None:
  172. w = _new_world_with_monster()
  173. w.monsters[44444] = _Monster(key=_ActorKey(44444), x=5, y=5, race=0,
  174. name="鸡", is_dead=False, contested_t=123.0)
  175. WorldUpdater().apply(w, _sc31("44444", w.me.uid))
  176. assert w.monsters[44444].contested_t == 0.0
  177. def _new_world_with_monster() -> "_WorldMirror":
  178. from pbot.world.mirror import WorldMirror
  179. w = WorldMirror()
  180. w.me.uid = "14590001790847124000069434"
  181. w.monsters[44444] = _Monster(key=_ActorKey(44444), x=5, y=5, race=0,
  182. name="鸡", is_dead=False)
  183. return w