- 新增 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
291 lines
12 KiB
GDScript
291 lines
12 KiB
GDScript
# 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
|
||
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 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}
|
||
# __ProcessEnemySkillTargetRange:distance >= 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 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 分类正确")
|