# player_skill_test —— §3.8 修改 1:player_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 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 Cut:ATTACK_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 Stump:HORSE_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 修改 3:NeedSP / __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") # __CheckShortMana:SP 不足 -> 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) # __CheckShortLife:skill 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} # 结盟技 109 Cure:CAN_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 分类正确")