feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 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
This commit is contained in:
@@ -3,113 +3,236 @@ 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)
|
||||
|
||||
var client: Node
|
||||
var flow: Node
|
||||
var connection_epoch := 0
|
||||
var _connections: Array[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",
|
||||
}
|
||||
|
||||
func setup(app_flow: Node, m2client: Node) -> void:
|
||||
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
|
||||
_bind("entered_game", Callable(self, "_on_entered_game"))
|
||||
_bind("disconnected", Callable(self, "_on_disconnected"))
|
||||
_bind("char_list", Callable(self, "_on_char_list"))
|
||||
_bind("entity_spawned", Callable(self, "_on_entity_spawned"))
|
||||
_bind("entity_despawned", Callable(self, "_on_entity_despawned"))
|
||||
_bind("entity_moved", Callable(self, "_on_entity_moved"))
|
||||
_bind("entity_dead", Callable(self, "_on_entity_dead"))
|
||||
_bind("damage", Callable(self, "_on_damage"))
|
||||
_bind("vitals_changed", Callable(self, "_on_vitals"))
|
||||
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
|
||||
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
|
||||
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
|
||||
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
|
||||
_bind("effect_cue", Callable(self, "_on_effect_cue"))
|
||||
_bind("fly_cue", Callable(self, "_on_fly_cue"))
|
||||
_bind("fly_targeting", Callable(self, "_on_fly_targeting"))
|
||||
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 in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0
|
||||
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0
|
||||
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1
|
||||
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, "connection_epoch": connection_epoch}
|
||||
"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 client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback):
|
||||
client.disconnect(signal_name, 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(signal_name: String, callback: Callable) -> void:
|
||||
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback):
|
||||
client.connect(signal_name, callback)
|
||||
_connections.append({"signal": signal_name, "callback": callback})
|
||||
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 := {}) -> void:
|
||||
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:
|
||||
connection_epoch += 1
|
||||
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})
|
||||
_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:
|
||||
_emit("char_list", {"count": chars.size()})
|
||||
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", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))})
|
||||
_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": vid})
|
||||
_emit("entity_despawned", {}, vid)
|
||||
|
||||
func _on_entity_moved(vid: int) -> void:
|
||||
_emit("entity_moved", {"vid": vid})
|
||||
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", {"vid": vid})
|
||||
_emit("entity_dead", {}, 0, vid)
|
||||
|
||||
func _on_damage(vid: int, amount: int, flag: int) -> void:
|
||||
_emit("damage", {"vid": vid, "amount": amount, "flag": flag})
|
||||
_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:
|
||||
_emit("vitals_changed", {"vid": vid})
|
||||
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", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))})
|
||||
_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", {"vid": vid})
|
||||
_emit("ground_item_removed", {"iid": vid})
|
||||
|
||||
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
|
||||
_emit("item_picked_up", {"vnum": vnum, "count": count, "source": source})
|
||||
# `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", {"vid": vid, "name": name, "special": special})
|
||||
_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": start_vid, "end_vid": end_vid})
|
||||
_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", {"shooter_vid": shooter_vid, "target_vid": target_vid,
|
||||
"target_cm": [target_cm.x, target_cm.y], "append": append})
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user