- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
382 lines
12 KiB
GDScript
382 lines
12 KiB
GDScript
# test_quest_dialog_parity.gd —— Phase 3 任务系统与对话流 40250 行为对齐验证
|
|
extends SceneTree
|
|
|
|
const QuestDialog = preload("res://ui/quest_dialog.gd")
|
|
const QuestLog = preload("res://ui/quest_log.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 script_dialog(skin: int, text: String)
|
|
signal quest_confirm_ask(msg: String, timeout: int, request_pid: int)
|
|
signal quest_info(index: int)
|
|
|
|
var script_answers: Array = []
|
|
var script_buttons: Array = []
|
|
var quest_inputs: Array = []
|
|
var quest_confirms: Array = []
|
|
var quest_cancels := 0
|
|
var quests: Dictionary = {}
|
|
|
|
func script_answer(answer: int) -> bool:
|
|
script_answers.append(answer)
|
|
return true
|
|
|
|
func script_button(idx: int) -> bool:
|
|
script_buttons.append(idx)
|
|
return true
|
|
|
|
func quest_input(text: String) -> bool:
|
|
quest_inputs.append(text)
|
|
return true
|
|
|
|
func quest_confirm(yes: bool, request_pid: int) -> bool:
|
|
quest_confirms.append({"yes": yes, "pid": request_pid})
|
|
return true
|
|
|
|
func quest_cancel() -> bool:
|
|
quest_cancels += 1
|
|
script_answers.append(254)
|
|
return true
|
|
|
|
func get_quests() -> Array:
|
|
return quests.values()
|
|
|
|
func is_in_game() -> bool:
|
|
return true
|
|
|
|
func _init() -> void:
|
|
print("=== Running test_quest_dialog_parity (Phase 3 Parity) ===")
|
|
_test_pipe_question_parsing()
|
|
_test_choice_pagination()
|
|
_test_input_submission()
|
|
_test_next_and_done()
|
|
_test_esc_cancel()
|
|
_test_skin_modes()
|
|
_test_left_image_layout()
|
|
_test_quest_button_and_log()
|
|
_test_quest_confirm()
|
|
|
|
if _failed:
|
|
print("\n=== QUEST DIALOG PARITY TESTS FAILED ===")
|
|
quit(1)
|
|
else:
|
|
print("\n=== ALL QUEST DIALOG PARITY TESTS PASSED (%d checks) ===" % _pass_count)
|
|
quit(0)
|
|
|
|
# --- Test 1: 40250 Pipe-delimited [QUESTION] Parsing ---
|
|
func _test_pipe_question_parsing() -> void:
|
|
print("\n--- Test 1: 40250 Standard Pipe-delimited [QUESTION] ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
# 40250 standard wire script from questlua.cpp:642 GotoSelectState
|
|
var script := "老铁匠看着你说道:[ENTER]请问你需要什么帮助?[QUESTION 1;拜访铁匠|2;购买强化材料|3;打听消息|4;离开]"
|
|
client.script_dialog.emit(0, script)
|
|
qd.process_events(100.0)
|
|
|
|
_check(qd.is_open(), "Quest dialog opened")
|
|
var parsed: Dictionary = qd.parse_script(script)
|
|
_check(parsed.choices.size() == 4, "Parsed exactly 4 choices")
|
|
_check(parsed.choices[0] == "拜访铁匠", "Choice 0 is '拜访铁匠'")
|
|
_check(parsed.choices[1] == "购买强化材料", "Choice 1 is '购买强化材料'")
|
|
_check(parsed.choices[2] == "打听消息", "Choice 2 is '打听消息'")
|
|
_check(parsed.choices[3] == "离开", "Choice 3 is '离开'")
|
|
|
|
# Click choice 2 ("购买强化材料") -> 0-indexed choice 1 sent to server
|
|
var btn2: Button = qd._btnrow.get_child(1)
|
|
btn2.pressed.emit()
|
|
_check(client.script_answers == [1], "Clicking choice 2 sends 0-indexed script_answer(1)")
|
|
_check(not qd.is_open(), "Dialog closes after choice selection")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 2: Choice Pagination (> 8 choices per 40250 uiquest.py) ---
|
|
func _test_choice_pagination() -> void:
|
|
print("\n--- Test 2: Choice Pagination (> 8 choices) ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
# 12 choices
|
|
var script := "[QUESTION "
|
|
for i in range(1, 13):
|
|
if i > 1:
|
|
script += "|"
|
|
script += "%d;传送地点_%d" % [i, i]
|
|
script += "]"
|
|
|
|
client.script_dialog.emit(0, script)
|
|
qd.process_events(100.0)
|
|
|
|
_check(qd.is_open(), "Dialog opened for 12 choices")
|
|
# Page 1: 8 choice buttons + 1 navigation row = 9 children
|
|
_check(qd._btnrow.get_child_count() == 9, "Page 1 renders 8 choice buttons + 1 nav row")
|
|
var first_btn: Button = qd._btnrow.get_child(0)
|
|
_check(first_btn.text == "传送地点_1", "First button on Page 1 is '传送地点_1'")
|
|
|
|
# Nav row is child 8
|
|
var nav_row: HBoxContainer = qd._btnrow.get_child(8)
|
|
_check(nav_row != null, "Navigation row exists")
|
|
var next_btn: Button = nav_row.get_child(nav_row.get_child_count() - 1)
|
|
_check(next_btn.text == "下一页", "Next page button present")
|
|
|
|
# Switch to Page 2
|
|
next_btn.pressed.emit()
|
|
_check(qd._current_choice_page == 1, "Switched to Page 2")
|
|
# Page 2: remaining 4 choice buttons + 1 nav row = 5 children
|
|
_check(qd._btnrow.get_child_count() == 5, "Page 2 renders remaining 4 buttons + 1 nav row")
|
|
var page2_btn2: Button = qd._btnrow.get_child(1) # This is 10th overall choice (index 9)
|
|
_check(page2_btn2.text == "传送地点_10", "Second button on Page 2 is '传送地点_10'")
|
|
|
|
# Click choice 10 -> global index 9 sent to server
|
|
page2_btn2.pressed.emit()
|
|
_check(client.script_answers == [9], "Clicking option on Page 2 sends global index script_answer(9)")
|
|
_check(not qd.is_open(), "Dialog closed after selection on page 2")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 3: [INPUT] Submission Flow ---
|
|
func _test_input_submission() -> void:
|
|
print("\n--- Test 3: [INPUT] Submission Flow ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
client.script_dialog.emit(0, "请输入暗号密码:[INPUT]")
|
|
qd.process_events(100.0)
|
|
|
|
_check(qd.is_open(), "Dialog with input opened")
|
|
var line_edit: LineEdit = null
|
|
var submit_btn: Button = null
|
|
for child in qd._btnrow.get_children():
|
|
if child is LineEdit:
|
|
line_edit = child
|
|
elif child is Button and child.text == "确定":
|
|
submit_btn = child
|
|
|
|
_check(line_edit != null, "LineEdit control created for [INPUT]")
|
|
_check(submit_btn != null, "Submit button created")
|
|
|
|
line_edit.text = "metin2_open_sesame"
|
|
submit_btn.pressed.emit()
|
|
|
|
_check(client.quest_inputs == ["metin2_open_sesame"], "Submitted input string sent via quest_input")
|
|
_check(not qd.is_open(), "Dialog closes after input submission")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 4: [NEXT] and [DONE] Protocols ---
|
|
func _test_next_and_done() -> void:
|
|
print("\n--- Test 4: [NEXT] and [DONE] Protocols ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
# 4.1: [NEXT] button
|
|
client.script_dialog.emit(0, "这是第一段剧情。[NEXT]")
|
|
qd.process_events(100.0)
|
|
var next_btn: Button = qd._btnrow.get_child(0)
|
|
_check(next_btn.text == "继续", "Button label is '继续'")
|
|
next_btn.pressed.emit()
|
|
_check(client.script_answers == [254], "[NEXT] click sends script_answer(254) to resume")
|
|
|
|
# 4.2: [DONE] button
|
|
client.script_answers.clear()
|
|
var done_emitted := [false]
|
|
qd.done_event.connect(func():
|
|
done_emitted[0] = true
|
|
)
|
|
client.script_dialog.emit(0, "任务已经完成![DONE]")
|
|
qd.process_events(100.0)
|
|
var done_btn: Button = qd._btnrow.get_child(0)
|
|
_check(done_btn.text == "关闭", "Button label is '关闭'")
|
|
done_btn.pressed.emit()
|
|
_check(client.script_answers == [254], "[DONE] click sends script_answer(254)")
|
|
_check(done_emitted[0], "done_event signal emitted on [DONE] click")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 5: ESC Key Cancel Flow ---
|
|
func _test_esc_cancel() -> void:
|
|
print("\n--- Test 5: ESC Key Cancel Flow (40250 OnCancel) ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
client.script_dialog.emit(0, "选择你的阵营:[QUESTION 1;红帝国|2;黄帝国|3;蓝帝国]")
|
|
qd.process_events(100.0)
|
|
_check(qd.is_open(), "Dialog open waiting for choice")
|
|
|
|
# Simulate ESC key press
|
|
var esc := InputEventKey.new()
|
|
esc.keycode = KEY_ESCAPE
|
|
esc.pressed = true
|
|
qd._unhandled_input(esc)
|
|
|
|
_check(client.quest_cancels == 1 or client.script_answers == [254], "ESC key triggers quest cancellation")
|
|
_check(not qd.is_open(), "Dialog closed on ESC")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 6: Skin Modes (40250 uiquest.py:328) ---
|
|
func _test_skin_modes() -> void:
|
|
print("\n--- Test 6: Skin Modes (NORMAL vs NOWINDOW vs CINEMA) ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
# 6.1: SKIN_NORMAL (0)
|
|
client.script_dialog.emit(0, "普通对话框[DONE]")
|
|
qd.process_events(100.0)
|
|
_check(qd._panel.visible, "SKIN_NORMAL displays background panel")
|
|
|
|
# 6.2: SKIN_CINEMA (5)
|
|
client.script_dialog.emit(5, "电影过场模式[DONE]")
|
|
qd.process_events(100.0)
|
|
_check(not qd._panel.visible, "SKIN_CINEMA hides background panel")
|
|
|
|
# 6.3: SKIN_NOWINDOW (1)
|
|
client.script_dialog.emit(1, "无边框纯文本模式[DONE]")
|
|
qd.process_events(100.0)
|
|
_check(not qd._panel.visible, "SKIN_NOWINDOW hides background panel")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 7: Left Image Layout Offset (LEFTIMAGE) ---
|
|
func _test_left_image_layout() -> void:
|
|
print("\n--- Test 7: Left Image Portrait Layout Offset ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
# Dialog without portrait
|
|
client.script_dialog.emit(0, "无立绘对话[DONE]")
|
|
qd.process_events(100.0)
|
|
var default_text_x: float = qd._text.position.x
|
|
_check(default_text_x == 16.0, "Default text position x is 16.0")
|
|
|
|
# Dialog with LEFTIMAGE
|
|
var script := "[LEFTIMAGE src;ui/game/questboard/npc_guard.sub]守卫向你致敬:[ENTER]城外近期有野狼出没,请多加小心![DONE]"
|
|
client.script_dialog.emit(0, script)
|
|
qd.process_events(100.0)
|
|
|
|
# Even if texture is dummy/mocked, layout handles parsed LEFTIMAGE panel spec
|
|
var parsed: Dictionary = qd.parse_script(script)
|
|
_check(parsed.images.size() == 1 and parsed.images[0]["panel"] == "LEFTIMAGE", "Parsed LEFTIMAGE correctly")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 8: Quest Button & Quest Log Integration ---
|
|
func _test_quest_button_and_log() -> void:
|
|
print("\n--- Test 8: Quest Button & Quest Log Flow ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
var ql := QuestLog.new()
|
|
root.add_child(ql)
|
|
ql.setup(client, host)
|
|
|
|
qd.quest_button_received.connect(func(idx, title, itype, iname):
|
|
ql.recv_quest(idx, title, itype, iname))
|
|
|
|
# Script emits QUESTBUTTON
|
|
var script := "[QUESTBUTTON idx(7) name(\"消灭恶狼\") icon_type(\"highlight\") icon_name(\"\")][DONE]"
|
|
client.script_dialog.emit(0, script)
|
|
qd.process_events(100.0)
|
|
|
|
_check(ql._quest_button_data.has(7), "QuestLog received quest button with index 7")
|
|
_check(ql._quest_button_data[7]["title"] == "消灭恶狼", "Quest title is '消灭恶狼'")
|
|
|
|
# Player clicks quest letter / button on sidebar
|
|
ql._start_quest(7)
|
|
_check(client.script_buttons == [7], "Clicking quest button sends script_button(7)")
|
|
_check(not ql._quest_button_data.has(7), "Quest button cleared from active list after click")
|
|
|
|
ql.queue_free()
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|
|
|
|
# --- Test 9: Quest Confirm (GC_QUEST_CONFIRM) ---
|
|
func _test_quest_confirm() -> void:
|
|
print("\n--- Test 9: Quest Confirmation Modal Flow ---")
|
|
var client := MockClient.new()
|
|
var host := Control.new()
|
|
root.add_child(host)
|
|
var qd := QuestDialog.new()
|
|
root.add_child(qd)
|
|
qd.setup(client, host)
|
|
|
|
# Server asks confirmation (e.g. party dungeon warp)
|
|
client.quest_confirm_ask.emit("是否确认进入地牢副本?", 30, 99999)
|
|
_check(qd.is_open(), "Confirm dialog opened")
|
|
_check(qd._text.text == "是否确认进入地牢副本?", "Dialog message matches")
|
|
|
|
# Find "接受" button
|
|
var accept_btn: Button = null
|
|
for row_child in qd._btnrow.get_children():
|
|
if row_child is HBoxContainer:
|
|
for b in row_child.get_children():
|
|
if b is Button and b.text == "接受":
|
|
accept_btn = b
|
|
|
|
_check(accept_btn != null, "'接受' button exists")
|
|
accept_btn.pressed.emit()
|
|
_check(client.quest_confirms == [{"yes": true, "pid": 99999}], "Clicking 接受 sends quest_confirm(true, 99999)")
|
|
_check(not qd.is_open(), "Confirm dialog closes after decision")
|
|
|
|
qd.queue_free()
|
|
host.queue_free()
|
|
client.queue_free()
|