完善客户端功能并同步差距文档

This commit is contained in:
shenlei
2026-09-03 18:40:01 +09:00
parent f93a172296
commit 13ccb8d02d
135 changed files with 21881 additions and 1259 deletions
+261
View File
@@ -0,0 +1,261 @@
# 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
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}
# 结盟技 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 分类正确")