- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
514 lines
20 KiB
GDScript
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()
|