- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
463 lines
14 KiB
GDScript
463 lines
14 KiB
GDScript
# test_npc_shop_flow.gd —— Phase 2 NPC 交互与商店系统 40250 行为对齐验证
|
|
extends SceneTree
|
|
|
|
const ShopUI = preload("res://ui/shop_ui.gd")
|
|
const NetPlay = preload("res://net_play.gd")
|
|
const EntityRules = preload("res://entity_rules.gd")
|
|
const ItemTooltip = preload("res://ui/item_tooltip.gd")
|
|
|
|
var _failed := false
|
|
var _pass_count := 0
|
|
|
|
func _check(ok: bool, msg: String) -> void:
|
|
if ok:
|
|
_pass_count += 1
|
|
print(" [PASS] " + msg)
|
|
else:
|
|
_failed = true
|
|
push_error("FAIL: " + msg)
|
|
print(" [FAIL] " + msg)
|
|
|
|
class MockClient extends Node:
|
|
signal shop_opened(vid: int)
|
|
signal shop_closed
|
|
signal shop_updated(pos: int)
|
|
signal shop_error(kind: String)
|
|
signal entity_main_set(vid: int)
|
|
signal entity_moved(vid: int, x: int, y: int, rot: float, dur: int)
|
|
signal points_changed()
|
|
signal vitals_changed()
|
|
signal target_info(info: Dictionary)
|
|
signal entity_despawned(vid: int)
|
|
signal entity_dead(vid: int)
|
|
|
|
var entities: Dictionary = {}
|
|
var main_vid := 1000
|
|
var click_calls: Array = []
|
|
var buy_calls: Array = []
|
|
var sell_calls: Array = []
|
|
var close_calls := 0
|
|
var shop_data: Dictionary = {"tabs": []}
|
|
|
|
func is_in_game() -> bool:
|
|
return true
|
|
|
|
func get_main_vid() -> int:
|
|
return main_vid
|
|
|
|
func get_entity(vid: int) -> Dictionary:
|
|
return entities.get(vid, {})
|
|
|
|
func click_npc(vid: int) -> bool:
|
|
click_calls.append(vid)
|
|
return true
|
|
|
|
func get_shop() -> Dictionary:
|
|
return shop_data
|
|
|
|
func get_shop_items() -> Array:
|
|
var tabs: Array = shop_data.get("tabs", [])
|
|
if not tabs.is_empty():
|
|
return tabs[0].get("items", [])
|
|
return []
|
|
|
|
func shop_buy(pos: int, count: int) -> bool:
|
|
buy_calls.append({"pos": pos, "count": count})
|
|
return true
|
|
|
|
func shop_sell(cell: int, count: int) -> bool:
|
|
sell_calls.append({"cell": cell, "count": count})
|
|
return true
|
|
|
|
func shop_close() -> void:
|
|
close_calls += 1
|
|
shop_closed.emit()
|
|
|
|
func get_inventory() -> Array:
|
|
return []
|
|
|
|
class MockPlayerController extends Node:
|
|
var player: Node3D
|
|
var stopped := false
|
|
var walk_dest := Vector3.ZERO
|
|
var going := false
|
|
signal target_selected(node: Node3D)
|
|
signal moved(is_moving: bool)
|
|
signal anim_state(s: String)
|
|
|
|
func is_going() -> bool:
|
|
return going
|
|
|
|
func stop() -> void:
|
|
stopped = true
|
|
going = false
|
|
|
|
func walk_to(dest: Vector3) -> void:
|
|
walk_dest = dest
|
|
going = true
|
|
stopped = false
|
|
|
|
class MockNetWorld extends Node:
|
|
var nodes: Dictionary = {}
|
|
func node_for(vid: int) -> Node3D:
|
|
return nodes.get(vid, null)
|
|
|
|
class MockProto extends Node:
|
|
var items: Dictionary = {}
|
|
func item(vnum: int) -> Dictionary:
|
|
return items.get(vnum, {})
|
|
|
|
func _init() -> void:
|
|
print("=== Running test_npc_shop_flow (Phase 2 Parity) ===")
|
|
_test_npc_interaction()
|
|
_test_npc_click_throttle()
|
|
_test_shop_data_and_sockets()
|
|
_test_shop_multi_tab_and_buy_index()
|
|
_test_shop_incremental_update()
|
|
_test_shop_direct_buy_right_click()
|
|
_test_shop_distance_auto_close()
|
|
|
|
if _failed:
|
|
print("\n=== NPC & SHOP TESTS FAILED ===")
|
|
quit(1)
|
|
else:
|
|
print("\n=== ALL NPC & SHOP TESTS PASSED (%d checks) ===" % _pass_count)
|
|
quit(0)
|
|
|
|
# --- Test 1: NPC Interaction (500cm range & reservation) ---
|
|
func _test_npc_interaction() -> void:
|
|
print("\n--- Test 1: NPC Interaction Distance & Reservation ---")
|
|
var client := MockClient.new()
|
|
root.add_child(client)
|
|
var pc := MockPlayerController.new()
|
|
root.add_child(pc)
|
|
var player_node := Node3D.new()
|
|
root.add_child(player_node)
|
|
player_node.position = Vector3(0, 0, 0)
|
|
pc.player = player_node
|
|
|
|
var nw := MockNetWorld.new()
|
|
root.add_child(nw)
|
|
var npc_node := Node3D.new()
|
|
root.add_child(npc_node)
|
|
npc_node.position = Vector3(3.0, 0, 0) # 3.0m <= 5.0m (500cm)
|
|
npc_node.set_meta("vid", 2001)
|
|
nw.nodes[2001] = npc_node
|
|
|
|
client.entities[1000] = {"vid": 1000, "ch_type": 0, "pos": Vector3(0,0,0)} # PC
|
|
client.entities[2001] = {"vid": 2001, "ch_type": 1, "pos": Vector3(3,0,0), "name": "General Store"} # NPC
|
|
|
|
var np := NetPlay.new()
|
|
root.add_child(np)
|
|
np.setup(client, pc, nw, null)
|
|
np._main_vid = 1000
|
|
|
|
# 1.1: Click within 5.0m -> Immediate click, stops, faces NPC
|
|
np._on_pick(npc_node)
|
|
_check(client.click_calls == [2001], "NPC click within 5m immediately sends click_npc packet")
|
|
_check(pc.stopped, "Character stops when interacting with NPC")
|
|
_check(abs(player_node.rotation.y - (PI / 2.0)) < 0.05, "Character faces toward NPC (+X direction)")
|
|
|
|
# 1.2: Move NPC far away (8.0m > 5.0m)
|
|
npc_node.position = Vector3(8.0, 0, 0)
|
|
client.entities[2001]["pos"] = Vector3(8,0,0)
|
|
client.click_calls.clear()
|
|
pc.stopped = false
|
|
|
|
# Reset throttle timer for test
|
|
np._last_click_actor_t = -10.0
|
|
|
|
np._on_pick(npc_node)
|
|
_check(client.click_calls.is_empty(), "NPC click beyond 5m does not immediately send packet")
|
|
_check(np._reserved_mode == np.ReservedMode.CLICK_ACTOR, "NPC click beyond 5m enters reservation mode (CLICK_ACTOR)")
|
|
_check(np._vid_reserved == 2001, "Reserved actor VID set to NPC VID")
|
|
_check(pc.going and pc.walk_dest == Vector3(8, 0, 0), "Character begins walking toward distant NPC")
|
|
|
|
# Character walks closer into 4.0m range
|
|
player_node.position = Vector3(4.5, 0, 0) # dist to 8.0 is 3.5m <= 5m
|
|
np._refresh_reserved()
|
|
_check(client.click_calls == [2001], "Reservation executes click_npc when entering 5m range")
|
|
_check(pc.stopped, "Reservation execution stops character")
|
|
_check(np._reserved_mode == np.ReservedMode.NONE, "Reservation cleared after execution")
|
|
|
|
np.queue_free()
|
|
player_node.queue_free()
|
|
npc_node.queue_free()
|
|
nw.queue_free()
|
|
pc.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 2: NPC Click Throttle (1000ms protection) ---
|
|
func _test_npc_click_throttle() -> void:
|
|
print("\n--- Test 2: NPC Click Throttle (40250 1000ms protection) ---")
|
|
var client := MockClient.new()
|
|
root.add_child(client)
|
|
var pc := MockPlayerController.new()
|
|
root.add_child(pc)
|
|
var pnode := Node3D.new()
|
|
root.add_child(pnode)
|
|
pc.player = pnode
|
|
|
|
var nw := MockNetWorld.new()
|
|
root.add_child(nw)
|
|
var npc1 := Node3D.new()
|
|
var npc2 := Node3D.new()
|
|
root.add_child(npc1)
|
|
root.add_child(npc2)
|
|
npc1.position = Vector3(1, 0, 0)
|
|
npc2.position = Vector3(2, 0, 0)
|
|
npc1.set_meta("vid", 2001)
|
|
npc2.set_meta("vid", 2002)
|
|
nw.nodes[2001] = npc1
|
|
nw.nodes[2002] = npc2
|
|
|
|
client.entities[1000] = {"vid": 1000, "ch_type": 0, "pos": Vector3.ZERO}
|
|
client.entities[2001] = {"vid": 2001, "ch_type": 1, "pos": Vector3(1,0,0)}
|
|
client.entities[2002] = {"vid": 2002, "ch_type": 1, "pos": Vector3(2,0,0)}
|
|
|
|
var np := NetPlay.new()
|
|
root.add_child(np)
|
|
np.setup(client, pc, nw, null)
|
|
np._main_vid = 1000
|
|
|
|
# First click on NPC 2001
|
|
var r1 := np._send_click_actor_packet(2001)
|
|
_check(r1 and client.click_calls == [2001], "Initial click on NPC 2001 succeeds")
|
|
|
|
# Immediate second click on NPC 2001 (<1s) -> throttled
|
|
var r2 := np._send_click_actor_packet(2001)
|
|
_check(not r2 and client.click_calls == [2001], "Rapid second click on same NPC is throttled")
|
|
|
|
# Click different NPC 2002 -> immediately succeeds
|
|
var r3 := np._send_click_actor_packet(2002)
|
|
_check(r3 and client.click_calls == [2001, 2002], "Clicking different NPC is not throttled")
|
|
|
|
# Advance simulated time past 1.0s
|
|
np._last_click_actor_t -= 1.1
|
|
var r4 := np._send_click_actor_packet(2002)
|
|
_check(r4 and client.click_calls == [2001, 2002, 2002], "Clicking same NPC after >1.0s succeeds")
|
|
|
|
np.queue_free()
|
|
pnode.queue_free()
|
|
npc1.queue_free()
|
|
npc2.queue_free()
|
|
nw.queue_free()
|
|
pc.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 3: Shop Sockets & Attrs Data Completeness ---
|
|
func _test_shop_data_and_sockets() -> void:
|
|
print("\n--- Test 3: Shop Sockets & Attrs Data Preservation ---")
|
|
var client := MockClient.new()
|
|
var proto := MockProto.new()
|
|
proto.items[10] = {
|
|
"name": "Sword+0",
|
|
"locale_name": "剑+0",
|
|
"type": 1, # WEAPON
|
|
"values": [0, 0, 0, 15, 20, 0], # attack 15-20
|
|
}
|
|
|
|
# Item with 3 sockets and 2 attributes
|
|
var item_entry := {
|
|
"pos": 0,
|
|
"vnum": 10,
|
|
"count": 1,
|
|
"price": 1000,
|
|
"sockets": [28430, 0, 0],
|
|
"attrs": [
|
|
{"type": 1, "value": 500}, # MAX_HP + 500
|
|
{"type": 5, "value": 12}, # STR + 12
|
|
{"type": 0, "value": 0},
|
|
{"type": 0, "value": 0},
|
|
{"type": 0, "value": 0},
|
|
{"type": 0, "value": 0},
|
|
{"type": 0, "value": 0}
|
|
]
|
|
}
|
|
client.shop_data = {
|
|
"tabs": [
|
|
{"name": "武器", "items": [item_entry]}
|
|
]
|
|
}
|
|
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var shop := ShopUI.new()
|
|
root.add_child(shop)
|
|
shop.setup(client, host, proto)
|
|
shop.open()
|
|
|
|
var slot_btn: Button = shop._grid.get_child(0)
|
|
_check(slot_btn != null, "Shop slot 0 created")
|
|
_check(slot_btn.tooltip_text.contains("最大生命:+500"), "Tooltip contains attribute 1 (MAX_HP +500)")
|
|
_check(slot_btn.tooltip_text.contains("力量:+12"), "Tooltip contains attribute 2 (STR +12)")
|
|
_check(slot_btn.tooltip_text.contains("价格:1000"), "Tooltip contains item price")
|
|
|
|
shop.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
proto.queue_free()
|
|
|
|
# --- Test 4: Shop Multi-Tab & Buy Index Calculation ---
|
|
func _test_shop_multi_tab_and_buy_index() -> void:
|
|
print("\n--- Test 4: Multi-tab Shop (START_EX) and Buy Index Parity ---")
|
|
var client := MockClient.new()
|
|
var proto := MockProto.new()
|
|
proto.items[10] = {"name": "Sword", "locale_name": "剑"}
|
|
proto.items[27001] = {"name": "Red Potion", "locale_name": "红药水"}
|
|
|
|
# 2 tabs: Tab 0 (Weapons), Tab 1 (Potions)
|
|
client.shop_data = {
|
|
"tabs": [
|
|
{
|
|
"name": "武器货架",
|
|
"items": [{"pos": 2, "vnum": 10, "count": 1, "price": 500, "sockets": [], "attrs": []}]
|
|
},
|
|
{
|
|
"name": "药品货架",
|
|
"items": [{"pos": 5, "vnum": 27001, "count": 50, "price": 200, "sockets": [], "attrs": []}]
|
|
}
|
|
]
|
|
}
|
|
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var shop := ShopUI.new()
|
|
root.add_child(shop)
|
|
shop.setup(client, host, proto)
|
|
shop.open()
|
|
|
|
_check(shop._tabbar.visible, "Multi-tab shop displays tabbar")
|
|
_check(shop._tabbar.get_child_count() == 2, "Tabbar contains 2 shelves")
|
|
_check(shop._tabbar.get_child(0).text == "武器货架", "Tab 0 name is 武器货架")
|
|
_check(shop._tabbar.get_child(1).text == "药品货架", "Tab 1 name is 药品货架")
|
|
|
|
# Check Tab 0 slot 2
|
|
var tab0_slot2: Button = shop._grid.get_child(2)
|
|
_check(not tab0_slot2.disabled and tab0_slot2.text.begins_with("剑"), "Tab 0 slot 2 is Sword")
|
|
|
|
# Switch to Tab 1
|
|
var tab1_btn: Button = shop._tabbar.get_child(1)
|
|
tab1_btn.pressed.emit()
|
|
_check(shop._active_tab == 1, "Switched to Tab 1")
|
|
|
|
# Check Tab 1 slot 5
|
|
var tab1_slot5: Button = shop._grid.get_child(5)
|
|
_check(not tab1_slot5.disabled and tab1_slot5.text.begins_with("红药水"), "Tab 1 slot 5 is Red Potion")
|
|
|
|
# Buy from Tab 1 slot 5: pos calculation = 1 * 40 + 5 = 45
|
|
tab1_slot5.pressed.emit()
|
|
_check(is_instance_valid(shop._buy_confirm), "Clicking slot opens confirmation modal")
|
|
shop._buy_confirm.confirmed.emit()
|
|
_check(client.buy_calls.size() == 1, "Buy request emitted")
|
|
_check(client.buy_calls[0]["pos"] == 45, "Buying position correctly calculated as tabIdx * 40 + slot (45)")
|
|
|
|
shop.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
proto.queue_free()
|
|
|
|
# --- Test 5: Incremental Slot Update preserves active tab ---
|
|
func _test_shop_incremental_update() -> void:
|
|
print("\n--- Test 5: Incremental Slot Update (shop_updated) ---")
|
|
var client := MockClient.new()
|
|
var proto := MockProto.new()
|
|
proto.items[10] = {"name": "Sword", "locale_name": "剑"}
|
|
proto.items[20] = {"name": "Bow", "locale_name": "弓"}
|
|
|
|
client.shop_data = {
|
|
"tabs": [
|
|
{"name": "Tab0", "items": [{"pos": 0, "vnum": 10, "count": 1, "price": 100, "sockets": [], "attrs": []}]},
|
|
{"name": "Tab1", "items": [{"pos": 1, "vnum": 20, "count": 1, "price": 200, "sockets": [], "attrs": []}]}
|
|
]
|
|
}
|
|
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var shop := ShopUI.new()
|
|
root.add_child(shop)
|
|
shop.setup(client, host, proto)
|
|
shop.open()
|
|
|
|
# User switches to Tab 1
|
|
shop._tabbar.get_child(1).pressed.emit()
|
|
_check(shop._active_tab == 1, "User is on Tab 1")
|
|
|
|
# Server sends slot update for Tab 1 item (price updated from 200 to 350)
|
|
client.shop_data["tabs"][1]["items"][0]["price"] = 350
|
|
client.shop_updated.emit(41) # pos 41 = 1 * 40 + 1
|
|
|
|
_check(shop._active_tab == 1, "shop_updated did NOT reset _active_tab back to 0")
|
|
var updated_slot: Button = shop._grid.get_child(1)
|
|
_check(updated_slot.tooltip_text.contains("价格:350"), "Slot 1 refreshed with updated price 350")
|
|
|
|
shop.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
proto.queue_free()
|
|
|
|
# --- Test 6: Right-click instant buy per 40250 UnselectItemSlot ---
|
|
func _test_shop_direct_buy_right_click() -> void:
|
|
print("\n--- Test 6: Right-Click Direct Buy (40250 UnselectItemSlot) ---")
|
|
var client := MockClient.new()
|
|
var proto := MockProto.new()
|
|
proto.items[10] = {"name": "Sword", "locale_name": "剑"}
|
|
client.shop_data = {
|
|
"tabs": [
|
|
{"name": "", "items": [{"pos": 3, "vnum": 10, "count": 1, "price": 500, "sockets": [], "attrs": []}]}
|
|
]
|
|
}
|
|
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var shop := ShopUI.new()
|
|
root.add_child(shop)
|
|
shop.setup(client, host, proto)
|
|
shop.open()
|
|
|
|
var slot3: Button = shop._grid.get_child(3)
|
|
var mb := InputEventMouseButton.new()
|
|
mb.button_index = MOUSE_BUTTON_RIGHT
|
|
mb.pressed = true
|
|
|
|
slot3.gui_input.emit(mb)
|
|
_check(client.buy_calls == [{"pos": 3, "count": 1}], "Right-click slot immediately buys without confirmation dialog")
|
|
_check(shop._buy_confirm == null, "Confirmation dialog was not opened on right-click")
|
|
|
|
shop.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
proto.queue_free()
|
|
|
|
# --- Test 7: Shop Distance Auto-close (10m / 1000cm) ---
|
|
func _test_shop_distance_auto_close() -> void:
|
|
print("\n--- Test 7: Shop Distance Auto-Close (>10.0m) ---")
|
|
var client := MockClient.new()
|
|
client.entities[1000] = {"vid": 1000, "pos": Vector3(100, 0, 100)} # Character at (100, 0, 100)
|
|
client.shop_data = {"tabs": [{"name": "", "items": []}]}
|
|
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var shop := ShopUI.new()
|
|
root.add_child(shop)
|
|
shop.setup(client, host, null)
|
|
|
|
shop.open()
|
|
_check(shop.is_open(), "Shop window opened")
|
|
_check(shop._open_char_pos == Vector3(100, 0, 100), "Recorded character opening position")
|
|
|
|
# Character walks 5m away -> within 10m limit
|
|
client.entities[1000]["pos"] = Vector3(105, 0, 100)
|
|
shop._process(0.016)
|
|
_check(shop.is_open(), "Shop remains open at 5m distance")
|
|
|
|
# Character walks 12m away -> exceeds 10m (USE_SHOP_LIMIT_RANGE_M)
|
|
client.entities[1000]["pos"] = Vector3(112, 0, 100)
|
|
shop._process(0.016)
|
|
_check(not shop.is_open(), "Shop auto-closes when character walks > 10m away")
|
|
_check(client.close_calls == 1, "shop_close packet sent to server")
|
|
|
|
shop.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|