fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
This commit is contained in:
@@ -11,18 +11,28 @@ class FakeClient extends Node:
|
||||
signal char_name_changed(pid: int, name: String)
|
||||
signal entered_game()
|
||||
signal login_failed(reason: String)
|
||||
signal connect_failed(reason: String)
|
||||
signal server_quit_requested()
|
||||
signal char_created(slot: int)
|
||||
signal char_create_failed(reason: int)
|
||||
signal char_deleted(slot: int)
|
||||
signal char_delete_failed()
|
||||
var selects := []
|
||||
var reconnects := 0
|
||||
var connect_calls := 0
|
||||
var retry_login_calls := 0
|
||||
func select_character(index: int) -> bool:
|
||||
selects.append(index)
|
||||
return true
|
||||
func reconnect() -> bool:
|
||||
reconnects += 1
|
||||
return true
|
||||
func connect_to_server(_auth_host: String, _auth_port: int, _game_host: String,
|
||||
_game_port: int, _id: String, _pw: String) -> void:
|
||||
connect_calls += 1
|
||||
func retry_login(_auth_host: String, _auth_port: int, _game_host: String,
|
||||
_game_port: int, _id: String, _pw: String) -> void:
|
||||
retry_login_calls += 1
|
||||
|
||||
var _fail := 0
|
||||
|
||||
@@ -46,33 +56,160 @@ func _init() -> void:
|
||||
_ck(lifecycle_nodes.size() == 1, "AppFlow owns exactly one AppLifecycle")
|
||||
_ck(flow.get_node_or_null("AppLifecycle") != null,
|
||||
"AppLifecycle survives login screen construction")
|
||||
_ck(flow._lifecycle.close_requested.is_connected(Callable(flow, "_on_close_requested")),
|
||||
"AppFlow consumes the desktop close request")
|
||||
var flow_file := FileAccess.open("res://app_flow.gd", FileAccess.READ)
|
||||
var flow_source: String = flow_file.get_as_text() if flow_file else ""
|
||||
var game_start: int = flow_source.find("func _goto_game()")
|
||||
var game_end: int = flow_source.find("# --- 登录表单", game_start)
|
||||
var game_body: String = flow_source.substr(game_start, game_end - game_start) if game_start >= 0 and game_end > game_start else ""
|
||||
_ck(not game_body.contains("client.net_poll()"),
|
||||
"AppFlow does not re-enter the M2Client frame pump while waiting for the model")
|
||||
# A 40250 GC_WARP is a replacement transport, not a terminal login error.
|
||||
# When the replacement connect fails synchronously, the native bridge must
|
||||
# retain the reconnect owner before returning to LoginPhase; otherwise the
|
||||
# next reconnect() call sees GameLogin and is incorrectly treated as a
|
||||
# duplicate in-flight request. Keep this source-level guard beside the
|
||||
# phase-only AppFlow behavior test so the native invariant cannot regress
|
||||
# while the Godot fake client still exercises the UI owner.
|
||||
var m2_path := ProjectSettings.globalize_path("res://../extension/src/net/m2_client.cpp")
|
||||
var m2_file := FileAccess.open(m2_path, FileAccess.READ)
|
||||
var m2_source: String = m2_file.get_as_text() if m2_file else ""
|
||||
var first_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(")
|
||||
var first_warp_body: String = m2_source.substr(first_warp_start, 700) \
|
||||
if first_warp_start >= 0 else ""
|
||||
var second_warp_start: int = m2_source.find("if (!classic_sess->connect_warp(", first_warp_start + 1)
|
||||
var second_warp_body: String = m2_source.substr(second_warp_start, 700) \
|
||||
if second_warp_start >= 0 else ""
|
||||
_ck(not first_warp_body.contains("classic_remote_disconnect_pending = true")
|
||||
and not first_warp_body.contains("emit_signal(\"disconnected\""),
|
||||
"fast DirectEnter connect failure stays on the ClosePhase owner")
|
||||
_ck(not second_warp_body.contains("classic_remote_disconnect_pending = true")
|
||||
and not second_warp_body.contains("emit_signal(\"disconnected\""),
|
||||
"GC_WARP connect failure stays on the ClosePhase owner")
|
||||
# 40250 keeps RecvPhasePacket(PHASE_CLOSE) separate from
|
||||
# OnRemoteDisconnect: ClosePhase -> SetLoginPhase is a phase-owner change,
|
||||
# not a transport failure. The native bridge must therefore not synthesize a
|
||||
# disconnected signal or arm the remote-disconnect retry owner here.
|
||||
var phase_close_start: int = m2_source.find("classic_sess->on_phase_close = [this]()")
|
||||
var phase_close_end: int = m2_source.find("classic_sess->on_phase_leave", phase_close_start)
|
||||
var phase_close_body: String = m2_source.substr(phase_close_start,
|
||||
phase_close_end - phase_close_start) if phase_close_start >= 0 and phase_close_end > phase_close_start else ""
|
||||
_ck(not phase_close_body.contains("classic_remote_disconnect_pending = true")
|
||||
and not phase_close_body.contains("emit_signal(\"disconnected\""),
|
||||
"explicit PHASE_CLOSE does not synthesize transport disconnected")
|
||||
# An empty 40250 account is a valid SelectPhase result. The live probe must
|
||||
# be able to verify that packet/UI owner without treating the absence of a
|
||||
# selectable slot as a login failure or trying to enter a stale slot.
|
||||
var e2e_path := ProjectSettings.globalize_path("res://../extension/tools/net_classic_e2e.cpp")
|
||||
var e2e_file := FileAccess.open(e2e_path, FileAccess.READ)
|
||||
var e2e_source: String = e2e_file.get_as_text() if e2e_file else ""
|
||||
_ck(e2e_source.contains("MT_CLASSIC_EXPECT_EMPTY")
|
||||
and e2e_source.contains("PASS EMPTY CHARACTER LIST"),
|
||||
"live probe treats an all-empty character list as a valid SelectPhase result")
|
||||
_ck(client.get_parent() == flow,
|
||||
"AppFlow keeps the same client beside lifecycle coordinator")
|
||||
var login_snapshot: Dictionary = flow.get_playable_snapshot()
|
||||
_ck(login_snapshot.get("stage", "") == "LOGIN" and not login_snapshot.get("scene_ready", true),
|
||||
"playable snapshot is a read-only LOGIN summary")
|
||||
|
||||
# A live game keeps its scene during a transient disconnect. ReconnectUI
|
||||
# owns the retry while the next character list silently re-enters the last
|
||||
# selected slot instead of flashing the character-select page.
|
||||
# 40250 intrologin.OnLoginFailure closes only the connecting dialog. The
|
||||
# LoginPhase window remains the owner of the retry, including its password
|
||||
# field and status surface; rebuilding the whole layer changes the phase
|
||||
# owner and can strand a pending retry callback on the old UI.
|
||||
var login_ui_before: Node = flow._ui
|
||||
var login_fields: Array[Node] = flow._ui.find_children("*", "LineEdit", true, false)
|
||||
var password_before: Node = login_fields[1] if login_fields.size() > 1 else null
|
||||
client.login_failed.emit("WRONGPWD")
|
||||
await process_frame
|
||||
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
|
||||
"login failure keeps the existing LoginPhase UI for retry")
|
||||
_ck(password_before != null, "login UI has a password field for retry focus")
|
||||
var login_status := flow._ui.find_child("Status", true, false)
|
||||
_ck(login_status != null and String(login_status.text).contains("WRONGPWD"),
|
||||
"login failure is rendered in the existing status surface")
|
||||
client.connect_failed.emit("ECONNREFUSED")
|
||||
await process_frame
|
||||
_ck(flow.state() == AppFlow.LOGIN and flow._ui == login_ui_before,
|
||||
"game connect failure keeps the existing LoginPhase UI for retry")
|
||||
_ck(login_status != null and String(login_status.text).contains("ECONNREFUSED"),
|
||||
"game connect failure uses the transport status callback, not credential failure")
|
||||
# 40250 LoginWindow.Connect reuses the existing MainStream owner and only
|
||||
# replaces the AccountConnector transport after a credential failure. The
|
||||
# AppFlow retry must therefore use the bridge's retry_login seam instead of
|
||||
# treating a still-owned LoginPhase as a full disconnect/rebuild.
|
||||
flow._do_connect()
|
||||
_ck(client.retry_login_calls == 1 and client.connect_calls == 0,
|
||||
"login form retry replaces only the authentication transport")
|
||||
var retry_start: int = m2_source.find("void M2Client::retry_login")
|
||||
var retry_end: int = m2_source.find("void M2Client::disconnect_from_server", retry_start)
|
||||
var retry_body: String = m2_source.substr(retry_start, retry_end - retry_start) \
|
||||
if retry_start >= 0 and retry_end > retry_start else ""
|
||||
_ck(retry_body.contains("classic_sess->retry_login")
|
||||
and not retry_body.contains("disconnect_from_server()"),
|
||||
"native LoginPhase retry does not rebuild the whole connection owner")
|
||||
|
||||
# 40250 CPythonNetworkStream::OnRemoteDisconnect calls SetLoginPhase.
|
||||
# SetLoginPhase replaces GameWindow with LoginWindow; it does not retain the
|
||||
# GameScene behind a reconnect mask or auto-select a remembered slot.
|
||||
flow._game = Node.new()
|
||||
flow.add_child(flow._game)
|
||||
flow._state = AppFlow.GAME
|
||||
flow._selected_char_slot = 2
|
||||
client.disconnected.emit("wifi lost")
|
||||
_ck(flow.state() == AppFlow.GAME and flow._reconnecting_game,
|
||||
"game disconnect enters reconnecting state without leaving GAME")
|
||||
_ck(flow._reconnect != null and flow._reconnect.is_showing(),
|
||||
"game disconnect shows reconnect overlay")
|
||||
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
|
||||
and flow._game == null,
|
||||
"game peer close returns to LoginPhase and releases GameScene")
|
||||
_ck(flow._reconnect == null or not flow._reconnect.is_showing(),
|
||||
"game peer close does not retain a reconnect overlay")
|
||||
client.char_list.emit([{"index": 2, "name": "Hero"}])
|
||||
_ck(client.selects == [2], "reconnect re-enters remembered character slot")
|
||||
client.entered_game.emit()
|
||||
_ck(flow.state() == AppFlow.GAME and not flow._reconnecting_game
|
||||
and not flow._reconnect.is_showing(),
|
||||
"successful reconnect restores GAME and hides overlay")
|
||||
var game_snapshot: Dictionary = flow.get_playable_snapshot()
|
||||
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
|
||||
"playable snapshot reports GAME after reconnect")
|
||||
_ck(flow.state() == AppFlow.SELECT and flow._game == null and client.selects.is_empty(),
|
||||
"post-disconnect character list enters SELECT without phantom re-entry")
|
||||
|
||||
# 40250 PHASE_CLOSE -> ClosePhase -> SetLoginPhase has the same LoginPhase
|
||||
# owner even when the bridge reports only the phase observer event.
|
||||
flow._game = Node.new()
|
||||
flow.add_child(flow._game)
|
||||
flow._state = AppFlow.GAME
|
||||
flow._reconnecting_game = false
|
||||
client.phase_changed.emit("login")
|
||||
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
|
||||
and flow._game == null
|
||||
and (flow._reconnect == null or not flow._reconnect.is_showing()),
|
||||
"game-side PHASE_CLOSE transition returns to LoginPhase owner")
|
||||
# The explicit PHASE_CLOSE spelling must use the same owner path as the
|
||||
# native phase-only login notification.
|
||||
flow._game = Node.new()
|
||||
flow.add_child(flow._game)
|
||||
flow._state = AppFlow.GAME
|
||||
client.phase_changed.emit("close")
|
||||
_ck(flow.state() == AppFlow.LOGIN and not flow._reconnecting_game
|
||||
and flow._game == null,
|
||||
"explicit PHASE_CLOSE observer releases GameScene for LoginPhase")
|
||||
|
||||
# 40250 SetSelectCharacterPhase still owns the UI when LoginPhase receives an
|
||||
# empty character list. The empty list must remain SELECT and never select a
|
||||
# stale slot.
|
||||
flow._state = AppFlow.LOGIN
|
||||
flow._reconnecting_game = false
|
||||
client.char_list.emit([])
|
||||
_ck(flow.state() == AppFlow.SELECT and not flow._reconnecting_game
|
||||
and flow._game == null and client.selects.is_empty(),
|
||||
"empty character list returns to the SELECT owner")
|
||||
|
||||
# 40250 ClosePhase/SetLoginPhase also owns the UI when a close arrives
|
||||
# before GAME. The phase-only bridge event must not leave the old SELECT
|
||||
# owner visible after the loading mask is dismissed.
|
||||
var select_ui_before_close: Node = flow._ui
|
||||
client.phase_changed.emit("login")
|
||||
_ck(flow.state() == AppFlow.LOGIN and flow._ui != select_ui_before_close,
|
||||
"pre-game LoginPhase transition replaces the SELECT owner")
|
||||
_ck(client.server_quit_requested.is_connected(Callable(flow, "_on_server_quit_requested")),
|
||||
"server quit command is wired to the application lifecycle owner")
|
||||
# Keep the following auto-login checks independent when the assertion above
|
||||
# is intentionally run against a pre-fix implementation.
|
||||
flow._reconnecting_game = false
|
||||
|
||||
|
||||
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
|
||||
# character, and the chosen slot must be remembered for a later reconnect.
|
||||
|
||||
Reference in New Issue
Block a user