# 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()