Files
mtgodot-poc/project/player_skill_test.gd
T
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

291 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# player_skill_test —— §3.8 修改 1player_skill.gd 技能三层合法性校验 headless 自检。
# godot --headless --path project --script player_skill_test.gd
#
# 逐层覆盖 ClickSkillSlot(a) / __CanUseSkill(b) / __CheckSkillUsable(c) / __UseSkill(d)
# 的每个 return 分支,断言返回 { ok, code, target_vid } 与 PythonPlayerSkill.cpp 一致。
extends SceneTree
const SkillTable = preload("res://ui/skill_table.gd")
const PlayerSkill = preload("res://player_skill.gd")
class FakeClient extends Node:
var main := 1000
var target := {"vid": 2000}
var entities := {1000: {"kind": 0}, 2000: {"kind": 2}}
var skills := [
{"id": 1, "level": 5, "master": 0},
{"id": 46, "level": 10, "master": 0},
{"id": 109, "level": 1, "master": 0},
]
var inv: Array = []
func get_main_vid() -> int: return main
func get_entity(v) -> Dictionary: return entities.get(int(v), {})
func get_target() -> Dictionary: return target
func get_skills() -> Array: return skills
func get_inventory() -> Array: return inv
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: player_skill_test (§3.8 三层校验)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
# 每次校验前把 gate 的上下文复位到「放行」缺省,避免用例互相污染。
func _reset(g: RefCounted) -> void:
g.observer_mode = false
g.mounting = false
g.in_safe = false
g.is_fishing = false
g.is_using_skill = false
g.can_act = true
g.private_shop_open = false
g.level_limit = true
g.cooltime_check = true
g.dash_active = false
g.weapon_sub_type = -1
g.arrow_count = -1
g.slot_cd_end = 0.0
g.cur_hp = -1
g.cur_sp = -1
g.bow_distance = 0
g.target_distance = Callable()
func _run() -> void:
var assets := AssetRoot.path()
var st := SkillTable.new()
var loaded := false
for lang in ["en", "common"]:
if st.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
loaded = true
break
if not loaded:
print(" (skip: no skilldesc.txt)")
return
var fc := FakeClient.new()
get_root().add_child(fc)
var g := PlayerSkill.new()
g.setup(fc, st)
# 常量对齐 §3.8 修改 1 硬编码
_ck(g.SKILL_MAX_NUM == 255, "SKILL_MAX_NUM == 255")
_ck(g.SKILL_TYPE_GUILD == 3, "SKILL_TYPE_GUILD == 3")
_ck(g.RIDING_SKILL_INDEX == 109, "RIDING_SKILL_INDEX == 109")
_ck(g.ITEM_EMPTY_BOTTLE == 27995 and g.ITEM_POISON_BOTTLE == 27996, "空瓶/毒瓶 vnum")
_ck(g.SKILL_INDEX_FISHING == 123 and g.SKILL_INDEX_COMBO == 122, "特殊技 vnum")
# ---------- (a) ClickSkillSlot ----------
_reset(g)
_ck(g.click_skill_slot(-1).code == "BAD_SLOT", "(a) 槽越界 -> BAD_SLOT")
_ck(g.click_skill_slot(999).code == "BAD_SLOT", "(a) 槽 >= SKILL_MAX_NUM -> BAD_SLOT")
# 表里查不到的 id
var missing := 0
for cand in range(240, 255):
if not st.has(cand):
missing = cand
break
if missing != 0:
_ck(g.click_skill_slot(missing).code == "NO_SKILL_DATA", "(a) 无技能数据 -> NO_SKILL_DATA")
# 公会技 151 -> 路由
if st.has(151) and st.is_guild_skill(151):
_ck(g.click_skill_slot(151).code == "GUILD_ROUTED", "(a) 公会技 -> GUILD_ROUTED")
# 被动技 121 -> 静默挡下
if st.has(121) and st.is_passive(121):
var r := g.click_skill_slot(121)
_ck(not r.ok and r.code == "PASSIVE", "(a) 被动技 -> PASSIVE")
# 站立 + 开关技,已激活 -> TOGGLE_OFF
if st.has(63) and st.is_standing(63) and st.is_toggle(63):
_ck(g.click_skill_slot(63, true).code == "TOGGLE_OFF", "(a) 开关技已激活 -> TOGGLE_OFF")
# ---------- (b) __CanUseSkill ----------
_reset(g)
_ck(g.can_use_skill().ok, "(b) 缺省上下文 -> ok")
_reset(g); g.observer_mode = true
_ck(g.can_use_skill().code == "OBSERVER", "(b) 观战 -> OBSERVER")
_reset(g); g.can_act = false
_ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 死亡/晕眩/击退 -> CANNOT_ACT")
_reset(g); g.mounting = true # 无骑乘术(skills 里 109 只有 level 1 / grade 0
_ck(g.can_use_skill().code == "NO_RIDING_SKILL", "(b) 骑乘缺骑乘术 -> NO_RIDING_SKILL")
_reset(g); g.mounting = true
fc.skills[2]["level"] = 20 # 骑乘术 109 练满
_ck(g.can_use_skill().ok, "(b) 骑乘术 lv20 -> ok")
fc.skills[2]["level"] = 1
# 无主角
_reset(g); fc.main = 0
_ck(g.can_use_skill().code == "CANNOT_ACT", "(b) 无主角 -> CANNOT_ACT")
fc.main = 1000
# ---------- (c) __CheckSkillUsable ----------
# skill 1 Three-Way CutATTACK_SKILL|NEED_TARGET,非骑乘技,需武器 SWORD|TWO_HANDED
_reset(g)
_ck(g.check_skill_usable(1).ok, "(c) skill 1 缺省 -> ok")
_reset(g); g.mounting = true
_ck(g.check_skill_usable(1).code == "NOT_HORSE_SKILL", "(c) 骑乘中放非骑乘技 -> NOT_HORSE_SKILL")
# skill 138 Horse StumpHORSE_SKILL
if st.has(138) and st.is_horse_skill(138):
_reset(g); g.level_limit = false
_ck(g.check_skill_usable(138).code == "HAVE_TO_RIDE", "(c) 未骑乘放骑乘技 -> HAVE_TO_RIDE")
_reset(g); g.mounting = true; g.level_limit = false
var r138 := g.check_skill_usable(138)
_ck(r138.ok, "(c) 骑乘放骑乘技 -> 放行")
_reset(g); g.in_safe = true
_ck(g.check_skill_usable(1).code == "IN_SAFE", "(c) 安全区放攻击技 -> IN_SAFE")
_reset(g); g.is_fishing = true
_ck(g.check_skill_usable(1).code == "REMOVE_FISHING_ROD", "(c) 钓鱼态 -> REMOVE_FISHING_ROD")
# 未学:表里有但 get_skills 没有
var unlearned := 0
for cand in [2, 3, 4, 5, 6, 7, 8, 9]:
if st.has(cand) and g._skill_level(cand) == 0 and not st.is_horse_skill(cand) \
and not st.is_passive(cand):
unlearned = cand
break
if unlearned != 0:
_reset(g)
_ck(g.check_skill_usable(unlearned).code == "NOT_YET_LEARN", "(c) 未学技能 -> NOT_YET_LEARN")
_reset(g); g.level_limit = false
_ck(g.check_skill_usable(unlearned).code != "NOT_YET_LEARN", "(c) 关等级限制 -> 不再挡")
# 武器种类不匹配:skill 46 需 BOW
if st.has(46) and st.has_weapon_limitation(46) and st.is_need_bow(46):
_reset(g); g.weapon_sub_type = st.WEAPON_SWORD
_ck(g.check_skill_usable(46).code == "NOT_MATCHABLE_WEAPON", "(c) 拿剑放弓技 -> NOT_MATCHABLE_WEAPON")
_reset(g); g.weapon_sub_type = st.WEAPON_BOW
_ck(g.check_skill_usable(46).ok, "(c) 拿弓放弓技 -> ok")
_reset(g); g.weapon_sub_type = st.WEAPON_BOW; g.arrow_count = 0
_ck(g.check_skill_usable(46).code == "EMPTY_ARROW", "(c) 弓技无箭 -> EMPTY_ARROW")
# 冷却
_reset(g); g.slot_cd_end = g._now() + 5.0
_ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) 冷却中 -> WAIT_COOLTIME")
_reset(g); g.slot_cd_end = g._now() + 5.0; g.cooltime_check = false
_ck(g.check_skill_usable(1).ok, "(c) m_sysIsCoolTime 关 -> 不查冷却")
# 冲刺 affect:非蓄力技仍查冷却
_reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true
_ck(g.check_skill_usable(1).code == "WAIT_COOLTIME", "(c) dash + 非蓄力 -> 仍查冷却")
# 蓄力技 5 Dash:冲刺 affect 下跳过冷却
if st.has(5) and st.is_charge_skill(5):
_reset(g); g.slot_cd_end = g._now() + 5.0; g.dash_active = true; g.level_limit = false
var r5 := g.check_skill_usable(5)
_ck(r5.ok or r5.code == "NOT_MATCHABLE_WEAPON", "(c) dash + 蓄力技 -> 跳过冷却")
# ---------- (c) §3.8 修改 3NeedSP / __CheckShortMana / __CheckShortLife ----------
# skill 1 samyeon.msk: NeedSPFormula "40 + (100 * SkillPoint)"_skill_point(5) = 12/100。
_ck(is_equal_approx(st._skill_point(20), 0.5), "_skill_point(20) = master 50/100")
_ck(is_equal_approx(st._skill_point(0), 0.0) and is_equal_approx(st._skill_point(50), 0.0),
"_skill_point 越界 -> 0")
if st.has(1) and String(st.entry(1).get("need_sp_formula", "")) != "":
_ck(st.need_sp(1, 5) == 52, "need_sp(skill 1, lv5) = 40 + 100*0.12 = 52")
_ck(st.need_hp(1, 5) == st.need_sp(1, 5), "need_hp 复用 GetNeedSP")
# __CheckShortManaSP 不足 -> NOT_ENOUGH_SP
_reset(g); g.cur_sp = 10
_ck(g.check_skill_usable(1).code == "NOT_ENOUGH_SP", "(c) SP 10 < 52 -> NOT_ENOUGH_SP")
_reset(g); g.cur_sp = 200
_ck(g.check_skill_usable(1).ok, "(c) SP 200 >= 52 -> ok")
_reset(g); g.cur_sp = -1
_ck(g.check_skill_usable(1).ok, "(c) SP 未知 -> 不判 NOT_ENOUGH_SP")
# 未知公式 -> need_sp = -1,不拦截
var no_sp := 0
for cand in st.for_job("WARRIOR"):
if st.has(cand) and String(st.entry(cand).get("need_sp_formula", "")) == "" \
and g._skill_level(cand) > 0 and not st.is_passive(cand) \
and not st.is_horse_skill(cand) and not st.is_toggle(cand):
no_sp = cand
break
if no_sp != 0:
_reset(g); g.cur_sp = 0
_ck(g.check_skill_usable(no_sp).code != "NOT_ENOUGH_SP",
"(c) 无 NeedSPFormula(%d) + SP 0 -> 不误挡" % no_sp)
# __CheckShortLifeskill 1 非 USE_HP -> 即便 HP 极低也不挡
_reset(g); g.cur_hp = 1
_ck(g.check_skill_usable(1).code != "NOT_ENOUGH_HP", "(c) 非 USE_HP 技 + HP 1 -> 不判 NOT_ENOUGH_HP")
# ---------- (d) __UseSkill ----------
_reset(g); g.private_shop_open = true
_ck(g.use_skill(1).code == "PRIVATE_SHOP", "(d) 私人商店 -> PRIVATE_SHOP")
_reset(g); g.is_using_skill = true
_ck(g.use_skill(1).code == "ALREADY_CASTING", "(d) 施法中 -> ALREADY_CASTING")
# 特殊技
_reset(g)
_ck(g.use_skill(g.SKILL_INDEX_FISHING).code == "SPECIAL_SKILL", "(d) 钓鱼技 -> SPECIAL_SKILL")
_ck(g.use_skill(g.SKILL_INDEX_COMBO).code == "SPECIAL_SKILL", "(d) 连击技 -> SPECIAL_SKILL")
# 目标解析:skill 1 需要目标
_reset(g)
if st.is_need_target(1):
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2}
var rt := g.use_skill(1)
_ck(rt.ok and rt.target_vid == 2000, "(d) 有可攻击目标 -> ok, target=2000")
fc.target = {"vid": 0}
_ck(g.use_skill(1).code == "NEED_TARGET", "(d) 无目标 -> NEED_TARGET")
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 2, "in_safe": true}
_ck(g.use_skill(1).code == "CANNOT_ATTACK_ENEMY_IN_SAFE_AREA",
"(d) 目标在安全区 -> CANNOT_ATTACK_ENEMY_IN_SAFE_AREA")
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC
_ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK")
fc.entities[2000] = {"kind": 2}
# __ProcessEnemySkillTargetRangedistance >= TargetRange(+弓距) -> 预约,不施法
var range1: int = st.target_range(1)
if range1 > 0:
var dist := {"cm": float(range1)}
g.target_distance = func(_vid: int) -> float: return float(dist.cm)
var far := g.use_skill(1)
_ck(not far.ok and far.code == "RESERVED" and far.target_vid == 2000 and far.range_cm == range1,
"(d) 目标恰在射程边界 -> RESERVED(range=%d): %s" % [range1, far])
_ck(PlayerSkill.is_silent_code("RESERVED"), "RESERVED 不弹 OnCannotUseSkill")
dist.cm = range1 - 1.0
_ck(g.use_skill(1).ok, "(d) 射程内 1cm -> ok")
g.bow_distance = 2
dist.cm = range1 + 150.0
_ck(g.use_skill(1).ok, "(d) POINT_BOW_DISTANCE*100 计入射程")
dist.cm = -1.0
_ck(g.use_skill(1).ok, "(d) 距离未知 -> 不预约")
_reset(g)
# 蓄力技在射程外要求调用方先发 use_skill(slot, 0)
for id in st._by_id.keys():
if st.is_charge_skill(int(id)) and st.target_range(int(id)) > 0 and st.is_need_target(int(id)):
fc.skills.append({"id": int(id), "level": 1, "master": 0})
g.target_distance = func(_vid: int) -> float: return 100000.0
var rc := g.use_skill(int(id))
_ck(rc.code != "RESERVED" or rc.charge, "(d) 蓄力技 %d 射程外 -> RESERVED charge=true: %s" % [id, rc])
fc.skills.pop_back()
_reset(g)
break
# 结盟技 109 CureCAN_USE_FOR_ME|ONLY_FOR_ALLIANCE
if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109):
_reset(g)
var needs_tgt: bool = st.is_need_target(109) or st.can_change_direction(109) \
or st.is_auto_search_target(109)
if needs_tgt:
fc.target = {"vid": 0}
var rself := g.use_skill(109)
_ck(rself.ok and rself.target_vid == fc.main,
"(d) 结盟技无目标 -> 回落自身 target=main")
fc.target = {"vid": 1000} # 自己
_ck(g.use_skill(109).ok, "(d) 结盟技对自己 -> ok")
fc.target = {"vid": 2000}
# 非目标类技能 -> 直接 ok, target 0
var no_target_skill := 0
for id in st.for_job("WARRIOR"):
if g._skill_level(id) > 0 and not st.is_need_target(id) \
and not st.can_change_direction(id) and not st.is_auto_search_target(id) \
and not st.is_passive(id) and not st.is_horse_skill(id):
no_target_skill = id
break
if no_target_skill != 0:
_reset(g)
var rnt := g.use_skill(no_target_skill)
_ck(rnt.ok and rnt.target_vid == 0 or rnt.code == "NOT_MATCHABLE_WEAPON",
"(d) 非目标技 %d -> ok/target 0" % no_target_skill)
# is_silent_code
_ck(PlayerSkill.is_silent_code("PASSIVE") and PlayerSkill.is_silent_code("TOGGLE_OFF")
and not PlayerSkill.is_silent_code("IN_SAFE"),
"is_silent_code 分类正确")