- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay 只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续 运行并记 BLOCKED;写本批次 release-manifest.json 后聚合 - run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理); soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数) - 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试 - forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别 - 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求 - .gitignore 排除本地场景配置、凭据文件与运行输出 离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS, node 夹具测试 PASS。未联网运行,未重建候选包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
239 lines
9.8 KiB
GDScript
239 lines
9.8 KiB
GDScript
class_name PlayableProbe
|
||
extends Node
|
||
|
||
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
|
||
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
|
||
##
|
||
## 每条观察都带 source:
|
||
## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调)
|
||
## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据
|
||
##
|
||
## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次
|
||
## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch
|
||
## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。
|
||
|
||
signal observed(kind: String, payload: Dictionary)
|
||
|
||
const SERVER_SIGNALS := {
|
||
"entered_game": "_on_entered_game",
|
||
"disconnected": "_on_disconnected",
|
||
"login_failed": "_on_login_failed",
|
||
"char_list": "_on_char_list",
|
||
"entity_main_set": "_on_entity_main_set",
|
||
"entity_spawned": "_on_entity_spawned",
|
||
"entity_despawned": "_on_entity_despawned",
|
||
"entity_moved": "_on_entity_moved",
|
||
"entity_dead": "_on_entity_dead",
|
||
"damage": "_on_damage",
|
||
"motion": "_on_motion",
|
||
"vitals_changed": "_on_vitals",
|
||
"points_changed": "_on_points_changed",
|
||
"target_info": "_on_target_info",
|
||
"ground_item_added": "_on_ground_item_added",
|
||
"ground_item_removed": "_on_ground_item_removed",
|
||
"item_picked_up": "_on_item_picked_up",
|
||
"inventory_changed": "_on_inventory_changed",
|
||
"affect_added": "_on_affect_added",
|
||
"effect_cue": "_on_effect_cue",
|
||
"fly_cue": "_on_fly_cue",
|
||
"fly_targeting": "_on_fly_targeting",
|
||
}
|
||
|
||
var client: Object
|
||
var flow: Object
|
||
var connection_epoch := 1
|
||
var _entered_in_epoch := false
|
||
var _connections: Array[Dictionary] = []
|
||
var _last_sp := -1
|
||
|
||
func setup(app_flow: Object, m2client: Object) -> void:
|
||
flow = app_flow
|
||
client = m2client
|
||
if client == null:
|
||
return
|
||
for signal_name in SERVER_SIGNALS:
|
||
_bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name]))
|
||
|
||
## Subscribe to a local presentation object (quickbar / skill fx). Its events
|
||
## are tagged source=local and never satisfy a server-evidence requirement.
|
||
func watch_local(source: Object, signal_name: String, kind: String) -> void:
|
||
if source == null or not source.has_signal(signal_name):
|
||
return
|
||
var callback := func(a: Variant = null, b: Variant = null, c: Variant = null,
|
||
d: Variant = null, e: Variant = null) -> void:
|
||
_emit_local(kind, [a, b, c, d, e])
|
||
_bind(source, signal_name, callback)
|
||
|
||
func snapshot() -> Dictionary:
|
||
var alive := client != null and is_instance_valid(client)
|
||
var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game()
|
||
var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0
|
||
var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0
|
||
var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0
|
||
var flow_alive := flow != null and is_instance_valid(flow)
|
||
var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1
|
||
return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count,
|
||
"ground_item_count": ground_count, "app_state": state}
|
||
|
||
func disconnect_all() -> void:
|
||
for entry in _connections:
|
||
var source: Object = entry.source
|
||
var signal_name: String = entry.signal
|
||
var callback: Callable = entry.callback
|
||
if source != null and is_instance_valid(source) and source.has_signal(signal_name) \
|
||
and source.is_connected(signal_name, callback):
|
||
source.disconnect(signal_name, callback)
|
||
_connections.clear()
|
||
|
||
func bound_count() -> int:
|
||
return _connections.size()
|
||
|
||
func _exit_tree() -> void:
|
||
disconnect_all()
|
||
|
||
func _bind(source: Object, signal_name: String, callback: Callable) -> void:
|
||
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
|
||
source.connect(signal_name, callback)
|
||
_connections.append({"source": source, "signal": signal_name, "callback": callback})
|
||
|
||
func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void:
|
||
var data: Dictionary = payload.duplicate(true)
|
||
data["source"] = "server"
|
||
data["connection_epoch"] = connection_epoch
|
||
data["actor_vid"] = actor_vid
|
||
data["target_vid"] = target_vid
|
||
data["received_us"] = Time.get_ticks_usec()
|
||
observed.emit(kind, data)
|
||
|
||
func _emit_local(kind: String, args: Array) -> void:
|
||
var data := {"source": "local", "connection_epoch": connection_epoch,
|
||
"actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()}
|
||
match kind:
|
||
"cast_started":
|
||
data["skill_id"] = int(args[0]) if args[0] != null else 0
|
||
data["target_vid"] = int(args[1]) if args[1] != null else 0
|
||
"skill_rejected":
|
||
data["skill_id"] = int(args[0]) if args[0] != null else 0
|
||
data["code"] = String(args[1]) if args[1] != null else ""
|
||
"fx_spawned", "fx_finished":
|
||
# EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]).
|
||
data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else ""
|
||
data["fx_id"] = int(args[1]) if args[1] != null else 0
|
||
data["lifetime_ms"] = int(args[2]) if args[2] != null else -1
|
||
if kind == "fx_finished":
|
||
data["elapsed_ms"] = int(args[3]) if args[3] != null else -1
|
||
data["reason"] = String(args[4]) if args[4] != null else ""
|
||
_:
|
||
if args[0] is int:
|
||
data["skill_id"] = args[0]
|
||
observed.emit(kind, data)
|
||
|
||
func _on_entered_game() -> void:
|
||
if _entered_in_epoch:
|
||
connection_epoch += 1
|
||
_entered_in_epoch = true
|
||
_last_sp = -1
|
||
_emit("entered_game")
|
||
|
||
func _on_disconnected(reason: String) -> void:
|
||
_emit("disconnected", {"reason": reason.left(120)})
|
||
connection_epoch += 1
|
||
_entered_in_epoch = false
|
||
_last_sp = -1
|
||
|
||
func _on_login_failed(reason: String) -> void:
|
||
_emit("login_failed", {"reason": reason.left(120)})
|
||
|
||
func _on_char_list(chars: Array) -> void:
|
||
var slots: Array = []
|
||
for character in chars:
|
||
if character is Dictionary:
|
||
slots.append(int(character.get("index", -1)))
|
||
_emit("char_list", {"count": chars.size(), "slots": slots})
|
||
|
||
func _on_entity_main_set(vid: int) -> void:
|
||
_emit("entity_main_set", {}, vid)
|
||
|
||
func _on_entity_spawned(entity: Dictionary) -> void:
|
||
_emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)),
|
||
"dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)},
|
||
int(entity.get("vid", 0)))
|
||
|
||
func _on_entity_despawned(vid: int) -> void:
|
||
_emit("entity_despawned", {}, vid)
|
||
|
||
func _on_entity_moved(vid: int) -> void:
|
||
var pos: Variant = Vector3.ZERO
|
||
if client != null and client.has_method("get_entity"):
|
||
pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO)
|
||
_emit("entity_moved", {"pos_cm": pos}, vid)
|
||
|
||
func _on_entity_dead(vid: int) -> void:
|
||
_emit("entity_dead", {}, 0, vid)
|
||
|
||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||
_emit("damage", {"amount": amount, "flag": flag}, 0, vid)
|
||
|
||
func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
|
||
_emit("server_motion", {"motion": motion}, vid, victim_vid)
|
||
|
||
func _on_vitals(vid: int) -> void:
|
||
var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {}
|
||
_emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)),
|
||
"dead": bool(entity.get("dead", false))}, vid)
|
||
|
||
func _on_points_changed(points: Dictionary) -> void:
|
||
# points_changed carries the main character's point table. Only a change in
|
||
# SP is interesting as skill-resource evidence.
|
||
var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1
|
||
if sp >= 0 and _last_sp >= 0 and sp != _last_sp:
|
||
_emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp})
|
||
if sp >= 0:
|
||
_last_sp = sp
|
||
|
||
func _on_target_info(vid: int, hp_percent: int) -> void:
|
||
_emit("target_info", {"hp_percent": hp_percent}, 0, vid)
|
||
|
||
func _on_ground_item_added(item: Dictionary) -> void:
|
||
_emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)),
|
||
"owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)})
|
||
|
||
func _on_ground_item_removed(vid: int) -> void:
|
||
_emit("ground_item_removed", {"iid": vid})
|
||
|
||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||
# `source` may be a player name; keep only whether it was attributed at all.
|
||
_emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()})
|
||
|
||
func _on_inventory_changed(window: int, cell: int) -> void:
|
||
_emit("inventory_changed", {"window": window, "cell": cell})
|
||
|
||
func _on_affect_added(affect: Dictionary) -> void:
|
||
_emit("affect_added", {"type": int(affect.get("type", 0))})
|
||
|
||
func _on_effect_cue(vid: int, name: String, special: int) -> void:
|
||
_emit("effect_cue", {"effect": name.get_file(), "special": special}, vid)
|
||
|
||
func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void:
|
||
_emit("fly_cue", {"type": kind}, start_vid, end_vid)
|
||
|
||
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void:
|
||
_emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid)
|
||
|
||
## Ownership is evaluated in-process so the owner's name never reaches a report:
|
||
## an empty owner is free-for-all, otherwise it must equal the main character.
|
||
func _owner_matches(owner: String) -> bool:
|
||
if owner.is_empty():
|
||
return true
|
||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||
return false
|
||
var main: Dictionary = client.get_entity(int(client.get_main_vid()))
|
||
return String(main.get("name", "")) == owner
|
||
|
||
func _ground_pos_cm(item: Dictionary) -> Vector2:
|
||
var pos: Variant = item.get("pos", null)
|
||
if pos is Vector3:
|
||
# M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm.
|
||
return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0))
|
||
return Vector2(-1, -1)
|