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