Files
mtgodot-poc/project/test_skill_inventory_parity.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

514 lines
20 KiB
GDScript

# test_skill_inventory_parity.gd
# Headless parity test suite for Phase 4:
# 1. Skill usability gating (CheckSkillUsable, cooltime, SP, weapon type, arrow, range)
# 2. Inventory & equipment wear/unwear/move flow and EquipRules gating
# 3. Character status window calculations, formatting, and /stat commands
# 4. Quickbar persistence (CG_QUICKSLOT_ADD/DEL/SWAP) and server restore
extends SceneTree
const PlayerSkill = preload("res://player_skill.gd")
const Quickbar = preload("res://ui/quickbar.gd")
const InventoryUI = preload("res://ui/inventory_ui.gd")
const CharStatusUI = preload("res://ui/char_status_ui.gd")
const EquipRules = preload("res://equip_rules.gd")
var _checks_passed := 0
var _checks_failed := 0
func _check(condition: bool, msg: String) -> void:
if condition:
_checks_passed += 1
print(" [PASS] %s" % msg)
else:
_checks_failed += 1
printerr(" [FAIL] %s" % msg)
push_error("FAIL: %s" % msg)
class MockClient extends Node:
signal points_changed(points: Dictionary)
signal inventory_changed(window: int, cell: int)
signal quickslots_changed()
signal entity_main_set(vid: int)
signal skill_group_changed(group: int)
var main_vid := 10001
var entities := {}
var target := {}
var points := {}
var skills := []
var skill_group := 1
var quickslots := []
var inventory := []
var equipment := []
var used_skills := []
var cast_skills := []
var used_items := []
var moved_items := []
var quickslot_adds := []
var quickslot_dels := []
var quickslot_swaps := []
var chat_messages := []
func _init() -> void:
for i in 90:
inventory.append({})
for i in 24:
equipment.append({})
for i in 36:
quickslots.append({"pos": i, "type": 0, "ref": 0})
func get_main_vid() -> int:
return main_vid
func get_entity(vid: int) -> Dictionary:
return entities.get(vid, {})
func get_target() -> Dictionary:
return target
func get_skills() -> Array:
return skills
func get_skill_group() -> int:
return skill_group
func get_points() -> Dictionary:
return points
func get_quickslots() -> Array:
return quickslots
func get_inventory() -> Array:
return inventory
func get_equipment() -> Array:
return equipment
func use_skill(skill_id: int, target_vid: int) -> bool:
used_skills.append({"skill_id": skill_id, "target_vid": target_vid})
return true
func cast_skill(motion_idx: int, rot_deg: float, x: int, y: int) -> bool:
cast_skills.append({"motion_idx": motion_idx, "rot_deg": rot_deg, "x": x, "y": y})
return true
func use_item(window: int, cell: int) -> bool:
used_items.append({"window": window, "cell": cell})
return true
func move_item(src_w: int, src_c: int, dst_w: int, dst_c: int, count: int) -> bool:
moved_items.append({"src_w": src_w, "src_c": src_c, "dst_w": dst_w, "dst_c": dst_c, "count": count})
return true
func quickslot_add(pos: int, type: int, ref: int) -> bool:
quickslot_adds.append({"pos": pos, "type": type, "ref": ref})
quickslots[pos] = {"pos": pos, "type": type, "ref": ref}
return true
func quickslot_del(pos: int) -> bool:
quickslot_dels.append(pos)
quickslots[pos] = {"pos": pos, "type": 0, "ref": 0}
return true
func quickslot_swap(pos: int, pos_to: int) -> bool:
quickslot_swaps.append({"from": pos, "to": pos_to})
var tmp: Dictionary = quickslots[pos]
quickslots[pos] = quickslots[pos_to]
quickslots[pos_to] = tmp
return true
func say(_type: int, text: String) -> bool:
chat_messages.append(text)
return true
class MockSkillTable extends RefCounted:
var skills_data := {
# Skill 1: 三连斩 (Warrior sword attack)
1: {
"is_attack": true, "is_can_use": true, "need_sp": 45, "target_range": 400,
"weapon_types": [0, 1], # SWORD, TWO_HAND
"is_need_bow": false, "is_toggle": false, "motion_idx": 1,
"is_horse_skill": false, "is_need_empty_bottle": false, "is_need_poison_bottle": false
},
# Skill 2: 旋风斩 (Warrior sword attack, fan range)
2: {
"is_attack": true, "is_can_use": true, "need_sp": 60, "target_range": 500,
"weapon_types": [0, 1],
"is_need_bow": false, "is_toggle": false, "motion_idx": 2,
"is_fan": true
},
# Skill 3: 狂战士 (Warrior buff, can use for self, no weapon restriction)
3: {
"is_attack": false, "is_can_use": true, "need_sp": 50, "target_range": 0,
"weapon_types": [],
"is_need_bow": false, "is_toggle": true, "can_use_for_me": true, "motion_idx": 3
},
# Skill 31: 穿透箭 (Ninja bow attack)
31: {
"is_attack": true, "is_can_use": true, "need_sp": 35, "target_range": 2500,
"weapon_types": [2], # BOW
"is_need_bow": true, "is_toggle": false, "motion_idx": 31
}
}
func has(id: int) -> bool: return skills_data.has(id)
func is_attack(id: int) -> bool: return skills_data.get(id, {}).get("is_attack", false)
func is_can_use_skill(id: int) -> bool: return skills_data.get(id, {}).get("is_can_use", true)
func is_standing(id: int) -> bool: return true
func is_toggle(id: int) -> bool: return skills_data.get(id, {}).get("is_toggle", false)
func is_horse_skill(id: int) -> bool: return skills_data.get(id, {}).get("is_horse_skill", false)
func is_need_bow(id: int) -> bool: return skills_data.get(id, {}).get("is_need_bow", false)
func is_need_empty_bottle(id: int) -> bool: return skills_data.get(id, {}).get("is_need_empty_bottle", false)
func is_need_poison_bottle(id: int) -> bool: return skills_data.get(id, {}).get("is_need_poison_bottle", false)
func can_use_weapon_type(id: int, wtype: int) -> bool:
var list: Array = skills_data.get(id, {}).get("weapon_types", [])
return list.is_empty() or list.has(wtype)
func need_sp(id: int, _lvl: int) -> int: return skills_data.get(id, {}).get("need_sp", -1)
func need_hp(_id: int, _lvl: int) -> int: return 0
func can_use_if_not_enough(_id: int) -> bool: return false
func target_range(id: int) -> int: return skills_data.get(id, {}).get("target_range", 0)
func can_use_for_me(id: int) -> bool: return skills_data.get(id, {}).get("can_use_for_me", false)
func is_auto_search_target(_id: int) -> bool: return true
func is_need_target(_id: int) -> bool: return false
func is_need_corpse(_id: int) -> bool: return false
func is_charge_skill(_id: int) -> bool: return false
func motion_idx_of(id: int) -> int: return skills_data.get(id, {}).get("motion_idx", 0)
func is_fan_range(id: int) -> bool: return skills_data.get(id, {}).get("is_fan", false)
func is_circle_range(_id: int) -> bool: return false
func target_count(_id: int, _lvl: int) -> int: return 3
func fly_shape(_id: int) -> int: return 0
func skill_type_of(_id: int) -> int: return 1
func is_use_hp(_id: int) -> bool: return false
func can_change_direction(_id: int) -> bool: return true
func is_only_for_alliance(_id: int) -> bool: return false
func name_of(id: int) -> String:
match id:
1: return "三连斩"
2: return "旋风斩"
3: return "狂战士"
31: return "穿透箭"
return "技能 %d" % id
func _init() -> void:
print("=== Running test_skill_inventory_parity (Phase 4 Parity) ===")
_test_skill_gating()
_test_inventory_equipment_flow()
_test_char_status_window()
_test_quickbar_persistence()
print("\n=======================================================")
if _checks_failed == 0:
print("=== ALL PHASE 4 PARITY TESTS PASSED (%d checks) ===" % _checks_passed)
quit(0)
else:
printerr("=== PHASE 4 PARITY TESTS FAILED: %d failed, %d passed ===" % [_checks_failed, _checks_passed])
quit(1)
# ==============================================================================
# 1. 技能施法三道门检 (Skill Usability Gating)
# ==============================================================================
func _test_skill_gating() -> void:
print("\n--- Test 1: Skill Usability & Gating (40250 CheckSkillUsable) ---")
var client := MockClient.new()
var table := MockSkillTable.new()
var gate := PlayerSkill.new()
gate.setup(client, table)
client.skills = [
{"id": 1, "level": 1, "master": 0},
{"id": 2, "level": 1, "master": 0},
{"id": 3, "level": 1, "master": 0},
{"id": 31, "level": 1, "master": 0}
]
client.entities[client.main_vid] = {"vid": client.main_vid, "kind": 0, "dead": false, "in_safe": false}
# 1.1 Dead player cannot act
gate.can_act = false
var res := gate.click_skill_slot(1)
_check(not res.ok and res.code == "CANNOT_ACT", "Dead or incapacitated player blocked (CANNOT_ACT)")
gate.can_act = true
# 1.2 Safe zone attack skill check
gate.in_safe = true
res = gate.click_skill_slot(1)
_check(not res.ok and res.code == "IN_SAFE", "Attack skill blocked in safe zone (IN_SAFE)")
res = gate.click_skill_slot(3)
_check(res.ok, "Buff skill allowed in safe zone")
gate.in_safe = false
# 1.3 Weapon type mismatch check
gate.weapon_sub_type = 2 # BOW
res = gate.check_skill_usable(1) # Requires SWORD (0) or TWO_HAND (1)
_check(not res.ok and res.code == "NOT_MATCHABLE_WEAPON", "Sword skill with Bow blocked (NOT_MATCHABLE_WEAPON)")
gate.weapon_sub_type = 0 # SWORD
res = gate.check_skill_usable(1)
_check(res.ok, "Sword skill with Sword allowed")
# 1.4 Arrow count check for bow skill
gate.weapon_sub_type = 2 # BOW
gate.arrow_count = 0
res = gate.check_skill_usable(31)
_check(not res.ok and res.code == "EMPTY_ARROW", "Bow skill without arrows blocked (EMPTY_ARROW)")
gate.arrow_count = 100
res = gate.check_skill_usable(31)
_check(res.ok, "Bow skill with arrows allowed")
gate.weapon_sub_type = 0
# 1.5 Insufficient SP check
gate.cur_sp = 20 # Skill 1 needs 45
res = gate.check_skill_usable(1)
_check(not res.ok and res.code == "NOT_ENOUGH_SP", "Skill with insufficient SP blocked (NOT_ENOUGH_SP)")
gate.cur_sp = 100
res = gate.check_skill_usable(1)
_check(res.ok, "Skill with sufficient SP allowed")
# 1.6 Cooltime gating
gate.slot_cd_end = Time.get_ticks_msec() / 1000.0 + 5.0 # 5s remaining
res = gate.check_skill_usable(1)
_check(not res.ok and res.code == "WAIT_COOLTIME", "Skill in cooldown blocked (WAIT_COOLTIME)")
gate.slot_cd_end = 0.0 # Expired
res = gate.check_skill_usable(1)
_check(res.ok, "Skill with expired cooldown allowed")
# 1.7 Target distance check
var enemy_vid := 20001
client.entities[enemy_vid] = {"vid": enemy_vid, "kind": 2, "dead": false, "in_safe": false}
client.target = {"vid": enemy_vid}
gate.target_distance = func(v: int) -> float: return 1000.0 if v == enemy_vid else -1.0 # 1000cm distance, skill range is 400cm
res = gate.use_skill(1)
# Target beyond range returns RESERVED for approach
_check(not res.ok and res.code == "RESERVED", "Target beyond skill range enters RESERVED state")
# Target in range (300cm < 400cm)
gate.target_distance = func(v: int) -> float: return 300.0 if v == enemy_vid else -1.0
res = gate.use_skill(1)
_check(res.ok and res.target_vid == enemy_vid, "Target within skill range allowed with target_vid")
client.queue_free()
# ==============================================================================
# 2. 背包与装备槽位映射与穿脱流 (Inventory & Equipment Flow)
# ==============================================================================
func _test_inventory_equipment_flow() -> void:
print("\n--- Test 2: Inventory & Equipment Flow (40250 ItemPos & Equip) ---")
var client := MockClient.new()
var inv := InventoryUI.new()
root.add_child(inv)
inv.setup(null, client, null, "")
# 2.1 Wire Coordinate Mapping
# Inventory cell 15 -> [WINDOW_INVENTORY (1), 15]
var w15 := inv._to_wire(15)
_check(w15[0] == 1 and w15[1] == 15, "Inventory slot 15 maps to [WINDOW_INVENTORY, 15]")
# Equipment cell 94 (WEAR_WEAPON = 4) -> [WINDOW_EQUIPMENT (2), 4]
var w_weapon := inv._to_wire(94)
_check(w_weapon[0] == 2 and w_weapon[1] == 4, "Equip slot 94 (WEAPON) maps to [WINDOW_EQUIPMENT, 4]")
# Equipment cell 90 (WEAR_BODY = 0) -> [WINDOW_EQUIPMENT (2), 0]
var w_body := inv._to_wire(90)
_check(w_body[0] == 2 and w_body[1] == 0, "Equip slot 90 (BODY) maps to [WINDOW_EQUIPMENT, 0]")
# New equipment cell 109 (Costume Body = wear 19) -> [WINDOW_INVENTORY, 109]
var w_costume := inv._to_wire(109)
_check(w_costume[0] == 1 and w_costume[1] == 109, "Costume Body slot 109 maps to [WINDOW_INVENTORY, 109]")
# 2.2 Wearing an item: right-click inventory cell
client.used_items.clear()
inv.use(5)
_check(client.used_items.size() == 1 and client.used_items[0].window == 1 and client.used_items[0].cell == 5,
"Right-click inventory cell 5 sends send_item_use(WINDOW_INVENTORY, 5)")
# 2.3 Unwearing an item: right-click equipment slot
client.used_items.clear()
inv.use(94) # Weapon slot (wear 4)
_check(client.used_items.size() == 1 and client.used_items[0].window == 2 and client.used_items[0].cell == 4,
"Right-click equipped weapon sends send_item_use(WINDOW_EQUIPMENT, 4) to unwear")
# 2.4 Moving item: drag between inventory cells
client.moved_items.clear()
inv.move_to(10, 20, 1)
_check(client.moved_items.size() == 1 and
client.moved_items[0].src_w == 1 and client.moved_items[0].src_c == 10 and
client.moved_items[0].dst_w == 1 and client.moved_items[0].dst_c == 20,
"Moving item between inventory cells sends move_item(1, 10, 1, 20, 1)")
# 2.5 Dragging item from inventory to equipment slot
client.moved_items.clear()
inv.move_to(3, 94, 1) # Slot 3 to Weapon slot (wear 4)
_check(client.moved_items.size() == 1 and
client.moved_items[0].src_w == 1 and client.moved_items[0].src_c == 3 and
client.moved_items[0].dst_w == 2 and client.moved_items[0].dst_c == 4,
"Dragging item to weapon slot sends move_item(1, 3, 2, 4, 1)")
# 2.6 Equip rules rejection
# Item requiring level 50 on level 10 character
var high_lvl_item := {
"type": 1, "sub_type": 0, "anti_flags": 0,
"limits": [{"type": 1, "value": 50}], # LIMIT_LEVEL = 1
"apply": []
}
var ctx := {"level": 10, "race": 0, "st": 10, "dx": 10, "ht": 10, "iq": 10, "worn": {}}
var verdict := EquipRules.can_equip(high_lvl_item, -1, ctx)
_check(not verdict.ok and verdict.code == "LIMIT_LEVEL" and verdict.need == 50,
"EquipRules rejects item when character level is insufficient (LIMIT_LEVEL)")
inv.queue_free()
client.queue_free()
# ==============================================================================
# 3. 角色属性面板计算与加点 (Character Status Parity)
# ==============================================================================
func _test_char_status_window() -> void:
print("\n--- Test 3: Character Status Window (40250 uicharacter.py Parity) ---")
var client := MockClient.new()
var char_ui := CharStatusUI.new()
root.add_child(char_ui)
char_ui.setup(null, client, "")
# Populate points array (EPointTypes from 40250 char.h / Packet.h)
var pts_arr := []
for i in 255:
pts_arr.append(0)
pts_arr[CharStatusUI.P_LEVEL] = 55
pts_arr[CharStatusUI.P_EXP] = 120000
pts_arr[CharStatusUI.P_NEXT_EXP] = 300000
pts_arr[CharStatusUI.P_HP] = 4500
pts_arr[CharStatusUI.P_MAX_HP] = 5000
pts_arr[CharStatusUI.P_SP] = 1200
pts_arr[CharStatusUI.P_MAX_SP] = 1500
pts_arr[CharStatusUI.P_ST] = 90
pts_arr[CharStatusUI.P_HT] = 75
pts_arr[CharStatusUI.P_DX] = 40
pts_arr[CharStatusUI.P_IQ] = 25
pts_arr[CharStatusUI.P_MIN_ATK] = 320
pts_arr[CharStatusUI.P_MAX_ATK] = 380
pts_arr[CharStatusUI.P_ATT_GRADE_BONUS] = 50
pts_arr[CharStatusUI.P_PARTY_ATT_GRADE] = 10
pts_arr[CharStatusUI.P_DEF_GRADE] = 280
pts_arr[CharStatusUI.P_DEF_GRADE_BONUS] = 20
pts_arr[CharStatusUI.P_MAGIC_ATT_GRADE] = 150
pts_arr[CharStatusUI.P_MIN_MAGIC_WEP] = 30
pts_arr[CharStatusUI.P_MAX_MAGIC_WEP] = 50
pts_arr[CharStatusUI.P_STAT] = 5 # 5 unused stat points
client.points = {
"level": 55, "exp": 120000, "next_exp": 300000,
"hp": 4500, "max_hp": 5000, "sp": 1200, "max_sp": 1500,
"points": pts_arr
}
# 3.1 Attack calculation:
# lo = min_atk (320) + bonus (50 + 10 = 60) = 380
# hi = max_atk (380) + bonus (60) = 440 -> "380-440"
var get_pt := func(idx: int) -> int: return int(pts_arr[idx])
var att_str := char_ui._att_text(get_pt)
_check(att_str == "380-440", "Total Attack text matches 40250 formula ('380-440')")
# Single value attack when min == max
pts_arr[CharStatusUI.P_MIN_ATK] = 350
pts_arr[CharStatusUI.P_MAX_ATK] = 350
att_str = char_ui._att_text(get_pt)
_check(att_str == "410", "Total Attack when min==max matches single value ('410')")
# 3.2 Defense calculation:
# def_grade (280) + def_grade_bonus (20) = 300
var def_str := char_ui._def_text(get_pt)
_check(def_str == "300", "Total Defense text matches 40250 formula ('300')")
# 3.3 Magic Attack calculation:
# mag_att (150) + min (30) = 180, mag_att (150) + max (50) = 200 -> "180-200"
var matt_str := char_ui._matt_text(get_pt)
_check(matt_str == "180-200", "Total Magic Attack matches 40250 formula ('180-200')")
# 3.4 Stat allocation commands
client.chat_messages.clear()
char_ui._send_stat("/stat ht")
char_ui._send_stat("/stat iq")
char_ui._send_stat("/stat st")
char_ui._send_stat("/stat dx")
_check(client.chat_messages == ["/stat ht", "/stat iq", "/stat st", "/stat dx"],
"Stat addition sends exact 40250 chat commands (/stat ht, iq, st, dx)")
client.chat_messages.clear()
char_ui._send_stat("/stat- st")
_check(client.chat_messages == ["/stat- st"], "Stat reset command sends exact 40250 /stat- command")
char_ui.queue_free()
client.queue_free()
# ==============================================================================
# 4. 快捷栏持久化与同步 (Quickbar Persistence & Sync)
# ==============================================================================
func _test_quickbar_persistence() -> void:
print("\n--- Test 4: Quickbar Persistence & Sync (40250 CG_QUICKSLOT_*) ---")
var client := MockClient.new()
var table := MockSkillTable.new()
var qb := Quickbar.new()
root.add_child(qb)
qb.setup(client, table, root, func(): return null, "")
# 4.1 Assign Skill to Quickslot 0
client.quickslot_adds.clear()
# assign skill 1 to local slot 0
var ok := qb.assign(0, "skill", 1)
_check(ok, "Assigned skill 1 to quickbar slot 0")
_check(client.quickslot_adds.size() == 1 and
client.quickslot_adds[0].pos == 0 and
client.quickslot_adds[0].type == 2 and # 2 = SKILL
client.quickslot_adds[0].ref == 1,
"Skill assignment sends quickslot_add(pos=0, type=2, ref=1)")
# 4.2 Assign Item to Quickslot 1
client.quickslot_adds.clear()
ok = qb.assign(1, "item", 12) # Inventory cell 12
_check(ok, "Assigned item (cell 12) to quickbar slot 1")
_check(client.quickslot_adds.size() == 1 and
client.quickslot_adds[0].pos == 1 and
client.quickslot_adds[0].type == 1 and # 1 = ITEM
client.quickslot_adds[0].ref == 12,
"Item assignment sends quickslot_add(pos=1, type=1, ref=12)")
# 4.3 Assign Emote to Quickslot 2
client.quickslot_adds.clear()
ok = qb.assign(2, "emote", 5) # Emote 5
_check(ok, "Assigned emote 5 to quickbar slot 2")
_check(client.quickslot_adds.size() == 1 and
client.quickslot_adds[0].pos == 2 and
client.quickslot_adds[0].type == 3 and # 3 = EMOTE
client.quickslot_adds[0].ref == 5,
"Emote assignment sends quickslot_add(pos=2, type=3, ref=5)")
# 4.4 Swap Quickslots 0 and 1
client.quickslot_swaps.clear()
qb._swap(0, 1)
_check(client.quickslot_swaps.size() == 1 and
client.quickslot_swaps[0].from == 0 and
client.quickslot_swaps[0].to == 1,
"Quickslot swap sends quickslot_swap(0, 1)")
_check(qb._state[0].kind == "item" and qb._state[1].kind == "skill",
"Local quickslot state successfully swapped")
# 4.5 Delete Quickslot
client.quickslot_dels.clear()
qb._clear(0)
_check(client.quickslot_dels == [0], "Clearing slot 0 sends quickslot_del(0)")
_check(qb._state[0].kind == "", "Slot 0 cleared locally")
# 4.6 Server Full Restore
# Simulate server sending 36 slots array
client.quickslots = [
{"pos": 0, "type": 2, "ref": 2}, # Skill 2
{"pos": 1, "type": 1, "ref": 8}, # Item cell 8
{"pos": 2, "type": 3, "ref": 1}, # Emote 1
]
for i in range(3, 36):
client.quickslots.append({"pos": i, "type": 0, "ref": 0})
qb.restore_from_server()
_check(qb._state[0].kind == "skill" and qb._state[0].id == 2, "Restored slot 0 is Skill 2")
_check(qb._state[1].kind == "item" and qb._state[1].id == 8, "Restored slot 1 is Item cell 8")
_check(qb._state[2].kind == "emote" and qb._state[2].id == 1, "Restored slot 2 is Emote 1")
_check(qb._state[3].kind == "", "Restored slot 3 is empty")
qb.queue_free()
client.queue_free()