Files
mtgodot-poc/project/test_npc_shop_flow.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

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